Compare commits

...

112 Commits

Author SHA1 Message Date
opencode 81deea855f release: v0.11.6 2025-09-26 09:52:23 +00:00
Dax Raad 5c67bebf86 tui: fix cursor position offset in home screen 2025-09-26 05:46:16 -04:00
opencode 9cc1f2884f release: v0.11.5 2025-09-26 09:40:16 +00:00
Dax Raad f2b547cc45 fix erroring on custom tool folder 2025-09-26 05:33:38 -04:00
Dax Raad 70310a37b3 validate config directory 2025-09-26 03:23:25 -04:00
Dax Raad eb7f4e20df core: add config update endpoint and functionality 2025-09-26 02:37:19 -04:00
Dax Raad ea21bfd3c6 ci: ignore 2025-09-26 02:01:19 -04:00
Dax Raad 22d5be9bf8 ci: setup husky pre-push hook to run typecheck 2025-09-26 02:01:19 -04:00
opencode 1c878c662b release: v0.11.4 2025-09-26 05:56:03 +00:00
Dax Raad 55d154d4ac tui: fix opencode logo spacing in home view 2025-09-26 01:40:32 -04:00
Dax Raad f5c7a94abe turn reasoning summaries back on by default for zen 2025-09-26 01:37:44 -04:00
Dax Raad 7ec3900208 core: enable reasoning.encrypted_content and reasoningSummary for opencode provider\ntui: adjust editorY position calculation 2025-09-26 01:27:53 -04:00
Aiden Cline 5d95846df1 fix: openai reasoning issue (#2780) 2025-09-26 01:23:30 -04:00
Aiden Cline d47feb9969 tweak: include usage by default for openai compatible providers (#2788) 2025-09-25 21:06:58 -05:00
David Hill 8f135d13e3 Update opencode logo 2025-09-25 23:22:50 +01:00
Frank f9ab4102f6 zen: track tps 2025-09-25 17:56:41 -04:00
Frank f9117bcc7f zen: check balance on enable billing 2025-09-25 17:47:50 -04:00
Frank 6e712f9faf zen: fix parsing cache write tokens 2025-09-25 17:02:23 -04:00
Adam b207ed2b7b wip: better desktop file status state and timeline 2025-09-25 14:41:31 -05:00
Adam 945de4eddc wip: watch select .git files in watcher 2025-09-25 14:41:31 -05:00
GitHub Action cd655177d9 ignore: update download stats 2025-09-25 2025-09-25 12:04:27 +00:00
Frank 8f90497fc4 zen: billing 2025-09-24 21:26:04 -04:00
GitHub Action 9659efca46 chore: format code 2025-09-24 23:10:17 +00:00
Frank d0377a95cf zen: billing 2025-09-24 19:09:28 -04:00
Adam 3b20bf6d4f fix(app): changes view 2025-09-24 15:49:40 -05:00
Adam c3e52580b0 feat(app): changes view 2025-09-24 15:46:33 -05:00
Adam 2badfcdcf4 fix: select dialog hover 2025-09-24 15:08:58 -05:00
Adam f589fc2327 feat: fuzzy file open 2025-09-24 12:40:54 -05:00
Filip d3b6545e7c feat(app): added command palette (#2630)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-09-24 11:05:15 -05:00
GitHub Action 3f911b22b0 ignore: update download stats 2025-09-24 2025-09-24 12:04:37 +00:00
opencode 5199141369 release: v0.11.3 2025-09-24 03:43:46 +00:00
Dax Raad d86d3e7ea1 update copilot auth 2025-09-23 23:36:27 -04:00
GitHub Action fe8d29cb2b chore: format code 2025-09-23 21:59:09 +00:00
Frank edd6198999 zen: refund 2025-09-23 17:58:27 -04:00
GitHub Action c3b2c27997 chore: format code 2025-09-23 21:37:20 +00:00
Jay V 679aeb29f0 docs: add codex to zen 2025-09-23 17:36:40 -04:00
Jay V 190413580f docs: edit 2025-09-23 17:34:52 -04:00
Jay V 8c9fbc7717 docs: edits 2025-09-23 17:34:52 -04:00
Jay V 9d3fdda674 docs: edit 2025-09-23 17:34:52 -04:00
Siddhant Choudhary 449994f120 feat: output-format flag to stream json output (#2471)
Co-authored-by: Siddhant Choudhary <sid@treaps.com>
Co-authored-by: rekram1-node <aidenpcline@gmail.com>
2025-09-23 16:19:32 -05:00
opencode d772fff776 release: v0.11.2 2025-09-23 21:03:59 +00:00
Dax Raad 71b43fd02e fix codex errors 2025-09-23 16:56:40 -04:00
Dax Raad f40b91ab7a fix: remove file existence check from LSP debug and format storage code 2025-09-23 16:49:57 -04:00
Alain Schlesser 6404bd006d ignore: more reliable install script, handle non prettified json responses (#2745)
Co-authored-by: rekram1-node <aidenpcline@gmail.com>
2025-09-23 14:58:04 -05:00
Frank 75157e515c wip: remove 2025-09-23 14:21:39 -04:00
Frank 5a96ee8e1b zen: update logged endpoints 2025-09-23 14:21:39 -04:00
Adam ee6ceb4c64 fix: open text files 2025-09-23 12:43:24 -05:00
David Hill 9d53628e19 Merge branch 'dev' of https://github.com/sst/opencode into dev 2025-09-23 18:36:27 +01:00
David Hill 869b476145 ignore: Copy update 2025-09-23 18:35:42 +01:00
David Hill 223d487787 ignore: Footer update 2025-09-23 18:30:06 +01:00
Adam 5ead6d7dd5 fix: exclude generated css file 2025-09-23 12:08:49 -05:00
GitHub Action a98454217f chore: format code 2025-09-23 17:05:32 +00:00
Adam cbb75d8577 fix: theme css format 2025-09-23 12:04:50 -05:00
GitHub Action 4ab992a9a9 chore: format code 2025-09-23 16:41:57 +00:00
Adam 80b0a93d64 wip: desktop file updates 2025-09-23 11:41:15 -05:00
Grégoire Paris e749d48534 docs: fix grammar mistake in lsp docs(#2744) 2025-09-23 08:12:46 -05:00
GitHub Action d7e873f807 ignore: update download stats 2025-09-23 2025-09-23 12:04:24 +00:00
Aiden Cline c23510346b ignore: lsp debug file check (#2743) 2025-09-22 22:16:03 -05:00
Aiden Cline f9c5df05a1 docs: github copilot model enable note (#2741) 2025-09-22 21:29:16 -05:00
Aiden Cline 02b4d1e2fc fix: lsp extension undefined handle (#2739) 2025-09-22 21:14:55 -05:00
Joseph Hanson 0b36eb8760 docs: fix permissions link (#2738) 2025-09-22 20:28:54 -05:00
Adam Hosker 36bec9948c docs: Add editor setup tip (#2638) 2025-09-22 16:01:27 -04:00
GitHub Action 2db73c39df chore: format code 2025-09-22 19:59:29 +00:00
Jay V 6107666d04 docs: edit tools doc 2025-09-22 15:58:48 -04:00
Aiden Cline cc2bd7141f fix: enforce extensions requirement for custom lsp servers (#2734) 2025-09-22 11:45:47 -05:00
GitHub Action ee442975df ignore: update download stats 2025-09-22 2025-09-22 12:04:25 +00:00
Dax Raad 9b1a508657 ci: bump 2025-09-22 01:47:28 -04:00
Dax Raad 288c977596 ci: snapshot builds 2025-09-22 01:45:23 -04:00
iwauo 6b799b304c feat: add Java LSP server support (#2547)
Co-authored-by: rekram1-node <aidenpcline@gmail.com>
2025-09-21 23:55:15 -05:00
Aiden Cline 92c126d875 fix: lsp spawn logic (#2723) 2025-09-21 11:25:47 -05:00
GitHub Action 7123fbeb47 ignore: update download stats 2025-09-21 2025-09-21 12:04:06 +00:00
opencode 84bb692193 release: v0.11.1 2025-09-21 08:51:11 +00:00
Dax Raad 079095d7a9 core: filter models without keys in opencode provider 2025-09-21 04:43:32 -04:00
opencode 28e1d67ea4 release: v0.11.0 2025-09-21 08:04:21 +00:00
GitHub Action c1940d1d2c chore: format code 2025-09-21 04:23:40 +00:00
Frank 869f629c14 wip: zen 2025-09-21 00:23:05 -04:00
Frank a55943e469 wip: zen 2025-09-21 00:06:50 -04:00
Aiden Cline 84d95a0d2a ignore: lsp log (#2715) 2025-09-20 23:02:06 -05:00
opencode 7dfed8ca35 release: v0.10.4 2025-09-20 23:00:32 +00:00
Dax Raad 38ea0fc051 turn back on compaction summaries 2025-09-20 18:52:30 -04:00
Osinachi Okpara 9223b6ed8f Enhance theme documentation with links (#2707)
Co-authored-by: rekram1-node <aidenpcline@gmail.com>
2025-09-20 11:25:12 -05:00
GitHub Action f8528c52d9 ignore: update download stats 2025-09-20 2025-09-20 12:04:03 +00:00
Aiden Cline d63ce40af2 fix: no payment method (#2706) 2025-09-20 04:15:44 -04:00
Jay V 5acdd70587 docs: zen 2025-09-19 20:13:57 -04:00
opencode b04df6c0d2 release: v0.10.3 2025-09-19 21:22:00 +00:00
GitHub Action f1cbdf441c chore: format code 2025-09-19 18:18:56 +00:00
Frank 9420d80b73 zen: data share 2025-09-19 14:16:53 -04:00
Aiden Cline c21161b75e docs: fix bad docs (#2691) 2025-09-19 12:40:40 -05:00
GitHub Action aaff066457 chore: format code 2025-09-19 17:29:44 +00:00
Jay V c7fbf9de44 ignore: zen 2025-09-19 13:29:04 -04:00
Adam d88c17dad0 wip: desktop progress 2025-09-19 10:53:49 -05:00
opencode f57c3f7cf6 release: v0.10.2 2025-09-19 15:18:11 +00:00
Adam 2460108223 fix: remove grok msg 2025-09-19 09:59:47 -05:00
Adam 84e8eea52e wip: desktop cleanup 2025-09-19 09:55:46 -05:00
Adam 9efc2eaf2e wip: desktop cleanup 2025-09-19 09:42:39 -05:00
Adam 37e2644452 wip: desktop visual tweaks 2025-09-19 09:38:10 -05:00
neriousy 22a78cf13f chore: opencode favicon 2025-09-19 09:33:28 -05:00
GitHub Action 2e9806b320 chore: format code 2025-09-19 14:32:55 +00:00
Adam ba839d4446 chore: normalize theme hex 2025-09-19 09:32:19 -05:00
GitHub Action 2bec21d81d chore: format code 2025-09-19 12:19:13 +00:00
Adam e5271f3d1a wip: desktop work 2025-09-19 07:18:39 -05:00
Adam 1edb23c2c7 wip: desktop work 2025-09-19 07:18:39 -05:00
Adam b1e6b9c7c9 wip: desktop work 2025-09-19 07:18:39 -05:00
Adam 20cb5a7c56 wip: desktop starting state 2025-09-19 07:18:38 -05:00
GitHub Action b1d44482bc ignore: update download stats 2025-09-19 2025-09-19 12:04:16 +00:00
GitHub Action e11102c9df chore: format code 2025-09-19 10:14:30 +00:00
Dax Raad 7be9dc8e49 ignore: fix 2025-09-19 06:13:45 -04:00
Dax Raad 824e035815 ci: stuff 2025-09-19 06:10:27 -04:00
GitHub Action d652b94a14 chore: format code 2025-09-19 09:29:24 +00:00
Dax Raad ebef2ea2d0 ci: stuff 2025-09-19 05:28:46 -04:00
GitHub Action b5b8a0555d chore: format code 2025-09-19 09:12:20 +00:00
Dax Raad ae6154e1c3 ignore: rework bootstrap so server lazy starts it 2025-09-19 05:11:29 -04:00
118 changed files with 6996 additions and 6701 deletions
+50
View File
@@ -0,0 +1,50 @@
name: snapshot
on:
push:
branches:
- dev
- opentui
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v5
with:
go-version: ">=1.24.0"
cache: true
cache-dependency-path: go.sum
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.21
- name: Cache ~/.bun
id: cache-bun
uses: actions/cache@v3
with:
path: ~/.bun
key: ${{ runner.os }}-bun-1-2-21-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-1-2-21-
- name: Install dependencies
run: bun install
- name: Publish
run: |
./packages/opencode/script/publish.ts
env:
OPENCODE_SNAPSHOT: true
OPENCODE_TAG: ${{ github.ref_name }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
bun prettier --write --ignore-unknown $(git diff --name-only HEAD~1 HEAD)
bun run typecheck
-11
View File
@@ -1,11 +0,0 @@
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "call this tool when you want to give up",
args: {
message: tool.schema.string().describe("give up message"),
},
async execute(args) {
return "Hey fuck you!"
},
})
+7
View File
@@ -83,3 +83,10 @@
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
+176 -632
View File
File diff suppressed because it is too large Load Diff
+3 -19
View File
@@ -75,6 +75,7 @@ export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
"checkout.session.async_payment_succeeded",
"checkout.session.completed",
"checkout.session.expired",
"charge.refunded",
"customer.created",
"customer.deleted",
"customer.updated",
@@ -93,17 +94,10 @@ export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
"customer.subscription.resumed",
"customer.subscription.trial_will_end",
"customer.subscription.updated",
"customer.tax_id.created",
"customer.tax_id.deleted",
"customer.tax_id.updated",
],
})
const ANTHROPIC_API_KEY = new sst.Secret("ANTHROPIC_API_KEY")
const OPENAI_API_KEY = new sst.Secret("OPENAI_API_KEY")
const XAI_API_KEY = new sst.Secret("XAI_API_KEY")
const BASETEN_API_KEY = new sst.Secret("BASETEN_API_KEY")
const FIREWORKS_API_KEY = new sst.Secret("FIREWORKS_API_KEY")
const ZEN_MODELS = new sst.Secret("ZEN_MODELS")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
@@ -128,17 +122,7 @@ if ($app.stage === "production" || $app.stage === "frank") {
new sst.cloudflare.x.SolidStart("Console", {
domain,
path: "packages/console/app",
link: [
database,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
STRIPE_SECRET_KEY,
ANTHROPIC_API_KEY,
OPENAI_API_KEY,
XAI_API_KEY,
BASETEN_API_KEY,
FIREWORKS_API_KEY,
],
link: [database, AUTH_API_URL, STRIPE_WEBHOOK_SECRET, STRIPE_SECRET_KEY, ZEN_MODELS],
environment: {
//VITE_DOCS_URL: web.url.apply((url) => url!),
//VITE_API_URL: gateway.url.apply((url) => url!),
+1 -1
View File
@@ -4,7 +4,7 @@ new sst.cloudflare.StaticSite("Desktop", {
domain: "desktop." + domain,
path: "packages/app",
build: {
command: "bun run build",
command: "bun turbo build",
output: "./dist",
},
})
+1 -1
View File
@@ -46,7 +46,7 @@ mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then
url="https://github.com/sst/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
+4 -5
View File
@@ -7,8 +7,7 @@
"scripts": {
"dev": "bun run --conditions=development packages/opencode/src/index.ts",
"typecheck": "bun turbo typecheck",
"generate": "(cd packages/sdk && ./js/script/generate.ts) && (cd packages/sdk/stainless && ./generate.ts)",
"postinstall": "./script/hooks"
"prepare": "husky"
},
"workspaces": {
"packages": [
@@ -23,16 +22,16 @@
"@tsconfig/node22": "22.0.2",
"ai": "5.0.8",
"hono": "4.7.10",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"typescript": "5.8.2",
"zod": "4.1.8",
"remeda": "2.26.0",
"solid-js": "1.9.9"
}
},
"dependencies": {
"pulumi-stripe": "0.0.24"
},
"devDependencies": {
"husky": "9.1.7",
"prettier": "3.6.2",
"sst": "3.17.13",
"turbo": "2.5.6"
+1
View File
@@ -0,0 +1 @@
src/assets/theme.css
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.svg" />
<link rel="stylesheet" href="/src/assets/theme.css" />
<title>opencode</title>
</head>
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "0.10.1",
"version": "0.11.6",
"description": "",
"type": "module",
"scripts": {
@@ -24,12 +24,14 @@
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "workspace:*",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solidjs/router": "0.15.3",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "8.0.2",
"luxon": "3.7.1",
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
"remeda": "catalog:",
+11 -8
View File
@@ -35,18 +35,19 @@ class ColorResolver {
try {
if (typeof value === "string") {
if (value.startsWith("#") || value === "none") {
return { dark: value, light: value }
if (value === "none") return { dark: value, light: value }
if (value.startsWith("#")) {
return { dark: value.toLowerCase(), light: value.toLowerCase() }
}
const resolved = this.resolveReference(value)
return { dark: resolved, light: resolved }
}
if (typeof value === "object" && value !== null) {
const dark = this.resolveColorValue(value.dark || value.light || "#000000")
const light = this.resolveColorValue(value.light || value.dark || "#ffffff")
const light = this.resolveColorValue(value.light || value.dark || "#FFFFFF")
return { dark, light }
}
return { dark: "#000000", light: "#ffffff" }
return { dark: "#000000", light: "#FFFFFF" }
} finally {
this.visited.delete(key)
}
@@ -54,8 +55,9 @@ class ColorResolver {
private resolveColorValue(value: any): string {
if (typeof value === "string") {
if (value.startsWith("#") || value === "none") {
return value
if (value === "none") return value
if (value.startsWith("#")) {
return value.toLowerCase()
}
return this.resolveReference(value)
}
@@ -68,8 +70,9 @@ class ColorResolver {
throw new Error(`Color reference '${ref}' not found`)
}
if (typeof colorValue === "string") {
if (colorValue.startsWith("#") || colorValue === "none") {
return colorValue
if (colorValue === "none") return colorValue
if (colorValue.startsWith("#")) {
return colorValue.toLowerCase()
}
return this.resolveReference(colorValue)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="600" height="600" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M115 180H300V420H115V180ZM253.75 229.044H161.25V370.405H253.75V229.044Z" fill="white"/>
<path d="M346.25 180H485V229.044H392.5V370.405H485V419.449H346.25V180Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -18,7 +18,7 @@ export default function FileTree(props: {
<Dynamic
component={p.as ?? "div"}
classList={{
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-panel cursor-pointer": true,
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element cursor-pointer": true,
"bg-background-element": local.file.active()?.path === p.node.path,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
+4 -582
View File
@@ -1,595 +1,17 @@
import { transformerNotationDiff } from "@shikijs/transformers"
import { marked } from "marked"
import markedShiki from "marked-shiki"
import { codeToHtml } from "shiki"
import { useMarked } from "@/context"
import { createResource } from "solid-js"
const markedWithShiki = marked.use(
markedShiki({
highlight(code, lang) {
return codeToHtml(code, {
// structure: "inline",
lang: lang || "text",
tabindex: false,
theme: {
colors: {
"actionBar.toggledBackground": "var(--theme-background-element)",
"activityBarBadge.background": "var(--theme-accent)",
"checkbox.border": "var(--theme-border)",
"editor.background": "transparent",
"editor.foreground": "var(--theme-text)",
"editor.inactiveSelectionBackground": "var(--theme-background-element)",
"editor.selectionHighlightBackground": "var(--theme-border-active)",
"editorIndentGuide.activeBackground1": "var(--theme-border-subtle)",
"editorIndentGuide.background1": "var(--theme-border-subtle)",
"input.placeholderForeground": "var(--theme-text-muted)",
"list.activeSelectionIconForeground": "var(--theme-text)",
"list.dropBackground": "var(--theme-background-element)",
"menu.background": "var(--theme-background-panel)",
"menu.border": "var(--theme-border)",
"menu.foreground": "var(--theme-text)",
"menu.selectionBackground": "var(--theme-primary)",
"menu.separatorBackground": "var(--theme-border)",
"ports.iconRunningProcessForeground": "var(--theme-success)",
"sideBarSectionHeader.background": "transparent",
"sideBarSectionHeader.border": "var(--theme-border-subtle)",
"sideBarTitle.foreground": "var(--theme-text-muted)",
"statusBarItem.remoteBackground": "var(--theme-success)",
"statusBarItem.remoteForeground": "var(--theme-text)",
"tab.lastPinnedBorder": "var(--theme-border-subtle)",
"tab.selectedBackground": "var(--theme-background-element)",
"tab.selectedForeground": "var(--theme-text-muted)",
"terminal.inactiveSelectionBackground": "var(--theme-background-element)",
"widget.border": "var(--theme-border)",
},
displayName: "opencode",
name: "opencode",
semanticHighlighting: true,
semanticTokenColors: {
customLiteral: "var(--theme-syntax-function)",
newOperator: "var(--theme-syntax-operator)",
numberLiteral: "var(--theme-syntax-number)",
stringLiteral: "var(--theme-syntax-string)",
},
tokenColors: [
{
scope: [
"meta.embedded",
"source.groovy.embedded",
"string meta.image.inline.markdown",
"variable.legacy.builtin.python",
],
settings: {
foreground: "var(--theme-text)",
},
},
{
scope: "emphasis",
settings: {
fontStyle: "italic",
},
},
{
scope: "strong",
settings: {
fontStyle: "bold",
},
},
{
scope: "header",
settings: {
foreground: "var(--theme-markdown-heading)",
},
},
{
scope: "comment",
settings: {
foreground: "var(--theme-syntax-comment)",
},
},
{
scope: "constant.language",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: [
"constant.numeric",
"variable.other.enummember",
"keyword.operator.plus.exponent",
"keyword.operator.minus.exponent",
],
settings: {
foreground: "var(--theme-syntax-number)",
},
},
{
scope: "constant.regexp",
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: "entity.name.tag",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: ["entity.name.tag.css", "entity.name.tag.less"],
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: "entity.other.attribute-name",
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: [
"entity.other.attribute-name.class.css",
"source.css entity.other.attribute-name.class",
"entity.other.attribute-name.id.css",
"entity.other.attribute-name.parent-selector.css",
"entity.other.attribute-name.parent.less",
"source.css entity.other.attribute-name.pseudo-class",
"entity.other.attribute-name.pseudo-element.css",
"source.css.less entity.other.attribute-name.id",
"entity.other.attribute-name.scss",
],
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: "invalid",
settings: {
foreground: "var(--theme-error)",
},
},
{
scope: "markup.underline",
settings: {
fontStyle: "underline",
},
},
{
scope: "markup.bold",
settings: {
fontStyle: "bold",
foreground: "var(--theme-markdown-strong)",
},
},
{
scope: "markup.heading",
settings: {
fontStyle: "bold",
foreground: "var(--theme-markdown-heading)",
},
},
{
scope: "markup.italic",
settings: {
fontStyle: "italic",
},
},
{
scope: "markup.strikethrough",
settings: {
fontStyle: "strikethrough",
},
},
{
scope: "markup.inserted",
settings: {
foreground: "var(--theme-diff-added)",
},
},
{
scope: "markup.deleted",
settings: {
foreground: "var(--theme-diff-removed)",
},
},
{
scope: "markup.changed",
settings: {
foreground: "var(--theme-diff-context)",
},
},
{
scope: "punctuation.definition.quote.begin.markdown",
settings: {
foreground: "var(--theme-markdown-block-quote)",
},
},
{
scope: "punctuation.definition.list.begin.markdown",
settings: {
foreground: "var(--theme-markdown-list-enumeration)",
},
},
{
scope: "markup.inline.raw",
settings: {
foreground: "var(--theme-markdown-code)",
},
},
{
scope: "punctuation.definition.tag",
settings: {
foreground: "var(--theme-syntax-punctuation)",
},
},
{
scope: ["meta.preprocessor", "entity.name.function.preprocessor"],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "meta.preprocessor.string",
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: "meta.preprocessor.numeric",
settings: {
foreground: "var(--theme-syntax-number)",
},
},
{
scope: "meta.structure.dictionary.key.python",
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: "meta.diff.header",
settings: {
foreground: "var(--theme-diff-hunk-header)",
},
},
{
scope: "storage",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "storage.type",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: ["storage.modifier", "keyword.operator.noexcept"],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: ["string", "meta.embedded.assembly"],
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: "string.tag",
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: "string.value",
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: "string.regexp",
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: [
"punctuation.definition.template-expression.begin",
"punctuation.definition.template-expression.end",
"punctuation.section.embedded",
],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: ["meta.template.expression"],
settings: {
foreground: "var(--theme-text)",
},
},
{
scope: [
"support.type.vendored.property-name",
"support.type.property-name",
"source.css variable",
"source.coffee.embedded",
],
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: "keyword",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "keyword.control",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "keyword.operator",
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: [
"keyword.operator.new",
"keyword.operator.expression",
"keyword.operator.cast",
"keyword.operator.sizeof",
"keyword.operator.alignof",
"keyword.operator.typeid",
"keyword.operator.alignas",
"keyword.operator.instanceof",
"keyword.operator.logical.python",
"keyword.operator.wordlike",
],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "keyword.other.unit",
settings: {
foreground: "var(--theme-syntax-number)",
},
},
{
scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "support.function.git-rebase",
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: "constant.sha.git-rebase",
settings: {
foreground: "var(--theme-syntax-number)",
},
},
{
scope: [
"storage.modifier.import.java",
"variable.language.wildcard.java",
"storage.modifier.package.java",
],
settings: {
foreground: "var(--theme-text)",
},
},
{
scope: "variable.language",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: [
"entity.name.function",
"support.function",
"support.constant.handlebars",
"source.powershell variable.other.member",
"entity.name.operator.custom-literal",
],
settings: {
foreground: "var(--theme-syntax-function)",
},
},
{
scope: [
"support.class",
"support.type",
"entity.name.type",
"entity.name.namespace",
"entity.other.attribute",
"entity.name.scope-resolution",
"entity.name.class",
"storage.type.numeric.go",
"storage.type.byte.go",
"storage.type.boolean.go",
"storage.type.string.go",
"storage.type.uintptr.go",
"storage.type.error.go",
"storage.type.rune.go",
"storage.type.cs",
"storage.type.generic.cs",
"storage.type.modifier.cs",
"storage.type.variable.cs",
"storage.type.annotation.java",
"storage.type.generic.java",
"storage.type.java",
"storage.type.object.array.java",
"storage.type.primitive.array.java",
"storage.type.primitive.java",
"storage.type.token.java",
"storage.type.groovy",
"storage.type.annotation.groovy",
"storage.type.parameters.groovy",
"storage.type.generic.groovy",
"storage.type.object.array.groovy",
"storage.type.primitive.array.groovy",
"storage.type.primitive.groovy",
],
settings: {
foreground: "var(--theme-syntax-type)",
},
},
{
scope: [
"meta.type.cast.expr",
"meta.type.new.expr",
"support.constant.math",
"support.constant.dom",
"support.constant.json",
"entity.other.inherited-class",
"punctuation.separator.namespace.ruby",
],
settings: {
foreground: "var(--theme-syntax-type)",
},
},
{
scope: [
"keyword.control",
"source.cpp keyword.operator.new",
"keyword.operator.delete",
"keyword.other.using",
"keyword.other.directive.using",
"keyword.other.operator",
"entity.name.operator",
],
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: [
"variable",
"meta.definition.variable.name",
"support.variable",
"entity.name.variable",
"constant.other.placeholder",
],
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: ["variable.other.constant", "variable.other.enummember"],
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: ["meta.object-literal.key"],
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: [
"support.constant.property-value",
"support.constant.font-name",
"support.constant.media-type",
"support.constant.media",
"constant.other.color.rgb-value",
"constant.other.rgb-value",
"support.constant.color",
],
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: [
"punctuation.definition.group.regexp",
"punctuation.definition.group.assertion.regexp",
"punctuation.definition.character-class.regexp",
"punctuation.character.set.begin.regexp",
"punctuation.character.set.end.regexp",
"keyword.operator.negation.regexp",
"support.other.parenthesis.regexp",
],
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: [
"constant.character.character-class.regexp",
"constant.other.character-class.set.regexp",
"constant.other.character-class.regexp",
"constant.character.set.regexp",
],
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: "keyword.operator.quantifier.regexp",
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: ["constant.character", "constant.other.option"],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "constant.character.escape",
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: "entity.name.label",
settings: {
foreground: "var(--theme-text-muted)",
},
},
],
type: "dark",
},
transformers: [transformerNotationDiff()],
})
},
}),
)
function strip(text: string): string {
const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
const match = text.match(wrappedRe)
return match ? match[2] : text
}
export default function Markdown(props: { text: string; class?: string }) {
export function Markdown(props: { text: string; class?: string }) {
const marked = useMarked()
const [html] = createResource(
() => strip(props.text),
async (markdown) => {
return markedWithShiki.parse(markdown)
return marked.parse(markdown)
},
)
return (
@@ -0,0 +1,225 @@
import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Icon, IconButton } from "@/ui"
import { createStore } from "solid-js/store"
import { entries, flatMap, groupBy, map, pipe } from "remeda"
import { createList } from "solid-list"
import fuzzysort from "fuzzysort"
interface SelectDialogProps<T> {
items: T[] | ((filter: string) => Promise<T[]>)
key: (item: T) => string
render: (item: T) => JSX.Element
filter?: string[]
current?: T
placeholder?: string
groupBy?: (x: T) => string
onSelect?: (value: T | undefined) => void
onClose?: () => void
}
export function SelectDialog<T>(props: SelectDialogProps<T>) {
let scrollRef: HTMLDivElement | undefined
const [store, setStore] = createStore({
filter: "",
mouseActive: false,
})
const [grouped] = createResource(
() => store.filter,
async (filter) => {
const needle = filter.toLowerCase()
const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
const result = pipe(
all,
(x) => {
if (!needle) return x
if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) {
return fuzzysort.go(needle, x).map((x) => x.target) as T[]
}
return fuzzysort.go(needle, x, { keys: props.filter! }).map((x) => x.obj)
},
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
// mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
entries(),
map(([k, v]) => ({ category: k, items: v })),
)
return result
},
)
const flat = createMemo(() => {
return pipe(
grouped() || [],
flatMap((x) => x.items),
)
})
const list = createList({
items: () => flat().map(props.key),
initialActive: props.current ? props.key(props.current) : undefined,
loop: true,
})
const resetSelection = () => {
const all = flat()
if (all.length === 0) return
list.setActive(props.key(all[0]))
}
createEffect(() => {
store.filter
scrollRef?.scrollTo(0, 0)
resetSelection()
})
createEffect(() => {
const all = flat()
if (store.mouseActive || all.length === 0) return
if (list.active() === props.key(all[0])) {
scrollRef?.scrollTo(0, 0)
return
}
const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`)
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
})
const handleInput = (value: string) => {
setStore("filter", value)
resetSelection()
}
const handleSelect = (item: T) => {
props.onSelect?.(item)
props.onClose?.()
}
const handleKey = (e: KeyboardEvent) => {
setStore("mouseActive", false)
if (e.key === "Enter") {
e.preventDefault()
const selected = flat().find((x) => props.key(x) === list.active())
if (selected) handleSelect(selected)
} else if (e.key === "Escape") {
e.preventDefault()
props.onClose?.()
} else {
list.onKeyDown(e)
}
}
return (
<Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" />
<Dialog.Content
class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl
shadow-[0_0_33px_rgba(0,0,0,0.8)]
bg-background border border-border-subtle/30 rounded-lg z-[101]
max-h-[60vh] flex flex-col"
>
<div class="border-b border-border-subtle/30">
<div class="relative">
<Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" />
<input
type="text"
value={store.filter}
onInput={(e) => handleInput(e.currentTarget.value)}
onKeyDown={handleKey}
placeholder={props.placeholder}
class="w-full pl-10 pr-4 py-2 rounded-t-md
text-sm text-text placeholder-text-muted/70
focus:outline-none"
autofocus
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{/* <Show when={fileResults.loading && mode() === "files"}>
<div class="text-text-muted">
<Icon name="refresh" size={14} class="animate-spin" />
</div>
</Show> */}
<Show when={store.filter}>
<IconButton
size="xs"
variant="ghost"
class="text-text-muted hover:text-text"
onClick={() => {
setStore("filter", "")
resetSelection()
}}
>
<Icon name="close" size={14} />
</IconButton>
</Show>
</div>
</div>
</div>
<div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto">
<Show
when={flat().length > 0}
fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>}
>
<For each={grouped()}>
{(group) => (
<>
<Show when={group.category}>
<div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
{group.category}
</div>
</Show>
<div class="p-2">
<For each={group.items}>
{(item) => (
<button
data-key={props.key(item)}
onClick={() => handleSelect(item)}
onMouseMove={() => {
setStore("mouseActive", true)
list.setActive(props.key(item))
}}
classList={{
"w-full px-3 py-2 flex items-center gap-3": true,
"rounded-md text-left transition-colors group": true,
"bg-background-element": props.key(item) === list.active(),
}}
>
{props.render(item)}
</button>
)}
</For>
</div>
</>
)}
</For>
</Show>
</div>
<div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted">
<div class="flex items-center gap-5">
<span class="flex items-center gap-1.5">
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
</kbd>
Navigate
</span>
<span class="flex items-center gap-1.5">
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
</kbd>
Select
</span>
<span class="flex items-center gap-1.5">
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
ESC
</kbd>
Close
</span>
</div>
<span>{`${flat().length} results`}</span>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { Select as KobalteSelect } from "@kobalte/core/select"
import { createMemo } from "solid-js"
import type { ComponentProps } from "solid-js"
import { Icon } from "@/ui/icon"
import { pipe, groupBy, entries, map } from "remeda"
import { Button, type ButtonProps } from "@/ui"
export interface SelectProps<T> {
placeholder?: string
options: T[]
current?: T
value?: (x: T) => string
label?: (x: T) => string
groupBy?: (x: T) => string
onSelect?: (value: T | undefined) => void
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
}
export function Select<T>(props: SelectProps<T> & ButtonProps) {
const grouped = createMemo(() => {
const result = pipe(
props.options,
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(),
map(([k, v]) => ({ category: k, options: v })),
)
return result
})
return (
<KobalteSelect<T, { category: string; options: T[] }>
value={props.current}
options={grouped()}
optionValue={(x) => (props.value ? props.value(x) : (x as string))}
optionTextValue={(x) => (props.label ? props.label(x) : (x as string))}
optionGroupChildren="options"
placeholder={props.placeholder}
sectionComponent={(props) => (
<KobalteSelect.Section class="text-xs uppercase text-text-muted/60 font-light mt-3 first:mt-0 ml-2">
{props.section.rawValue.category}
</KobalteSelect.Section>
)}
itemComponent={(itemProps) => (
<KobalteSelect.Item
classList={{
"relative flex cursor-pointer select-none items-center": true,
"rounded-sm px-2 py-0.5 text-xs outline-none text-text": true,
"transition-colors data-[disabled]:pointer-events-none": true,
"data-[highlighted]:bg-background-element data-[disabled]:opacity-50": true,
[props.class ?? ""]: !!props.class,
}}
{...itemProps}
>
<KobalteSelect.ItemLabel>
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
</KobalteSelect.ItemLabel>
<KobalteSelect.ItemIndicator class="ml-auto">
<Icon name="checkmark" size={16} />
</KobalteSelect.ItemIndicator>
</KobalteSelect.Item>
)}
onChange={(v) => {
props.onSelect?.(v ?? undefined)
}}
>
<KobalteSelect.Trigger
as={Button}
size={props.size || "sm"}
variant={props.variant || "secondary"}
classList={{
...(props.classList ?? {}),
[props.class ?? ""]: !!props.class,
}}
>
<KobalteSelect.Value<T>>
{(state) => {
const selected = state.selectedOption() ?? props.current
if (!selected) return props.placeholder || ""
if (props.label) return props.label(selected)
return selected as string
}}
</KobalteSelect.Value>
<KobalteSelect.Icon
classList={{
"size-fit shrink-0 text-text-muted transition-transform duration-100 data-[expanded]:rotate-180": true,
}}
>
<Icon name="chevron-down" size={24} />
</KobalteSelect.Icon>
</KobalteSelect.Trigger>
<KobalteSelect.Portal>
<KobalteSelect.Content
classList={{
"min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true,
"bg-background-panel p-1 shadow-md z-50": true,
"data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95": true,
"data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
}}
>
<KobalteSelect.Listbox class="overflow-y-auto max-h-48" />
</KobalteSelect.Content>
</KobalteSelect.Portal>
</KobalteSelect>
)
}
+1 -1
View File
@@ -7,7 +7,7 @@ export default function SessionList() {
const local = useLocal()
return (
<VList data={sync.data.session} class="p-2 no-scrollbar">
<VList data={sync.data.session} class="p-2">
{(session) => (
<Tooltip placement="right" value={session.title} class="w-full min-w-0">
<Button
+213 -111
View File
@@ -1,5 +1,5 @@
import { useLocal, useSync } from "@/context"
import { Collapsible, Icon, type IconProps } from "@/ui"
import { Collapsible, Icon } from "@/ui"
import type { Part, ToolPart } from "@opencode-ai/sdk"
import { DateTime } from "luxon"
import {
@@ -13,58 +13,14 @@ import {
type ParentProps,
createEffect,
createMemo,
Show,
} from "solid-js"
import { getFilename } from "@/utils"
import Markdown from "./markdown"
import { Markdown } from "./markdown"
import { Code } from "./code"
import { createElementSize } from "@solid-primitives/resize-observer"
import { createScrollPosition } from "@solid-primitives/scroll"
function TimelineIcon(props: { name: IconProps["name"]; class?: string }) {
return (
<div
classList={{
"relative flex flex-none self-start items-center justify-center bg-background h-6 w-6": true,
[props.class ?? ""]: !!props.class,
}}
>
<Icon name={props.name} class="text-text/40" size={18} />
</div>
)
}
function CollapsibleTimelineIcon(props: { name: IconProps["name"]; class?: string }) {
return (
<>
<TimelineIcon
name={props.name}
class={`group-hover/li:hidden group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
/>
<TimelineIcon
name="chevron-right"
class={`hidden group-hover/li:flex group-has-[[data-expanded]]/li:hidden ${props.class ?? ""}`}
/>
<TimelineIcon name="chevron-down" class={`hidden group-has-[[data-expanded]]/li:flex ${props.class ?? ""}`} />
</>
)
}
function ToolIcon(props: { part: ToolPart }) {
return (
<Switch fallback={<TimelineIcon name="hammer" />}>
<Match when={props.part.tool === "read"}>
<TimelineIcon name="file" />
</Match>
<Match when={props.part.tool === "edit"}>
<CollapsibleTimelineIcon name="pencil" />
</Match>
<Match when={props.part.tool === "write"}>
<CollapsibleTimelineIcon name="file-plus" />
</Match>
</Switch>
)
}
function Part(props: ParentProps & ComponentProps<"div">) {
const [local, others] = splitProps(props, ["class", "classList", "children"])
return (
@@ -97,9 +53,13 @@ function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps
}
function ReadToolPart(props: { part: ToolPart }) {
const sync = useSync()
const local = useLocal()
return (
<Switch>
<Match when={props.part.state.status === "pending"}>
<Part>Reading file...</Part>
</Match>
<Match when={props.part.state.status === "completed" && props.part.state}>
{(state) => {
const path = state().input["filePath"] as string
@@ -110,13 +70,27 @@ function ReadToolPart(props: { part: ToolPart }) {
)
}}
</Match>
<Match when={props.part.state.status === "error" && props.part.state}>
{(state) => (
<div>
<Part>
<span class="text-text-muted">Read</span> {getFilename(state().input["filePath"] as string)}
</Part>
<div class="text-error">{sync.sanitize(state().error)}</div>
</div>
)}
</Match>
</Switch>
)
}
function EditToolPart(props: { part: ToolPart }) {
const sync = useSync()
return (
<Switch>
<Match when={props.part.state.status === "pending"}>
<Part>Preparing edit...</Part>
</Match>
<Match when={props.part.state.status === "completed" && props.part.state}>
{(state) => (
<CollapsiblePart
@@ -135,13 +109,30 @@ function EditToolPart(props: { part: ToolPart }) {
</CollapsiblePart>
)}
</Match>
<Match when={props.part.state.status === "error" && props.part.state}>
{(state) => (
<CollapsiblePart
title={
<>
<span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
</>
}
>
<div class="text-error">{sync.sanitize(state().error)}</div>
</CollapsiblePart>
)}
</Match>
</Switch>
)
}
function WriteToolPart(props: { part: ToolPart }) {
const sync = useSync()
return (
<Switch>
<Match when={props.part.state.status === "pending"}>
<Part>Preparing write...</Part>
</Match>
<Match when={props.part.state.status === "completed" && props.part.state}>
{(state) => (
<CollapsiblePart
@@ -155,35 +146,95 @@ function WriteToolPart(props: { part: ToolPart }) {
</CollapsiblePart>
)}
</Match>
<Match when={props.part.state.status === "error" && props.part.state}>
{(state) => (
<div>
<Part>
<span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
</Part>
<div class="text-error">{sync.sanitize(state().error)}</div>
</div>
)}
</Match>
</Switch>
)
}
function BashToolPart(props: { part: ToolPart }) {
const sync = useSync()
return (
<Switch>
<Match when={props.part.state.status === "pending"}>
<Part>Writing shell command...</Part>
</Match>
<Match when={props.part.state.status === "completed" && props.part.state}>
{(state) => (
<CollapsiblePart
defaultOpen
title={
<>
<span class="text-text-muted">Run command:</span> {state().input["command"]}
</>
}
>
<Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
</CollapsiblePart>
)}
</Match>
<Match when={props.part.state.status === "error" && props.part.state}>
{(state) => (
<CollapsiblePart
title={
<>
<span class="text-text-muted">Shell</span> {state().input["command"]}
</>
}
>
<div class="text-error">{sync.sanitize(state().error)}</div>
</CollapsiblePart>
)}
</Match>
</Switch>
)
}
function ToolPart(props: { part: ToolPart }) {
// read
// edit
// write
// bash
// ls
// glob
// grep
// todowrite
// todoread
// webfetch
// websearch
// patch
// task
return (
<Switch
fallback={
<div class="flex-auto min-w-0 text-xs">
{props.part.type}:{props.part.tool}
</div>
}
>
<Match when={props.part.tool === "read"}>
<div class="min-w-0 flex-auto">
<div class="min-w-0 flex-auto text-xs">
<Switch
fallback={
<span>
{props.part.type}:{props.part.tool}
</span>
}
>
<Match when={props.part.tool === "read"}>
<ReadToolPart part={props.part} />
</div>
</Match>
<Match when={props.part.tool === "edit"}>
<div class="min-w-0 flex-auto">
</Match>
<Match when={props.part.tool === "edit"}>
<EditToolPart part={props.part} />
</div>
</Match>
<Match when={props.part.tool === "write"}>
<div class="min-w-0 flex-auto">
</Match>
<Match when={props.part.tool === "write"}>
<WriteToolPart part={props.part} />
</div>
</Match>
</Switch>
</Match>
<Match when={props.part.tool === "bash"}>
<BashToolPart part={props.part} />
</Match>
</Switch>
</div>
)
}
@@ -196,6 +247,7 @@ export default function SessionTimeline(props: { session: string; class?: string
const scroll = createScrollPosition(scrollElement)
onMount(() => sync.session.sync(props.session))
const session = createMemo(() => sync.session.get(props.session))
const messages = createMemo(() => sync.data.message[props.session] ?? [])
const working = createMemo(() => {
const last = messages()[messages().length - 1]
@@ -285,60 +337,33 @@ export default function SessionTimeline(props: { session: string; class?: string
<div
ref={setRoot}
classList={{
"p-4 select-text flex flex-col gap-y-8": true,
"p-4 select-text flex flex-col gap-y-1": true,
[props.class ?? ""]: !!props.class,
}}
>
<For each={messages()}>
{(message) => (
<ul role="list" class="space-y-2">
<ul role="list" class="flex flex-col gap-1">
<For each={messages()}>
{(message) => (
<For each={sync.data.part[message.id]?.filter(valid)}>
{(part) => (
<li classList={{ "relative group/li flex gap-x-4 min-w-0 w-full": true }}>
<div
classList={{
"absolute top-0 left-0 flex w-6 justify-center": true,
"last:h-10 not-last:-bottom-10": true,
}}
>
<div class="w-px bg-border-subtle" />
</div>
<Switch
fallback={
<div class="m-0.5 relative flex size-5 flex-none items-center justify-center bg-background">
<div class="size-1 rounded-full bg-text/10 ring ring-text/20" />
</div>
}
>
<Match when={part.type === "text"}>
<Switch>
<Match when={message.role === "user"}>
<TimelineIcon name="avatar-square" />
</Match>
<Match when={message.role === "assistant"}>
<TimelineIcon name="sparkles" />
</Match>
</Switch>
</Match>
<Match when={part.type === "reasoning"}>
<CollapsibleTimelineIcon name="brain" />
</Match>
<Match when={part.type === "tool" && part}>{(part) => <ToolIcon part={part()} />}</Match>
</Switch>
<li class="group/li">
<Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}>
<Match when={part.type === "text" && part}>
{(part) => (
<Switch>
<Match when={message.role === "user"}>
<div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0">
<div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0 mt-5 group-first/li:mt-0">
<p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel">
<span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span>
</p>
<p class="text-xs text-text-muted">12:07pm · adam</p>
<p class="text-xs text-text-muted">
{DateTime.fromMillis(message.time.created).toRelative()} ·{" "}
{sync.data.config.username ?? "user"}
</p>
</div>
</Match>
<Match when={message.role === "assistant"}>
<Markdown text={part().text} class="text-text" />
<Markdown text={sync.sanitize(part().text)} class="text-text mt-1" />
</Match>
</Switch>
)}
@@ -347,9 +372,11 @@ export default function SessionTimeline(props: { session: string; class?: string
{(part) => (
<CollapsiblePart
title={
<>
<span class="text-text-muted">Thought</span> for {duration(part())}s
</>
<Switch fallback={<span class="text-text-muted">Thinking</span>}>
<Match when={part().time.end}>
<span class="text-text-muted">Thought</span> for {duration(part())}s
</Match>
</Switch>
}
>
<Markdown text={part().text} />
@@ -361,9 +388,84 @@ export default function SessionTimeline(props: { session: string; class?: string
</li>
)}
</For>
</ul>
)}
</For>
)}
</For>
</ul>
<Show when={false}>
<Collapsible defaultOpen={false}>
<Collapsible.Trigger>
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
<Icon name="file-code" size={16} />
<span>Raw Session Data</span>
<Collapsible.Arrow size={18} class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content class="mt-5">
<ul role="list" class="space-y-2">
<li>
<Collapsible>
<Collapsible.Trigger>
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
<Icon name="file-code" size={16} />
<span>session</span>
<Collapsible.Arrow size={18} class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Code path="session.json" code={JSON.stringify(session(), null, 2)} class="[&_code]:pb-0!" />
</Collapsible.Content>
</Collapsible>
</li>
<For each={messages()}>
{(message) => (
<>
<li>
<Collapsible>
<Collapsible.Trigger>
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
<Icon name="file-code" size={16} />
<span>{message.role === "user" ? "user" : "assistant"}</span>
<Collapsible.Arrow size={18} class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Code
path={message.id + ".json"}
code={JSON.stringify(message, null, 2)}
class="[&_code]:pb-0!"
/>
</Collapsible.Content>
</Collapsible>
</li>
<For each={sync.data.part[message.id]?.filter(valid)}>
{(part) => (
<li>
<Collapsible>
<Collapsible.Trigger>
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
<Icon name="file-code" size={16} />
<span>{part.type}</span>
<Collapsible.Arrow size={18} class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Code
path={message.id + "." + part.id + ".json"}
code={JSON.stringify(part, null, 2)}
class="[&_code]:pb-0!"
/>
</Collapsible.Content>
</Collapsible>
</li>
)}
</For>
</>
)}
</For>
</ul>
</Collapsible.Content>
</Collapsible>
</Show>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { createContext, useContext, type ParentProps } from "solid-js"
import { createEventBus } from "@solid-primitives/event-bus"
import type { Event as SDKEvent } from "@opencode-ai/sdk"
import { useSDK } from "@/context"
export type Event = SDKEvent // can extend with custom events later
function init() {
const sdk = useSDK()
const bus = createEventBus<Event>()
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
bus.emit(event)
}
})
return bus
}
type EventContext = ReturnType<typeof init>
const ctx = createContext<EventContext>()
export function EventProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useEvent() {
const value = useContext(ctx)
if (!value) {
throw new Error("useEvent must be used within a EventProvider")
}
return value
}
+3
View File
@@ -1,4 +1,7 @@
export { EventProvider, useEvent } from "./event"
export { LocalProvider, useLocal } from "./local"
export { MarkedProvider, useMarked } from "./marked"
export { SDKProvider, useSDK } from "./sdk"
export { ShikiProvider, useShiki } from "./shiki"
export { SyncProvider, useSync } from "./sync"
export { ThemeProvider, useTheme } from "./theme"
+115 -90
View File
@@ -1,9 +1,8 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
import { useSync } from "./sync"
import { uniqueBy } from "remeda"
import type { FileContent, FileNode } from "@opencode-ai/sdk"
import { useSDK } from "./sdk"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
import { useSDK, useEvent, useSync } from "@/context"
export type LocalFile = FileNode &
Partial<{
@@ -16,30 +15,40 @@ export type LocalFile = FileNode &
view: "raw" | "diff-unified" | "diff-split"
folded: string[]
selectedChange: number
status: FileStatus
}>
export type TextSelection = LocalFile["selection"]
export type View = LocalFile["view"]
export type LocalModel = Omit<Model, "provider"> & {
provider: Provider
}
export type ModelKey = { providerID: string; modelID: string }
function init() {
const sdk = useSDK()
const sync = useSync()
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const [store, setStore] = createStore<{
current: string
}>({
current: agents()[0].name,
current: list()[0].name,
})
return {
list,
current() {
return agents().find((x) => x.name === store.current)!
return list().find((x) => x.name === store.current)!
},
set(name: string | undefined) {
setStore("current", name ?? list()[0].name)
},
move(direction: 1 | -1) {
let next = agents().findIndex((x) => x.name === store.current) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
let next = list().findIndex((x) => x.name === store.current) + direction
if (next < 0) next = list().length - 1
if (next >= list().length) next = 0
const value = list()[next]
setStore("current", value.name)
if (value.model)
model.set({
@@ -51,18 +60,14 @@ function init() {
})()
const model = (() => {
const list = createMemo(() =>
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
)
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
const [store, setStore] = createStore<{
model: Record<
string,
{
providerID: string
modelID: string
}
>
recent: {
providerID: string
modelID: string
}[]
model: Record<string, ModelKey>
recent: ModelKey[]
}>({
model: {},
recent: [],
@@ -78,35 +83,24 @@ function init() {
if (store.recent.length) return store.recent[0]
const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0]
return {
providerID: provider.id,
modelID: model.id,
}
return { modelID: model.id, providerID: provider.id }
})
const current = createMemo(() => {
const a = agent.current()
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
})
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
return {
list,
current,
recent() {
return store.recent
},
parsed: createMemo(() => {
const value = current()
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
const model = provider.models[value.modelID]
return {
provider: provider.name ?? value.providerID,
model: model.name ?? value.modelID,
}
}),
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
recent,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
setStore("model", agent.current().name, model)
if (options?.recent) {
setStore("model", agent.current().name, model ?? fallback())
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setStore("recent", uniq)
@@ -131,11 +125,36 @@ function init() {
return store.node[store.active]
})
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
const changes = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
const status = (path: string) => sync.data.changes.find((f) => f.path === path)
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
createEffect((prev: FileStatus[]) => {
const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
for (const p of removed) {
setStore(
"node",
p.path,
produce((draft) => {
draft.status = undefined
draft.view = "raw"
}),
)
load(p.path)
}
for (const p of sync.data.changes) {
if (store.node[p.path] === undefined) {
fetch(p.path).then(() => setStore("node", p.path, "status", p))
} else {
setStore("node", p.path, "status", p)
}
}
return sync.data.changes
}, sync.data.changes)
const changed = (path: string) => {
const set = changes()
const node = store.node[path]
if (node?.status) return true
const set = changeset()
if (set.has(path)) return true
for (const p of set) {
if (p.startsWith(path ? path + "/" : "")) return true
@@ -144,50 +163,50 @@ function init() {
}
const resetNode = (path: string) => {
setStore("node", path, {
loaded: undefined,
pinned: undefined,
content: undefined,
selection: undefined,
scrollTop: undefined,
folded: undefined,
view: undefined,
selectedChange: undefined,
})
setStore("node", path, undefined!)
}
const load = async (path: string) =>
sdk.file.read({ query: { path } }).then((x) => {
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
const load = async (path: string) => {
const relativePath = relative(path)
sdk.file.read({ query: { path: relativePath } }).then((x) => {
setStore(
"node",
path,
relativePath,
produce((draft) => {
draft.loaded = true
draft.content = x.data
}),
)
})
}
const open = async (path: string) => {
const relative = path.replace(sync.data.path.directory + "/", "")
if (!store.node[relative]) {
const parent = relative.split("/").slice(0, -1).join("/")
if (parent) {
await list(parent)
}
const fetch = async (path: string) => {
const relativePath = relative(path)
const parent = relativePath.split("/").slice(0, -1).join("/")
if (parent) {
await list(parent)
}
}
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
const relativePath = relative(path)
if (!store.node[relativePath]) await fetch(path)
setStore("opened", (x) => {
if (x.includes(relative)) return x
if (x.includes(relativePath)) return x
return [
...opened()
.filter((x) => x.pinned)
.map((x) => x.path),
relative,
relativePath,
]
})
setStore("active", relative)
if (store.node[relative].loaded) return
return load(relative)
setStore("active", relativePath)
if (options?.pinned) setStore("node", path, "pinned", true)
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
if (store.node[relativePath].loaded) return
return load(relativePath)
}
const list = async (path: string) => {
@@ -204,27 +223,31 @@ function init() {
})
}
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
switch (event.type) {
case "message.part.updated":
const part = event.properties.part
if (part.type === "tool" && part.state.status === "completed") {
switch (part.tool) {
case "read":
console.log("read", part.state.input)
break
case "edit":
const absolute = part.state.input["filePath"] as string
const path = absolute.replace(sync.data.path.directory + "/", "")
load(path)
break
default:
break
}
const search = (query: string) => sdk.find.files({ query: { query } }).then((x) => x.data!)
const bus = useEvent()
bus.listen((event) => {
switch (event.type) {
case "message.part.updated":
const part = event.properties.part
if (part.type === "tool" && part.state.status === "completed") {
switch (part.tool) {
case "read":
break
case "edit":
// load(part.state.input["filePath"] as string)
break
default:
break
}
break
}
}
break
case "file.watcher.updated":
setTimeout(sync.load.changes, 1000)
const relativePath = relative(event.properties.file)
if (relativePath.startsWith(".git/")) return
load(relativePath)
break
}
})
@@ -297,8 +320,8 @@ function init() {
setChangeIndex(path: string, index: number | undefined) {
setStore("node", path, "selectedChange", index)
},
changes,
changed,
status,
children(path: string) {
return Object.values(store.node).filter(
(x) =>
@@ -307,6 +330,8 @@ function init() {
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
)
},
search,
relative,
}
})()
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useContext, type ParentProps } from "solid-js"
import { useShiki } from "@/context"
import { marked } from "marked"
import markedShiki from "marked-shiki"
import { bundledLanguages, type BundledLanguage } from "shiki"
function init(highlighter: ReturnType<typeof useShiki>) {
return marked.use(
markedShiki({
async highlight(code, lang) {
if (!(lang in bundledLanguages)) {
lang = "text"
}
if (!highlighter.getLoadedLanguages().includes(lang)) {
await highlighter.loadLanguage(lang as BundledLanguage)
}
return highlighter.codeToHtml(code, {
lang: lang || "text",
theme: "opencode",
tabindex: false,
})
},
}),
)
}
type MarkedContext = ReturnType<typeof init>
const ctx = createContext<MarkedContext>()
export function MarkedProvider(props: ParentProps) {
const highlighter = useShiki()
const value = init(highlighter)
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useMarked() {
const value = useContext(ctx)
if (!value) {
throw new Error("useMarked must be used within a MarkedProvider")
}
return value
}
+582
View File
@@ -0,0 +1,582 @@
import { createHighlighter, type ThemeInput } from "shiki"
import { createContext, useContext, type ParentProps } from "solid-js"
const theme: ThemeInput = {
colors: {
"actionBar.toggledBackground": "var(--theme-background-element)",
"activityBarBadge.background": "var(--theme-accent)",
"checkbox.border": "var(--theme-border)",
"editor.background": "transparent",
"editor.foreground": "var(--theme-text)",
"editor.inactiveSelectionBackground": "var(--theme-background-element)",
"editor.selectionHighlightBackground": "var(--theme-border-active)",
"editorIndentGuide.activeBackground1": "var(--theme-border-subtle)",
"editorIndentGuide.background1": "var(--theme-border-subtle)",
"input.placeholderForeground": "var(--theme-text-muted)",
"list.activeSelectionIconForeground": "var(--theme-text)",
"list.dropBackground": "var(--theme-background-element)",
"menu.background": "var(--theme-background-panel)",
"menu.border": "var(--theme-border)",
"menu.foreground": "var(--theme-text)",
"menu.selectionBackground": "var(--theme-primary)",
"menu.separatorBackground": "var(--theme-border)",
"ports.iconRunningProcessForeground": "var(--theme-success)",
"sideBarSectionHeader.background": "transparent",
"sideBarSectionHeader.border": "var(--theme-border-subtle)",
"sideBarTitle.foreground": "var(--theme-text-muted)",
"statusBarItem.remoteBackground": "var(--theme-success)",
"statusBarItem.remoteForeground": "var(--theme-text)",
"tab.lastPinnedBorder": "var(--theme-border-subtle)",
"tab.selectedBackground": "var(--theme-background-element)",
"tab.selectedForeground": "var(--theme-text-muted)",
"terminal.inactiveSelectionBackground": "var(--theme-background-element)",
"widget.border": "var(--theme-border)",
},
displayName: "opencode",
name: "opencode",
semanticHighlighting: true,
semanticTokenColors: {
customLiteral: "var(--theme-syntax-function)",
newOperator: "var(--theme-syntax-operator)",
numberLiteral: "var(--theme-syntax-number)",
stringLiteral: "var(--theme-syntax-string)",
},
tokenColors: [
{
scope: [
"meta.embedded",
"source.groovy.embedded",
"string meta.image.inline.markdown",
"variable.legacy.builtin.python",
],
settings: {
foreground: "var(--theme-text)",
},
},
{
scope: "emphasis",
settings: {
fontStyle: "italic",
},
},
{
scope: "strong",
settings: {
fontStyle: "bold",
},
},
{
scope: "header",
settings: {
foreground: "var(--theme-markdown-heading)",
},
},
{
scope: "comment",
settings: {
foreground: "var(--theme-syntax-comment)",
},
},
{
scope: "constant.language",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: [
"constant.numeric",
"variable.other.enummember",
"keyword.operator.plus.exponent",
"keyword.operator.minus.exponent",
],
settings: {
foreground: "var(--theme-syntax-number)",
},
},
{
scope: "constant.regexp",
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: "entity.name.tag",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: ["entity.name.tag.css", "entity.name.tag.less"],
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: "entity.other.attribute-name",
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: [
"entity.other.attribute-name.class.css",
"source.css entity.other.attribute-name.class",
"entity.other.attribute-name.id.css",
"entity.other.attribute-name.parent-selector.css",
"entity.other.attribute-name.parent.less",
"source.css entity.other.attribute-name.pseudo-class",
"entity.other.attribute-name.pseudo-element.css",
"source.css.less entity.other.attribute-name.id",
"entity.other.attribute-name.scss",
],
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: "invalid",
settings: {
foreground: "var(--theme-error)",
},
},
{
scope: "markup.underline",
settings: {
fontStyle: "underline",
},
},
{
scope: "markup.bold",
settings: {
fontStyle: "bold",
foreground: "var(--theme-markdown-strong)",
},
},
{
scope: "markup.heading",
settings: {
fontStyle: "bold",
foreground: "var(--theme-markdown-heading)",
},
},
{
scope: "markup.italic",
settings: {
fontStyle: "italic",
},
},
{
scope: "markup.strikethrough",
settings: {
fontStyle: "strikethrough",
},
},
{
scope: "markup.inserted",
settings: {
foreground: "var(--theme-diff-added)",
},
},
{
scope: "markup.deleted",
settings: {
foreground: "var(--theme-diff-removed)",
},
},
{
scope: "markup.changed",
settings: {
foreground: "var(--theme-diff-context)",
},
},
{
scope: "punctuation.definition.quote.begin.markdown",
settings: {
foreground: "var(--theme-markdown-block-quote)",
},
},
{
scope: "punctuation.definition.list.begin.markdown",
settings: {
foreground: "var(--theme-markdown-list-enumeration)",
},
},
{
scope: "markup.inline.raw",
settings: {
foreground: "var(--theme-markdown-code)",
},
},
{
scope: "punctuation.definition.tag",
settings: {
foreground: "var(--theme-syntax-punctuation)",
},
},
{
scope: ["meta.preprocessor", "entity.name.function.preprocessor"],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "meta.preprocessor.string",
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: "meta.preprocessor.numeric",
settings: {
foreground: "var(--theme-syntax-number)",
},
},
{
scope: "meta.structure.dictionary.key.python",
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: "meta.diff.header",
settings: {
foreground: "var(--theme-diff-hunk-header)",
},
},
{
scope: "storage",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "storage.type",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: ["storage.modifier", "keyword.operator.noexcept"],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: ["string", "meta.embedded.assembly"],
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: "string.tag",
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: "string.value",
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: "string.regexp",
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: [
"punctuation.definition.template-expression.begin",
"punctuation.definition.template-expression.end",
"punctuation.section.embedded",
],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: ["meta.template.expression"],
settings: {
foreground: "var(--theme-text)",
},
},
{
scope: [
"support.type.vendored.property-name",
"support.type.property-name",
"source.css variable",
"source.coffee.embedded",
],
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: "keyword",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "keyword.control",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "keyword.operator",
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: [
"keyword.operator.new",
"keyword.operator.expression",
"keyword.operator.cast",
"keyword.operator.sizeof",
"keyword.operator.alignof",
"keyword.operator.typeid",
"keyword.operator.alignas",
"keyword.operator.instanceof",
"keyword.operator.logical.python",
"keyword.operator.wordlike",
],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "keyword.other.unit",
settings: {
foreground: "var(--theme-syntax-number)",
},
},
{
scope: ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "support.function.git-rebase",
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: "constant.sha.git-rebase",
settings: {
foreground: "var(--theme-syntax-number)",
},
},
{
scope: ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"],
settings: {
foreground: "var(--theme-text)",
},
},
{
scope: "variable.language",
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: [
"entity.name.function",
"support.function",
"support.constant.handlebars",
"source.powershell variable.other.member",
"entity.name.operator.custom-literal",
],
settings: {
foreground: "var(--theme-syntax-function)",
},
},
{
scope: [
"support.class",
"support.type",
"entity.name.type",
"entity.name.namespace",
"entity.other.attribute",
"entity.name.scope-resolution",
"entity.name.class",
"storage.type.numeric.go",
"storage.type.byte.go",
"storage.type.boolean.go",
"storage.type.string.go",
"storage.type.uintptr.go",
"storage.type.error.go",
"storage.type.rune.go",
"storage.type.cs",
"storage.type.generic.cs",
"storage.type.modifier.cs",
"storage.type.variable.cs",
"storage.type.annotation.java",
"storage.type.generic.java",
"storage.type.java",
"storage.type.object.array.java",
"storage.type.primitive.array.java",
"storage.type.primitive.java",
"storage.type.token.java",
"storage.type.groovy",
"storage.type.annotation.groovy",
"storage.type.parameters.groovy",
"storage.type.generic.groovy",
"storage.type.object.array.groovy",
"storage.type.primitive.array.groovy",
"storage.type.primitive.groovy",
],
settings: {
foreground: "var(--theme-syntax-type)",
},
},
{
scope: [
"meta.type.cast.expr",
"meta.type.new.expr",
"support.constant.math",
"support.constant.dom",
"support.constant.json",
"entity.other.inherited-class",
"punctuation.separator.namespace.ruby",
],
settings: {
foreground: "var(--theme-syntax-type)",
},
},
{
scope: [
"keyword.control",
"source.cpp keyword.operator.new",
"keyword.operator.delete",
"keyword.other.using",
"keyword.other.directive.using",
"keyword.other.operator",
"entity.name.operator",
],
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: [
"variable",
"meta.definition.variable.name",
"support.variable",
"entity.name.variable",
"constant.other.placeholder",
],
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: ["variable.other.constant", "variable.other.enummember"],
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: ["meta.object-literal.key"],
settings: {
foreground: "var(--theme-syntax-variable)",
},
},
{
scope: [
"support.constant.property-value",
"support.constant.font-name",
"support.constant.media-type",
"support.constant.media",
"constant.other.color.rgb-value",
"constant.other.rgb-value",
"support.constant.color",
],
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: [
"punctuation.definition.group.regexp",
"punctuation.definition.group.assertion.regexp",
"punctuation.definition.character-class.regexp",
"punctuation.character.set.begin.regexp",
"punctuation.character.set.end.regexp",
"keyword.operator.negation.regexp",
"support.other.parenthesis.regexp",
],
settings: {
foreground: "var(--theme-syntax-string)",
},
},
{
scope: [
"constant.character.character-class.regexp",
"constant.other.character-class.set.regexp",
"constant.other.character-class.regexp",
"constant.character.set.regexp",
],
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: "keyword.operator.quantifier.regexp",
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: ["constant.character", "constant.other.option"],
settings: {
foreground: "var(--theme-syntax-keyword)",
},
},
{
scope: "constant.character.escape",
settings: {
foreground: "var(--theme-syntax-operator)",
},
},
{
scope: "entity.name.label",
settings: {
foreground: "var(--theme-text-muted)",
},
},
],
type: "dark",
}
const highlighter = await createHighlighter({
themes: [theme],
langs: [],
})
type ShikiContext = typeof highlighter
const ctx = createContext<ShikiContext>()
export function ShikiProvider(props: ParentProps) {
return <ctx.Provider value={highlighter}>{props.children}</ctx.Provider>
}
export function useShiki() {
const value = useContext(ctx)
if (!value) {
throw new Error("useShiki must be used within a ShikiProvider")
}
return value
}
+75 -68
View File
@@ -1,7 +1,7 @@
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "./sdk"
import { createContext, Show, useContext, type ParentProps } from "solid-js"
import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js"
import { useSDK, useEvent } from "@/context"
import { Binary } from "@/utils/binary"
function init() {
@@ -33,83 +33,88 @@ function init() {
changes: [],
})
const sdk = useSDK()
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
const bus = useEvent()
bus.listen((event) => {
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
case "message.part.updated": {
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.part.updated": {
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
}
})
Promise.all([
sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
sdk.path.get().then((x) => setStore("path", x.data!)),
sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
sdk.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
const sdk = useSDK()
const load = {
provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
session: () =>
sdk.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
),
),
),
sdk.config.get().then((x) => setStore("config", x.data!)),
sdk.file.status().then((x) => setStore("changes", x.data!)),
sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
]).then(() => setStore("ready", true))
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
}
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(), "")
return {
data: store,
@@ -140,6 +145,8 @@ function init() {
)
},
},
load,
sanitize,
}
}
+13
View File
@@ -19,6 +19,19 @@
/* color: var(--color-background); */
}
::-webkit-scrollbar-track {
background: var(--theme-background-panel);
}
::-webkit-scrollbar-thumb {
background-color: var(--theme-border-subtle);
border-radius: 6px;
}
* {
scrollbar-color: var(--theme-border-subtle) var(--theme-background-panel);
}
.prose h1 {
color: var(--color-text);
font-size: var(--text-sm);
+24 -10
View File
@@ -4,7 +4,15 @@ import { Router, Route } from "@solidjs/router"
import "@/index.css"
import Layout from "@/pages/layout"
import Home from "@/pages"
import { SDKProvider, SyncProvider, LocalProvider, ThemeProvider } from "@/context"
import {
EventProvider,
SDKProvider,
SyncProvider,
LocalProvider,
ThemeProvider,
ShikiProvider,
MarkedProvider,
} from "@/context"
const root = document.getElementById("root")
@@ -18,15 +26,21 @@ render(
() => (
<div class="h-full bg-background text-text-muted">
<ThemeProvider defaultTheme="opencode" defaultDarkMode={true}>
<SDKProvider>
<SyncProvider>
<LocalProvider>
<Router root={Layout}>
<Route path="/" component={Home} />
</Router>
</LocalProvider>
</SyncProvider>
</SDKProvider>
<ShikiProvider>
<MarkedProvider>
<SDKProvider>
<EventProvider>
<SyncProvider>
<LocalProvider>
<Router root={Layout}>
<Route path="/" component={Home} />
</Router>
</LocalProvider>
</SyncProvider>
</EventProvider>
</SDKProvider>
</MarkedProvider>
</ShikiProvider>
</ThemeProvider>
</div>
),
+186 -86
View File
@@ -1,7 +1,9 @@
import { FileIcon, Icon, IconButton, Tooltip } from "@/ui"
import { Button, FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
import { Tabs } from "@/ui/tabs"
import { Select } from "@/components/select"
import FileTree from "@/components/file-tree"
import { createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { SelectDialog } from "@/components/select-dialog"
import { useLocal, useSDK } from "@/context"
import { Code } from "@/components/code"
import {
@@ -17,18 +19,20 @@ import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { LocalFile } from "@/context/local"
import SessionList from "@/components/session-list"
import SessionTimeline from "@/components/session-timeline"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
export default function Page() {
const sdk = useSDK()
const local = useLocal()
const [clickTimer, setClickTimer] = createSignal<number | undefined>()
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
const [inputValue, setInputValue] = createSignal("")
const [isDragging, setIsDragging] = createSignal<"left" | "right" | undefined>(undefined)
const [leftScrolled, setLeftScrolled] = createSignal(false)
// TODO: remove
local.model.set({ providerID: "opencode", modelID: "grok-code" })
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
activeItem: undefined as string | undefined,
prompt: "",
dragging: undefined as "left" | "right" | undefined,
modelSelectOpen: false,
fileSelectOpen: false,
})
let inputRef: HTMLInputElement | undefined = undefined
@@ -43,6 +47,17 @@ export default function Page() {
})
const handleKeyDown = (e: KeyboardEvent) => {
if (e.getModifierState(MOD) && e.shiftKey && e.key.toLowerCase() === "p") {
e.preventDefault()
// TODO: command palette
return
}
if (e.getModifierState(MOD) && e.key.toLowerCase() === "p") {
e.preventDefault()
setStore("fileSelectOpen", true)
return
}
const inputFocused = document.activeElement === inputRef
if (inputFocused) {
if (e.key === "Escape") {
@@ -51,6 +66,10 @@ export default function Page() {
return
}
if (document.activeElement?.id === "select-filter") {
return
}
if (local.file.active()) {
if (e.getModifierState(MOD)) {
if (e.key.toLowerCase() === "a") {
@@ -76,20 +95,20 @@ export default function Page() {
}
const resetClickTimer = () => {
if (!clickTimer()) return
clearTimeout(clickTimer())
setClickTimer(undefined)
if (!store.clickTimer) return
clearTimeout(store.clickTimer)
setStore("clickTimer", undefined)
}
const startClickTimer = () => {
const newClickTimer = setTimeout(() => {
setClickTimer(undefined)
setStore("clickTimer", undefined)
}, 300)
setClickTimer(newClickTimer as unknown as number)
setStore("clickTimer", newClickTimer as unknown as number)
}
const handleFileClick = async (file: LocalFile) => {
if (clickTimer()) {
if (store.clickTimer) {
resetClickTimer()
local.file.update(file.path, { ...file, pinned: true })
} else {
@@ -107,7 +126,7 @@ export default function Page() {
}
const onDragStart = (event: any) => {
setActiveItem(event.draggable.id as string)
setStore("activeItem", event.draggable.id as string)
}
const onDragOver = (event: DragEvent) => {
@@ -123,12 +142,12 @@ export default function Page() {
}
const onDragEnd = () => {
setActiveItem(undefined)
setStore("activeItem", undefined)
}
const handleLeftDragStart = (e: MouseEvent) => {
e.preventDefault()
setIsDragging("left")
setStore("dragging", "left")
const startX = e.clientX
const startWidth = local.layout.leftWidth()
@@ -139,7 +158,7 @@ export default function Page() {
}
const handleMouseUp = () => {
setIsDragging(undefined)
setStore("dragging", undefined)
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
@@ -150,7 +169,7 @@ export default function Page() {
const handleRightDragStart = (e: MouseEvent) => {
e.preventDefault()
setIsDragging("right")
setStore("dragging", "right")
const startX = e.clientX
const startWidth = local.layout.rightWidth()
@@ -161,7 +180,7 @@ export default function Page() {
}
const handleMouseUp = () => {
setIsDragging(undefined)
setStore("dragging", undefined)
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
@@ -172,8 +191,8 @@ export default function Page() {
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault()
const prompt = inputValue()
setInputValue("")
const prompt = store.prompt
setStore("prompt", "")
inputRef?.blur()
const session =
@@ -186,7 +205,7 @@ export default function Page() {
path: { id: session!.id },
body: {
agent: local.agent.current()!.name,
model: local.model.current(),
model: { modelID: local.model.current()!.id, providerID: local.model.current()!.provider.id },
parts: [
{
type: "text",
@@ -222,41 +241,47 @@ export default function Page() {
return (
<div class="relative">
<div
class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden"
class="fixed top-0 left-0 h-full border-r border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
style={`width: ${local.layout.leftWidth()}px`}
>
<Tabs class="relative flex flex-col h-full" defaultValue="files">
<div class="sticky top-0 shrink-0 flex">
<Tabs.List class="grow w-full after:hidden">
<Tabs.Trigger value="files" class="flex-1 justify-center">
<Tabs.Trigger value="files" class="flex-1 justify-center text-xs">
Files
</Tabs.Trigger>
<Tabs.Trigger value="changes" class="flex-1 justify-center">
<Tabs.Trigger value="changes" class="flex-1 justify-center text-xs">
Changes
</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content
value="files"
class="grow min-h-0 py-2 bg-background"
onScroll={(e: Event & { currentTarget: HTMLDivElement }) => setLeftScrolled(e.currentTarget.scrollTop > 0)}
>
<Tabs.Content value="files" class="grow min-h-0 py-2 bg-background">
<FileTree path="" onFileClick={handleFileClick} />
<Show when={leftScrolled()}>
<div
class="pointer-events-none sticky top-20 left-px h-4
bg-gradient-to-t from-transparent to-background"
style={`width: ${local.layout.leftWidth() - 2}px`}
/>
</Show>
<div
class="pointer-events-none fixed bottom-0 left-px h-4
bg-gradient-to-b from-transparent to-background"
style={`width: ${local.layout.leftWidth() - 2}px`}
/>
</Tabs.Content>
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
<div class="px-2 text-sm text-text-muted">No changes yet</div>
<Show
when={local.file.changes().length}
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
>
<ul class="">
<For each={local.file.changes()}>
{(path) => (
<li>
<button
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
>
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
{getDirectory(path)}
</span>
</button>
</li>
)}
</For>
</ul>
</Show>
</Tabs.Content>
</Tabs>
</div>
@@ -266,23 +291,23 @@ export default function Page() {
onMouseDown={(e) => handleLeftDragStart(e)}
>
<div
class="w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors"
classList={{
"bg-border-active": isDragging() === "left",
"w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true,
"bg-border-active!": store.dragging === "left",
}}
/>
</div>
<Show when={local.layout.rightPane()}>
<div
class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden"
class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden bg-background z-10"
style={`width: ${local.layout.rightWidth()}px`}
>
<div class="relative flex-1 min-h-0 overflow-y-auto no-scrollbar">
<div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<Show when={local.session.active()} fallback={<SessionList />}>
{(activeSession) => (
<div class="relative">
<div class="sticky top-0 bg-background z-50 p-2 h-9 border-b border-border-subtle/30">
<div class="flex items-center gap-2">
<div class="sticky top-0 bg-background z-50 px-2 h-8 border-b border-border-subtle/30">
<div class="h-full flex items-center gap-2">
<IconButton
size="xs"
variant="ghost"
@@ -300,16 +325,6 @@ export default function Page() {
</div>
)}
</Show>
<div
class="pointer-events-none fixed top-0 right-px h-4
bg-gradient-to-t from-transparent to-background"
style={`width: ${local.layout.rightWidth() - 2}px`}
/>
<div
class="pointer-events-none fixed bottom-0 right-px h-4
bg-gradient-to-b from-transparent to-background"
style={`width: ${local.layout.rightWidth() - 2}px`}
/>
</div>
</div>
<div
@@ -318,14 +333,22 @@ export default function Page() {
onMouseDown={(e) => handleRightDragStart(e)}
>
<div
class="w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors"
classList={{ "bg-border-active": isDragging() === "right" }}
classList={{
"w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true,
"bg-border-active!": store.dragging === "right",
}}
/>
</div>
</Show>
<div
class="relative"
style={`margin-left: ${local.layout.leftWidth()}px; margin-right: ${local.layout.rightPane() ? local.layout.rightWidth() : 0}px`}
>
<Logo
size={64}
variant="ornate"
class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
/>
<DragDropProvider
onDragStart={onDragStart}
onDragEnd={onDragEnd}
@@ -435,12 +458,12 @@ export default function Page() {
</For>
</Tabs>
<DragOverlay>
{activeItem() &&
{store.activeItem &&
(() => {
const draggedFile = local.file.node(activeItem()!)
const draggedFile = local.file.node(store.activeItem!)
return (
<div
class="relative px-3 h-9 flex items-center
class="relative px-3 h-8 flex items-center
text-sm font-medium text-text whitespace-nowrap
shrink-0 bg-background-panel
border-x border-border-subtle/40 border-b border-b-transparent"
@@ -453,11 +476,14 @@ export default function Page() {
</DragDropProvider>
<form
onSubmit={handleSubmit}
class="peer/editor absolute bottom-8 z-50 flex items-center justify-center"
style={`left: ${local.layout.leftWidth() + 40}px; right: ${local.layout.rightPane() ? local.layout.rightWidth() + 40 : 40}px`}
class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center"
classList={{
"bottom-8": !!local.file.active(),
"bottom-2/5": local.file.active() === undefined,
}}
>
<div
class="w-full max-w-2xl min-w-1/2 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
flex flex-col gap-1
bg-gradient-to-b from-background-panel/90 to-background/90
ring-1 ring-border-active/50 border border-transparent
@@ -479,16 +505,25 @@ export default function Page() {
<input
ref={(el) => (inputRef = el)}
type="text"
value={inputValue()}
onInput={(e) => setInputValue(e.currentTarget.value)}
placeholder="It all starts with a prompt..."
value={store.prompt}
onInput={(e) => setStore("prompt", e.currentTarget.value)}
placeholder="Placeholder text..."
class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none"
/>
<div class="px-1 flex justify-between items-center text-xs text-text-muted">
<span>
<span class="text-primary uppercase">{local.agent.current()?.name ?? "unknown"}</span> /{" "}
{local.model.parsed().provider} / {local.model.parsed().model}
</span>
<div class="flex justify-between items-center text-xs text-text-muted">
<div class="flex gap-2 items-center">
<Select
options={local.agent.list().map((a) => a.name)}
current={local.agent.current().name}
onSelect={local.agent.set}
class="uppercase"
/>
<Button onClick={() => setStore("modelSelectOpen", true)}>
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size={24} class="text-text-muted" />
</Button>
<span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span>
</div>
<div class="flex gap-1 items-center">
<IconButton class="text-text-muted" size="xs" variant="ghost">
<Icon name="photo" size={16} />
@@ -501,29 +536,94 @@ export default function Page() {
</div>
</form>
</div>
<Show when={store.modelSelectOpen}>
<SelectDialog
key={(x) => `${x.provider.id}:${x.id}`}
items={local.model.list()}
current={local.model.current()}
render={(i) => (
<div class="w-full flex items-center justify-between">
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" />
<span class="text-xs text-text whitespace-nowrap">{i.name}</span>
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{i.id}
</span>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0">
<Tooltip forceMount={false} value="Reasoning">
<Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} />
</Tooltip>
<Tooltip forceMount={false} value="Tools">
<Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} />
</Tooltip>
<Tooltip forceMount={false} value="Attachments">
<Icon name="photo" size={16} classList={{ "text-success": i.attachment }} />
</Tooltip>
<div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]">
{new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(i.limit.context)}
</div>
<Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}>
<div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]">
<Switch fallback="FREE">
<Match when={i.cost?.input > 10}>$$$</Match>
<Match when={i.cost?.input > 1}>$$</Match>
<Match when={i.cost?.input > 0.1}>$</Match>
</Switch>
</div>
</Tooltip>
</div>
</div>
)}
filter={["provider.name", "name", "id"]}
groupBy={(x) => x.provider.name}
onClose={() => setStore("modelSelectOpen", false)}
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
/>
</Show>
<Show when={store.fileSelectOpen}>
<SelectDialog
items={local.file.search}
key={(x) => x}
render={(i) => (
<div class="w-full flex items-center justify-between">
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<span class="text-xs text-text whitespace-nowrap">{getFilename(i)}</span>
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
)}
onClose={() => setStore("fileSelectOpen", false)}
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
/>
</Show>
</div>
)
}
const TabVisual = (props: { file: LocalFile }) => {
const local = useLocal()
return (
<div class="flex items-center gap-x-1.5">
<FileIcon node={props.file} class="" />
<span
classList={{ "text-xs": true, "text-primary": local.file.changed(props.file.path), italic: !props.file.pinned }}
>
<span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
{props.file.name}
</span>
<span class="text-xs opacity-70">
<Switch>
<Match when={local.file.status(props.file.path)?.status === "modified"}>
<Match when={props.file.status?.status === "modified"}>
<span class="text-primary">M</span>
</Match>
<Match when={local.file.status(props.file.path)?.status === "added"}>
<Match when={props.file.status?.status === "added"}>
<span class="text-success">A</span>
</Match>
<Match when={local.file.status(props.file.path)?.status === "deleted"}>
<Match when={props.file.status?.status === "deleted"}>
<span class="text-error">D</span>
</Match>
</Switch>
@@ -548,7 +648,7 @@ const SortableTab = (props: {
<TabVisual file={props.file} />
</Tabs.Trigger>
<IconButton
class="absolute right-1 top-2 opacity-0 text-text-muted/60
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60
peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text
peer-data-[selected]/tab:hover:bg-border-subtle
hover:opacity-100 peer-hover/tab:opacity-100"
+27 -40
View File
@@ -1,49 +1,36 @@
import { Button as KobalteButton } from "@kobalte/core/button"
import { splitProps } from "solid-js"
import type { ComponentProps } from "solid-js"
import { Button as Kobalte } from "@kobalte/core/button"
import { type ComponentProps, splitProps } from "solid-js"
export interface ButtonProps extends ComponentProps<typeof KobalteButton> {
variant?: "primary" | "secondary" | "outline" | "ghost"
export interface ButtonProps {
variant?: "primary" | "secondary" | "ghost"
size?: "sm" | "md" | "lg"
}
export const buttonStyles = {
base: "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
variants: {
primary: "bg-primary text-background hover:bg-secondary focus-visible:ring-primary data-[disabled]:opacity-50",
secondary:
"bg-background-panel text-text hover:bg-background-element focus-visible:ring-secondary data-[disabled]:opacity-50",
outline:
"border border-border bg-transparent text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:border-border-subtle data-[disabled]:text-text-muted",
ghost: "text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:text-text-muted",
},
sizes: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
}
export function getButtonClasses(
variant: keyof typeof buttonStyles.variants = "primary",
size: keyof typeof buttonStyles.sizes = "md",
className?: string,
) {
return `${buttonStyles.base} ${buttonStyles.variants[variant]} ${buttonStyles.sizes[size]}${className ? ` ${className}` : ""}`
}
export function Button(props: ButtonProps) {
const [local, others] = splitProps(props, ["variant", "size", "class", "classList"])
export function Button(props: ComponentProps<"button"> & ButtonProps) {
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
return (
<KobalteButton
<Kobalte
{...rest}
data-size={split.size || "sm"}
data-variant={split.variant || "secondary"}
class="inline-flex items-center justify-center rounded-md cursor-pointer font-medium transition-colors
min-w-0 whitespace-nowrap truncate
data-[size=sm]:h-6 data-[size=sm]:pl-2 data-[size=sm]:text-xs
data-[size=md]:h-8 data-[size=md]:pl-3 data-[size=md]:text-sm
data-[size=lg]:h-10 data-[size=lg]:pl-4 data-[size=lg]:text-base
data-[variant=primary]:bg-primary data-[variant=primary]:text-background
data-[variant=primary]:hover:bg-secondary data-[variant=primary]:focus-visible:ring-primary
data-[variant=secondary]:bg-background-element data-[variant=secondary]:text-text
data-[variant=secondary]:hover:bg-background-element data-[variant=secondary]:focus-visible:ring-secondary
data-[variant=ghost]:text-text data-[variant=ghost]:hover:bg-background-panel data-[variant=ghost]:focus-visible:ring-border-active
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-transparent
disabled:pointer-events-none disabled:opacity-50"
classList={{
...(local.classList ?? {}),
[buttonStyles.base]: true,
[buttonStyles.variants[local.variant || "primary"]]: true,
[buttonStyles.sizes[local.size || "md"]]: true,
[local.class ?? ""]: !!local.class,
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
{...others}
/>
>
{props.children}
</Kobalte>
)
}
+5
View File
@@ -125,6 +125,11 @@ const icons = {
columns: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.75 6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H17.25C18.3546 4.75 19.25 5.64543 19.25 6.75V17.25C19.25 18.3546 18.3546 19.25 17.25 19.25H6.75C5.64543 19.25 4.75 18.3546 4.75 17.25V6.75Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5V19"></path>',
"open-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 4.75v14.5m-3-8.5L9.75 12l1.5 1.25m-4.5 6h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
"close-pane": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 4.75v14.5m3-8.5L14.25 12l-1.5 1.25M6.75 19.25h10.5a2 2 0 0 0 2-2V6.75a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2Z"></path>',
"file-search": '<path fill="currentColor" d="M17.25 9.25V10a.75.75 0 0 0 .53-1.28l-.53.53Zm-4.5-4.5.53-.53a.75.75 0 0 0-.53-.22v.75ZM10.25 20a.75.75 0 0 0 0-1.5V20Zm7.427-3.383a.75.75 0 0 0-1.06 1.06l1.06-1.06Zm1.043 3.163a.75.75 0 1 0 1.06-1.06l-1.06 1.06Zm-.94-11.06-4.5-4.5-1.06 1.06 4.5 4.5 1.06-1.06ZM12.75 4h-6v1.5h6V4ZM4 6.75v10.5h1.5V6.75H4ZM6.75 20h3.5v-1.5h-3.5V20ZM12 4.75v3.5h1.5v-3.5H12ZM13.75 10h3.5V8.5h-3.5V10ZM12 8.25c0 .966.784 1.75 1.75 1.75V8.5a.25.25 0 0 1-.25-.25H12Zm-8 9A2.75 2.75 0 0 0 6.75 20v-1.5c-.69 0-1.25-.56-1.25-1.25H4ZM6.75 4A2.75 2.75 0 0 0 4 6.75h1.5c0-.69.56-1.25 1.25-1.25V4Zm8.485 14.47a3.235 3.235 0 0 0 3.236-3.235h-1.5c0 .959-.777 1.736-1.736 1.736v1.5Zm0-4.97c.959 0 1.736.777 1.736 1.735h1.5A3.235 3.235 0 0 0 15.235 12v1.5Zm0-1.5A3.235 3.235 0 0 0 12 15.235h1.5c0-.958.777-1.735 1.735-1.735V12Zm0 4.97a1.735 1.735 0 0 1-1.735-1.735H12a3.235 3.235 0 0 0 3.235 3.236v-1.5Zm1.382.707 2.103 2.103 1.06-1.06-2.103-2.103-1.06 1.06Z"></path>',
"folder-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.25 19.25h-3.5a2 2 0 0 1-2-2v-9.5h12.5a2 2 0 0 1 2 2v.5m-5.75-2.5-.931-1.958a2 2 0 0 0-1.756-1.042H6.75a2 2 0 0 0-2 2V11m12.695 6.445 1.805 1.805m-3.75-1a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z"></path>',
search: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 19.25L15.5 15.5M4.75 11C4.75 7.54822 7.54822 4.75 11 4.75C14.4518 4.75 17.25 7.54822 17.25 11C17.25 14.4518 14.4518 17.25 11 17.25C7.54822 17.25 4.75 14.4518 4.75 11Z"></path>',
"web-search": '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 8.25v-.5a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2v.5m14.5 0H4.75m14.5 0v2m-14.5-2v8a2 2 0 0 0 2 2h2.5m7.743-1.257 2.257 2.257m-4.015-1.53a2.485 2.485 0 1 0 0-4.97 2.485 2.485 0 0 0 0 4.97Z"></path>',
loading: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.75v1.5m5.126.624L16 8m3.25 4h-1.5m-.624 5.126-1.768-1.768M12 16.75v2.5m-3.36-3.891-1.768 1.768M7.25 12h-2.5m3.891-3.358L6.874 6.874"></path>',
} as const
export function Icon(props: IconProps) {
+3 -5
View File
@@ -1,15 +1,13 @@
import { A } from "@solidjs/router"
import { splitProps } from "solid-js"
import type { ComponentProps } from "solid-js"
import { getButtonClasses } from "./button"
export interface LinkProps extends ComponentProps<typeof A> {
variant?: "primary" | "secondary" | "outline" | "ghost"
variant?: "primary" | "secondary" | "ghost"
size?: "sm" | "md" | "lg"
}
export function Link(props: LinkProps) {
const [local, others] = splitProps(props, ["variant", "size", "class"])
const classes = local.variant ? getButtonClasses(local.variant, local.size, local.class) : local.class
return <A class={classes} {...others} />
const [, others] = splitProps(props, ["variant", "size", "class"])
return <A {...others} />
}
+2 -2
View File
@@ -18,7 +18,7 @@ function TabsList(props: TabsListProps) {
classList={{
"relative flex items-center bg-background overflow-x-auto no-scrollbar": true,
"divide-x divide-border-subtle/40": true,
"after:content-[''] after:block after:grow after:h-9": true,
"after:content-[''] after:block after:grow after:h-8": true,
"after:border-l empty:after:border-l-0! after:border-b after:border-border-subtle/40": true,
[local.class ?? ""]: !!local.class,
}}
@@ -32,7 +32,7 @@ function TabsTrigger(props: ParentProps<TabsTriggerProps>) {
return (
<KobalteTabs.Trigger
classList={{
"relative px-3 h-9 flex items-center": true,
"relative px-3 h-8 flex items-center": true,
"text-sm font-medium text-text-muted/60 cursor-pointer": true,
"whitespace-nowrap shrink-0 border-b border-border-subtle/40": true,
"disabled:pointer-events-none disabled:opacity-50": true,
+1 -1
View File
@@ -34,7 +34,7 @@ export function Tooltip(props: TooltipProps) {
<KobalteTooltip.Portal>
<KobalteTooltip.Content
classList={{
"z-50 max-w-[320px] rounded-md bg-background-element px-2 py-1": true,
"z-[1000] max-w-[320px] rounded-md bg-background-element px-2 py-1": true,
"text-xs font-medium text-text shadow-md pointer-events-none!": true,
"transition-all duration-150 ease-out": true,
"transform-gpu transform-origin-[var(--kb-tooltip-content-transform-origin)]": true,
+5
View File
@@ -3,6 +3,11 @@ export function getFilename(path: string) {
return parts[parts.length - 1]
}
export function getDirectory(path: string) {
const parts = path.split("/")
return parts.slice(0, parts.length - 1).join("/")
}
export function getFileExtension(path: string) {
const parts = path.split(".")
return parts[parts.length - 1]
+3 -2
View File
@@ -7,17 +7,18 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "0.10.1"
"version": "0.11.6"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
"@opencode/console-core": "workspace:*"
"zod": "catalog:"
},
"engines": {
"node": ">=22"
@@ -4,6 +4,8 @@ import { Billing } from "@opencode/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { IconCreditCard } from "~/component/icon"
import styles from "./billing-section.module.css"
import { Database, eq } from "@opencode/console-core/drizzle/index.js"
import { BillingTable } from "@opencode/console-core/schema/billing.sql.js"
const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
"use server"
@@ -17,12 +19,23 @@ const reload = action(async (form: FormData) => {
return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
}, "billing.reload")
const disableReload = action(async (form: FormData) => {
const setReload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
}, "billing.disableReload")
const reload = form.get("reload")?.toString() === "true"
return json(
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
),
{ revalidate: getBillingInfo.key },
)
}, "billing.setReload")
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
@@ -44,7 +57,7 @@ export function BillingSection() {
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
const createSessionUrlAction = useAction(createSessionUrl)
const createSessionUrlSubmission = useSubmission(createSessionUrl)
const disableReloadSubmission = useSubmission(disableReload)
const setReloadSubmission = useSubmission(setReload)
const reloadSubmission = useSubmission(reload)
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
@@ -89,6 +102,10 @@ export function BillingSection() {
return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
})
const hasBalance = createMemo(() => {
return (balanceInfo()?.balance ?? 0) > 0 && balanceAmount() !== "0.00"
})
return (
<section class={styles.root}>
<div data-slot="section-title">
@@ -136,19 +153,32 @@ export function BillingSection() {
<Show
when={balanceInfo()?.reload}
fallback={
<button
data-color="primary"
disabled={createCheckoutUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
if (checkoutUrl) {
window.location.href = checkoutUrl
}
}}
<Show
when={hasBalance()}
fallback={
<button
data-color="primary"
disabled={createCheckoutUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
if (checkoutUrl) {
window.location.href = checkoutUrl
}
}}
>
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
</button>
}
>
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
</button>
<form action={setReload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="reload" value="true" />
<button data-color="primary" type="submit" disabled={setReloadSubmission.pending}>
{setReloadSubmission.pending ? "Enabling..." : "Enable Billing"}
</button>
</form>
</Show>
}
>
<button
@@ -164,21 +194,31 @@ export function BillingSection() {
>
{createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
</button>
<form action={disableReload} method="post" data-slot="create-form">
<form action={setReload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
{disableReloadSubmission.pending ? "Disabling..." : "Disable"}
<input type="hidden" name="reload" value="false" />
<button data-color="ghost" type="submit" disabled={setReloadSubmission.pending}>
{setReloadSubmission.pending ? "Disabling..." : "Disable"}
</button>
</form>
</Show>
</div>
</div>
<div data-slot="usage">
<Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}>
<p>
You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
your account. You can continue using the API with your remaining balance.
</p>
<Show when={!balanceInfo()?.reload}>
<Show
when={hasBalance()}
fallback={
<p>
We'll load <b>$20</b> (+$1.23 processing fee) and reload it when it reaches <b>$5</b>.
</p>
}
>
<p>
You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
your account. You can continue using the API with your remaining balance.
</p>
</Show>
</Show>
<Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
<p>
@@ -40,6 +40,10 @@
&[data-slot="payment-amount"] {
color: var(--color-text);
&[data-refunded="true"] {
text-decoration: line-through;
}
}
}
@@ -85,7 +85,9 @@ export function PaymentSection() {
{formatDateForTable(date)}
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((payment.amount ?? 0) / 100000000).toFixed(2)}
</td>
<td data-slot="payment-receipt">
<button
onClick={async () => {
@@ -95,7 +97,6 @@ export function PaymentSection() {
}
}}
data-slot="receipt-button"
style="cursor: pointer;"
>
view
</button>
+1 -1
View File
@@ -175,7 +175,7 @@ export default function Home() {
<div data-component="legal">
<span>
©2025 <a href="https://anoma.ly">Anomaly Innovations</a>
©2025 <a href="https://anoma.ly">Anomaly</a>
</span>
</div>
</main>
@@ -1,6 +1,6 @@
import { Billing } from "@opencode/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
import { Database, eq, sql } from "@opencode/console-core/drizzle/index.js"
import { and, Database, eq, sql } from "@opencode/console-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode/console-core/schema/billing.sql.js"
import { Identifier } from "@opencode/console-core/identifier.js"
import { centsToMicroCents } from "@opencode/console-core/util/price.js"
@@ -41,12 +41,14 @@ export async function POST(input: APIEvent) {
const workspaceID = body.data.object.metadata?.workspaceID
const customerID = body.data.object.customer as string
const paymentID = body.data.object.payment_intent as string
const invoiceID = body.data.object.invoice as string
const amount = body.data.object.amount_total
if (!workspaceID) throw new Error("Workspace ID not found")
if (!customerID) throw new Error("Customer ID not found")
if (!amount) throw new Error("Amount not found")
if (!paymentID) throw new Error("Payment ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
await Actor.provide("system", { workspaceID }, async () => {
const customer = await Billing.get()
@@ -86,11 +88,45 @@ export async function POST(input: APIEvent) {
id: Identifier.create("payment"),
amount: centsToMicroCents(Billing.CHARGE_AMOUNT),
paymentID,
invoiceID,
customerID,
})
})
})
}
if (body.type === "charge.refunded") {
const customerID = body.data.object.customer as string
const paymentIntentID = body.data.object.payment_intent as string
if (!customerID) throw new Error("Customer ID not found")
if (!paymentIntentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({
workspaceID: BillingTable.workspaceID,
})
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found")
await Database.transaction(async (tx) => {
await tx
.update(PaymentTable)
.set({
timeRefunded: new Date(body.created * 1000),
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
console.log("finished handling")
@@ -1,8 +1,4 @@
import "./[id].css"
import { Billing } from "@opencode/console-core/billing.js"
import { query, useParams, createAsync } from "@solidjs/router"
import { Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { MonthlyLimitSection } from "~/component/workspace/monthly-limit-section"
import { NewUserSection } from "~/component/workspace/new-user-section"
import { BillingSection } from "~/component/workspace/billing-section"
@@ -10,17 +6,7 @@ import { PaymentSection } from "~/component/workspace/payment-section"
import { UsageSection } from "~/component/workspace/usage-section"
import { KeySection } from "~/component/workspace/key-section"
const getBillingInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.get()
}, workspaceID)
}, "billing.get")
export default function () {
const params = useParams()
const balanceInfo = createAsync(() => getBillingInfo(params.id))
return (
<div data-page="workspace-[id]">
<section data-component="title-section">
@@ -38,13 +24,18 @@ export default function () {
<NewUserSection />
<KeySection />
<BillingSection />
<Show when={true}>
{/*<Show when={balanceInfo()?.reload}>*/}
<MonthlyLimitSection />
</Show>
<MonthlyLimitSection />
<UsageSection />
<PaymentSection />
</div>
</div>
)
}
export function isBeta(workspaceID: string) {
return [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // production
"wrk_01K4NFRR5P7FSYWH88307B4DDS", // dev
"wrk_01K4PJRKJ2WPQZN3FFYRV4673F", // frank
].includes(workspaceID)
}
+269 -416
View File
@@ -1,37 +1,15 @@
import { z } from "zod"
import type { APIEvent } from "@solidjs/start/server"
import path from "node:path"
import { and, Database, eq, isNull, lt, or, sql } from "@opencode/console-core/drizzle/index.js"
import { KeyTable } from "@opencode/console-core/schema/key.sql.js"
import { BillingTable, PaymentTable, UsageTable } from "@opencode/console-core/schema/billing.sql.js"
import { BillingTable, UsageTable } from "@opencode/console-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode/console-core/util/price.js"
import { Identifier } from "@opencode/console-core/identifier.js"
import { Resource } from "@opencode/console-resource"
import { Billing } from "../../../../core/src/billing"
import { Actor } from "@opencode/console-core/actor.js"
type ModelCost = {
input: number
output: number
cacheRead?: number
cacheWrite5m?: number
cacheWrite1h?: number
}
type Model = {
id: string
auth: boolean
cost: ModelCost | ((usage: any) => ModelCost)
headerMappings: Record<string, string>
providers: Record<
string,
{
api: string
apiKey: string
model: string
weight?: number
}
>
}
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
export async function handler(
input: APIEvent,
@@ -56,184 +34,32 @@ export async function handler(
class MonthlyLimitError extends Error {}
class ModelError extends Error {}
const MODELS: Record<string, Model> = {
"claude-opus-4-1": {
id: "claude-opus-4-1" as const,
auth: true,
cost: {
input: 0.000015,
output: 0.000075,
cacheRead: 0.0000015,
cacheWrite5m: 0.00001875,
cacheWrite1h: 0.00003,
},
headerMappings: {},
providers: {
anthropic: {
api: "https://api.anthropic.com",
apiKey: Resource.ANTHROPIC_API_KEY.value,
model: "claude-opus-4-1-20250805",
},
},
},
"claude-sonnet-4": {
id: "claude-sonnet-4" as const,
auth: true,
cost: (usage: any) => {
const totalInputTokens =
usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens
return totalInputTokens <= 200_000
? {
input: 0.000003,
output: 0.000015,
cacheRead: 0.0000003,
cacheWrite5m: 0.00000375,
cacheWrite1h: 0.000006,
}
: {
input: 0.000006,
output: 0.0000225,
cacheRead: 0.0000006,
cacheWrite5m: 0.0000075,
cacheWrite1h: 0.000012,
}
},
headerMappings: {},
providers: {
anthropic: {
api: "https://api.anthropic.com",
apiKey: Resource.ANTHROPIC_API_KEY.value,
model: "claude-sonnet-4-20250514",
},
},
},
"claude-3-5-haiku": {
id: "claude-3-5-haiku" as const,
auth: true,
cost: {
input: 0.0000008,
output: 0.000004,
cacheRead: 0.00000008,
cacheWrite5m: 0.000001,
cacheWrite1h: 0.0000016,
},
headerMappings: {},
providers: {
anthropic: {
api: "https://api.anthropic.com",
apiKey: Resource.ANTHROPIC_API_KEY.value,
model: "claude-3-5-haiku-20241022",
},
},
},
"gpt-5": {
id: "gpt-5" as const,
auth: true,
cost: {
input: 0.00000125,
output: 0.00001,
cacheRead: 0.000000125,
},
headerMappings: {},
providers: {
openai: {
api: "https://api.openai.com",
apiKey: Resource.OPENAI_API_KEY.value,
model: "gpt-5",
},
},
},
"qwen3-coder": {
id: "qwen3-coder" as const,
auth: true,
cost: {
input: 0.00000045,
output: 0.0000018,
},
headerMappings: {},
providers: {
baseten: {
api: "https://inference.baseten.co",
apiKey: Resource.BASETEN_API_KEY.value,
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
weight: 4,
},
fireworks: {
api: "https://api.fireworks.ai/inference",
apiKey: Resource.FIREWORKS_API_KEY.value,
model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
weight: 1,
},
},
},
"kimi-k2": {
id: "kimi-k2" as const,
auth: true,
cost: {
input: 0.0000006,
output: 0.0000025,
},
headerMappings: {},
providers: {
baseten: {
api: "https://inference.baseten.co",
apiKey: Resource.BASETEN_API_KEY.value,
model: "moonshotai/Kimi-K2-Instruct-0905",
//weight: 4,
},
//fireworks: {
// api: "https://api.fireworks.ai/inference",
// apiKey: Resource.FIREWORKS_API_KEY.value,
// model: "accounts/fireworks/models/kimi-k2-instruct-0905",
// weight: 1,
//},
},
},
"grok-code": {
id: "grok-code" as const,
auth: false,
cost: {
input: 0,
output: 0,
cacheRead: 0,
},
headerMappings: {
"x-grok-conv-id": "x-opencode-session",
"x-grok-req-id": "x-opencode-request",
},
providers: {
xai: {
api: "https://api.x.ai",
apiKey: Resource.XAI_API_KEY.value,
model: "grok-code",
},
},
},
// deprecated
"qwen/qwen3-coder": {
id: "qwen/qwen3-coder" as const,
auth: true,
cost: {
input: 0.00000038,
output: 0.00000153,
},
headerMappings: {},
providers: {
baseten: {
api: "https://inference.baseten.co",
apiKey: Resource.BASETEN_API_KEY.value,
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
weight: 5,
},
fireworks: {
api: "https://api.fireworks.ai/inference",
apiKey: Resource.FIREWORKS_API_KEY.value,
model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
weight: 1,
},
},
},
}
const ModelCostSchema = z.object({
input: z.number(),
output: z.number(),
cacheRead: z.number().optional(),
cacheWrite5m: z.number().optional(),
cacheWrite1h: z.number().optional(),
})
const ModelSchema = z.object({
cost: ModelCostSchema,
cost200K: ModelCostSchema.optional(),
allowAnonymous: z.boolean().optional(),
providers: z.array(
z.object({
id: z.string(),
api: z.string(),
apiKey: z.string(),
model: z.string(),
weight: z.number().optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
disabled: z.boolean().optional(),
}),
),
})
type Model = z.infer<typeof ModelSchema>
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
@@ -259,31 +85,29 @@ export async function handler(
session: input.request.headers.get("x-opencode-session"),
request: input.request.headers.get("x-opencode-request"),
})
const MODEL = validateModel()
const apiKey = await authenticate()
const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "")
await checkCreditsAndLimit()
const providerName = selectProvider()
const providerData = MODEL.providers[providerName]
logger.metric({ provider: providerName })
const modelInfo = validateModel(body.model)
const providerInfo = selectProvider(modelInfo)
const authInfo = await authenticate(modelInfo)
validateBilling(modelInfo, authInfo)
logger.metric({ provider: providerInfo.id })
// Request to model provider
const startTimestamp = Date.now()
const res = await fetch(path.posix.join(providerData.api, url.pathname.replace(/^\/zen/, "") + url.search), {
const res = await fetch(path.posix.join(providerInfo.api, url.pathname.replace(/^\/zen/, "") + url.search), {
method: "POST",
headers: (() => {
const headers = input.request.headers
headers.delete("host")
headers.delete("content-length")
opts.setAuthHeader(headers, providerData.apiKey)
Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => {
opts.setAuthHeader(headers, providerInfo.apiKey)
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
return headers
})(),
body: JSON.stringify({
...(opts.modifyBody?.(body) ?? body),
model: providerData.model,
model: providerInfo.model,
}),
})
@@ -302,8 +126,8 @@ export async function handler(
const body = JSON.stringify(json)
logger.metric({ response_length: body.length })
logger.debug(body)
await trackUsage(json.usage)
await reload()
await trackUsage(authInfo, modelInfo, providerInfo.id, json.usage)
await reload(authInfo)
return new Response(body, {
status: res.status,
statusText: res.statusText,
@@ -323,18 +147,25 @@ export async function handler(
return (
reader?.read().then(async ({ done, value }) => {
if (done) {
logger.metric({ response_length: responseLength })
logger.metric({
response_length: responseLength,
"timestamp.last_byte": Date.now(),
})
const usage = opts.getStreamUsage()
if (usage) {
await trackUsage(usage)
await reload()
await trackUsage(authInfo, modelInfo, providerInfo.id, usage)
await reload(authInfo)
}
c.close()
return
}
if (responseLength === 0) {
logger.metric({ time_to_first_byte: Date.now() - startTimestamp })
const now = Date.now()
logger.metric({
time_to_first_byte: now - startTimestamp,
"timestamp.first_byte": now,
})
}
responseLength += value.length
buffer += decoder.decode(value, { stream: true })
@@ -363,202 +194,6 @@ export async function handler(
statusText: res.statusText,
headers: resHeaders,
})
function validateModel() {
if (!(body.model in MODELS)) {
throw new ModelError(`Model ${body.model} not supported`)
}
const model = MODELS[body.model as keyof typeof MODELS]
logger.metric({ model: model.id })
return model
}
async function authenticate() {
try {
const apiKey = opts.parseApiKey(input.request.headers)
if (!apiKey) throw new AuthError("Missing API key.")
const key = await Database.use((tx) =>
tx
.select({
id: KeyTable.id,
workspaceID: KeyTable.workspaceID,
})
.from(KeyTable)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)
if (!key) throw new AuthError("Invalid API key.")
logger.metric({
api_key: key.id,
workspace: key.workspaceID,
})
return key
} catch (e) {
// ignore error if model does not require authentication
if (!MODEL.auth) return
throw e
}
}
async function checkCreditsAndLimit() {
if (!apiKey || !MODEL.auth || isFree) return
const billing = await Database.use((tx) =>
tx
.select({
balance: BillingTable.balance,
paymentMethodID: BillingTable.paymentMethodID,
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
.then((rows) => rows[0]),
)
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
if (
billing.monthlyLimit &&
billing.monthlyUsage &&
billing.timeMonthlyUsageUpdated &&
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
) {
const now = new Date()
const currentYear = now.getUTCFullYear()
const currentMonth = now.getUTCMonth()
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
}
}
function selectProvider() {
const picks = Object.entries(MODEL.providers).flatMap(([name, provider]) =>
Array<string>(provider.weight ?? 1).fill(name),
)
return picks[Math.floor(Math.random() * picks.length)]
}
async function trackUsage(usage: any) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
opts.normalizeUsage(usage)
const modelCost = typeof MODEL.cost === "function" ? MODEL.cost(usage) : MODEL.cost
const inputCost = modelCost.input * inputTokens * 100
const outputCost = modelCost.output * outputTokens * 100
const reasoningCost = (() => {
if (!reasoningTokens) return undefined
return modelCost.output * reasoningTokens * 100
})()
const cacheReadCost = (() => {
if (!cacheReadTokens) return undefined
if (!modelCost.cacheRead) return undefined
return modelCost.cacheRead * cacheReadTokens * 100
})()
const cacheWrite5mCost = (() => {
if (!cacheWrite5mTokens) return undefined
if (!modelCost.cacheWrite5m) return undefined
return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
})()
const cacheWrite1hCost = (() => {
if (!cacheWrite1hTokens) return undefined
if (!modelCost.cacheWrite1h) return undefined
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
})()
const totalCostInCent =
inputCost +
outputCost +
(reasoningCost ?? 0) +
(cacheReadCost ?? 0) +
(cacheWrite5mCost ?? 0) +
(cacheWrite1hCost ?? 0)
logger.metric({
"tokens.input": inputTokens,
"tokens.output": outputTokens,
"tokens.reasoning": reasoningTokens,
"tokens.cache_read": cacheReadTokens,
"tokens.cache_write_5m": cacheWrite5mTokens,
"tokens.cache_write_1h": cacheWrite1hTokens,
"cost.input": Math.round(inputCost),
"cost.output": Math.round(outputCost),
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
"cost.total": Math.round(totalCostInCent),
})
if (!apiKey) return
const cost = isFree ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: apiKey.workspaceID,
id: Identifier.create("usage"),
model: MODEL.id,
provider: providerName,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
cost,
})
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(eq(KeyTable.id, apiKey.id)),
)
}
async function reload() {
if (!apiKey) return
const lock = await Database.use((tx) =>
tx
.update(BillingTable)
.set({
timeReloadLockedTill: sql`now() + interval 1 minute`,
})
.where(
and(
eq(BillingTable.workspaceID, apiKey.workspaceID),
eq(BillingTable.reload, true),
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
),
),
)
if (lock.rowsAffected === 0) return
await Actor.provide("system", { workspaceID: apiKey.workspaceID }, async () => {
await Billing.reload()
})
}
} catch (error: any) {
logger.metric({
"error.type": error.constructor.name,
@@ -591,4 +226,222 @@ export async function handler(
{ status: 500 },
)
}
function validateModel(reqModel: string) {
const json = JSON.parse(Resource.ZEN_MODELS.value)
const allModels = z.record(z.string(), ModelSchema).parse(json)
if (!(reqModel in allModels)) {
throw new ModelError(`Model ${reqModel} not supported`)
}
const modelId = reqModel as keyof typeof allModels
const modelData = allModels[modelId]
logger.metric({ model: modelId })
return { id: modelId, ...modelData }
}
function selectProvider(model: Model) {
const providers = model.providers
.filter((provider) => !provider.disabled)
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
return providers[Math.floor(Math.random() * providers.length)]
}
async function authenticate(model: Model) {
const apiKey = opts.parseApiKey(input.request.headers)
if (!apiKey) {
if (model.allowAnonymous) return
throw new AuthError("Missing API key.")
}
const data = await Database.use((tx) =>
tx
.select({
apiKey: KeyTable.id,
workspaceID: KeyTable.workspaceID,
balance: BillingTable.balance,
paymentMethodID: BillingTable.paymentMethodID,
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
})
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)
if (!data) throw new AuthError("Invalid API key.")
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
})
const isFree = FREE_WORKSPACES.includes(data.workspaceID)
return {
apiKeyId: data.apiKey,
workspaceID: data.workspaceID,
billing: {
paymentMethodID: data.paymentMethodID,
balance: data.balance,
monthlyLimit: data.monthlyLimit,
monthlyUsage: data.monthlyUsage,
timeMonthlyUsageUpdated: data.timeMonthlyUsageUpdated,
},
isFree,
}
}
function validateBilling(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
if (!authInfo || authInfo.isFree) return
if (model.allowAnonymous) return
const billing = authInfo.billing
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
if (
billing.monthlyLimit &&
billing.monthlyUsage &&
billing.timeMonthlyUsageUpdated &&
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
) {
const now = new Date()
const currentYear = now.getUTCFullYear()
const currentMonth = now.getUTCMonth()
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
}
}
async function trackUsage(
authInfo: Awaited<ReturnType<typeof authenticate>>,
modelInfo: ReturnType<typeof validateModel>,
providerId: string,
usage: any,
) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
opts.normalizeUsage(usage)
const modelCost =
modelInfo.cost200K &&
usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens > 200_000
? modelInfo.cost200K
: modelInfo.cost
const inputCost = modelCost.input * inputTokens * 100
const outputCost = modelCost.output * outputTokens * 100
const reasoningCost = (() => {
if (!reasoningTokens) return undefined
return modelCost.output * reasoningTokens * 100
})()
const cacheReadCost = (() => {
if (!cacheReadTokens) return undefined
if (!modelCost.cacheRead) return undefined
return modelCost.cacheRead * cacheReadTokens * 100
})()
const cacheWrite5mCost = (() => {
if (!cacheWrite5mTokens) return undefined
if (!modelCost.cacheWrite5m) return undefined
return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
})()
const cacheWrite1hCost = (() => {
if (!cacheWrite1hTokens) return undefined
if (!modelCost.cacheWrite1h) return undefined
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
})()
const totalCostInCent =
inputCost +
outputCost +
(reasoningCost ?? 0) +
(cacheReadCost ?? 0) +
(cacheWrite5mCost ?? 0) +
(cacheWrite1hCost ?? 0)
logger.metric({
"tokens.input": inputTokens,
"tokens.output": outputTokens,
"tokens.reasoning": reasoningTokens,
"tokens.cache_read": cacheReadTokens,
"tokens.cache_write_5m": cacheWrite5mTokens,
"tokens.cache_write_1h": cacheWrite1hTokens,
"cost.input": Math.round(inputCost),
"cost.output": Math.round(outputCost),
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
"cost.total": Math.round(totalCostInCent),
})
if (!authInfo) return
const cost = authInfo.isFree ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
id: Identifier.create("usage"),
model: modelInfo.id,
provider: providerId,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
cost,
})
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID))
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(eq(KeyTable.id, authInfo.apiKeyId)),
)
}
async function reload(authInfo: Awaited<ReturnType<typeof authenticate>>) {
if (!authInfo) return
const lock = await Database.use((tx) =>
tx
.update(BillingTable)
.set({
timeReloadLockedTill: sql`now() + interval 1 minute`,
})
.where(
and(
eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true),
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
),
),
)
if (lock.rowsAffected === 0) return
await Actor.provide("system", { workspaceID: authInfo.workspaceID }, async () => {
await Billing.reload()
})
}
}
@@ -5,6 +5,9 @@ type Usage = {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
// used by moonshot
cached_tokens?: number
// used by xai
prompt_tokens_details?: {
text_tokens?: number
audio_tokens?: number
@@ -48,7 +51,7 @@ export function POST(input: APIEvent) {
inputTokens: usage.prompt_tokens ?? 0,
outputTokens: usage.completion_tokens ?? 0,
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? undefined,
cacheReadTokens: usage.prompt_tokens_details?.cached_tokens ?? undefined,
cacheReadTokens: usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined,
}),
})
}
@@ -30,22 +30,25 @@ export function POST(input: APIEvent) {
let json
try {
json = JSON.parse(data.slice(6)) as { usage?: Usage }
json = JSON.parse(data.slice(6))
} catch (e) {
return
}
if (!json.usage) return
// ie. { type: "message_start"; message: { usage: Usage } }
// ie. { type: "message_delta"; usage: Usage }
const usageUpdate = json.usage ?? json.message?.usage
if (!usageUpdate) return
usage = {
...usage,
...json.usage,
...usageUpdate,
cache_creation: {
...usage?.cache_creation,
...json.usage.cache_creation,
...usageUpdate.cache_creation,
},
server_tool_use: {
...usage?.server_tool_use,
...json.usage.server_tool_use,
...usageUpdate.server_tool_use,
},
}
},
@@ -0,0 +1 @@
ALTER TABLE `workspace` ADD `data_share` boolean;
@@ -0,0 +1 @@
ALTER TABLE `workspace` DROP COLUMN `data_share`;
@@ -0,0 +1 @@
ALTER TABLE `payment` ADD `time_refunded` timestamp(3);
@@ -0,0 +1 @@
ALTER TABLE `payment` ADD `invoice_id` varchar(255);
@@ -0,0 +1,650 @@
{
"version": "5",
"dialect": "mysql",
"id": "12189a4e-5083-4b17-b8e3-8279c9a3e61a",
"prevId": "28336c91-553c-4d1d-9875-1ee761e47582",
"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
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email": {
"name": "email",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"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_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_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
},
"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
}
},
"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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"usage_workspace_id_id_pk": {
"name": "usage_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"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
},
"actor": {
"name": "actor",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"old_name": {
"name": "old_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"key": {
"name": "key",
"type": "varchar(255)",
"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
},
"name": {
"name": "name",
"columns": ["workspace_id", "name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"key_workspace_id_id_pk": {
"name": "key_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
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"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
}
},
"indexes": {
"user_email": {
"name": "user_email",
"columns": ["workspace_id", "email"],
"isUnique": true
}
},
"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": false,
"autoincrement": false
},
"data_share": {
"name": "data_share",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"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": {}
}
}
@@ -0,0 +1,643 @@
{
"version": "5",
"dialect": "mysql",
"id": "7f3989cb-3e8b-430e-a0f5-f87051d1d824",
"prevId": "12189a4e-5083-4b17-b8e3-8279c9a3e61a",
"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
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email": {
"name": "email",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"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_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_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
},
"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
}
},
"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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"usage_workspace_id_id_pk": {
"name": "usage_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"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
},
"actor": {
"name": "actor",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"old_name": {
"name": "old_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"key": {
"name": "key",
"type": "varchar(255)",
"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
},
"name": {
"name": "name",
"columns": ["workspace_id", "name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"key_workspace_id_id_pk": {
"name": "key_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
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"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
}
},
"indexes": {
"user_email": {
"name": "user_email",
"columns": ["workspace_id", "email"],
"isUnique": true
}
},
"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": false,
"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": {}
}
}
@@ -0,0 +1,650 @@
{
"version": "5",
"dialect": "mysql",
"id": "45b67fb4-77ce-4aa2-b883-1971429c69f5",
"prevId": "7f3989cb-3e8b-430e-a0f5-f87051d1d824",
"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
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email": {
"name": "email",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"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_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_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
},
"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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"usage_workspace_id_id_pk": {
"name": "usage_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"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
},
"actor": {
"name": "actor",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"old_name": {
"name": "old_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"key": {
"name": "key",
"type": "varchar(255)",
"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
},
"name": {
"name": "name",
"columns": ["workspace_id", "name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"key_workspace_id_id_pk": {
"name": "key_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
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"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
}
},
"indexes": {
"user_email": {
"name": "user_email",
"columns": ["workspace_id", "email"],
"isUnique": true
}
},
"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": false,
"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": {}
}
}
@@ -0,0 +1,657 @@
{
"version": "5",
"dialect": "mysql",
"id": "100a21cf-ff9c-476f-bf7d-100c1824b2b2",
"prevId": "45b67fb4-77ce-4aa2-b883-1971429c69f5",
"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
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email": {
"name": "email",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"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_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_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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"usage_workspace_id_id_pk": {
"name": "usage_workspace_id_id_pk",
"columns": ["workspace_id", "id"]
}
},
"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
},
"actor": {
"name": "actor",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"old_name": {
"name": "old_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"key": {
"name": "key",
"type": "varchar(255)",
"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
},
"name": {
"name": "name",
"columns": ["workspace_id", "name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"key_workspace_id_id_pk": {
"name": "key_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
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"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
}
},
"indexes": {
"user_email": {
"name": "user_email",
"columns": ["workspace_id", "email"],
"isUnique": true
}
},
"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": false,
"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": {}
}
}
@@ -99,6 +99,34 @@
"when": 1757956978089,
"tag": "0013_absurd_hobgoblin",
"breakpoints": true
},
{
"idx": 14,
"version": "5",
"when": 1758289919722,
"tag": "0014_demonic_princess_powerful",
"breakpoints": true
},
{
"idx": 15,
"version": "5",
"when": 1758428484500,
"tag": "0015_cloudy_revanche",
"breakpoints": true
},
{
"idx": 16,
"version": "5",
"when": 1758663086739,
"tag": "0016_cold_la_nuit",
"breakpoints": true
},
{
"idx": 17,
"version": "5",
"when": 1758755183232,
"tag": "0017_woozy_thaddeus_ross",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "0.10.1",
"version": "0.11.6",
"private": true,
"type": "module",
"dependencies": {
+44 -20
View File
@@ -10,6 +10,8 @@ import { Identifier } from "./identifier"
import { centsToMicroCents } from "./util/price"
export namespace Billing {
export const CHARGE_NAME = "opencode credits"
export const CHARGE_FEE_NAME = "processing fee"
export const CHARGE_AMOUNT = 2000 // $20
export const CHARGE_FEE = 123 // Stripe fee 4.4% + $0.30
export const CHARGE_THRESHOLD = 500 // $5
@@ -73,22 +75,39 @@ export namespace Billing {
.then((rows) => rows[0]),
)
const paymentID = Identifier.create("payment")
let charge
let invoice
try {
charge = await Billing.stripe().paymentIntents.create(
{
amount: Billing.CHARGE_AMOUNT + Billing.CHARGE_FEE,
currency: "usd",
customer: customerID!,
payment_method: paymentMethodID!,
off_session: true,
confirm: true,
},
{ idempotencyKey: paymentID },
)
if (charge.status !== "succeeded") throw new Error(charge.last_payment_error?.message)
const draft = await Billing.stripe().invoices.create({
customer: customerID!,
auto_advance: false,
default_payment_method: paymentMethodID!,
collection_method: "charge_automatically",
currency: "usd",
})
await Billing.stripe().invoiceItems.create({
amount: Billing.CHARGE_AMOUNT,
currency: "usd",
customer: customerID!,
description: CHARGE_NAME,
invoice: draft.id!,
})
await Billing.stripe().invoiceItems.create({
amount: Billing.CHARGE_FEE,
currency: "usd",
customer: customerID!,
description: CHARGE_FEE_NAME,
invoice: draft.id!,
})
await Billing.stripe().invoices.finalizeInvoice(draft.id!)
invoice = await Billing.stripe().invoices.pay(draft.id!, {
off_session: true,
payment_method: paymentMethodID!,
expand: ["payments"],
})
if (invoice.status !== "paid" || invoice.payments?.data.length !== 1)
throw new Error(invoice.last_finalization_error?.message)
} catch (e: any) {
console.error(e)
await Database.use((tx) =>
tx
.update(BillingTable)
@@ -114,7 +133,8 @@ export namespace Billing {
workspaceID: Actor.workspace(),
id: paymentID,
amount: centsToMicroCents(CHARGE_AMOUNT),
paymentID: charge.id,
invoiceID: invoice.id!,
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
customerID,
})
})
@@ -155,12 +175,13 @@ export namespace Billing {
const customer = await Billing.get()
const session = await Billing.stripe().checkout.sessions.create({
mode: "payment",
billing_address_collection: "required",
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: "opencode credits",
name: CHARGE_NAME,
},
unit_amount: CHARGE_AMOUNT,
},
@@ -170,16 +191,13 @@ export namespace Billing {
price_data: {
currency: "usd",
product_data: {
name: "processing fee",
name: CHARGE_FEE_NAME,
},
unit_amount: CHARGE_FEE,
},
quantity: 1,
},
],
payment_intent_data: {
setup_future_usage: "on_session",
},
...(customer.customerID
? {
customer: customer.customerID,
@@ -192,6 +210,12 @@ export namespace Billing {
workspaceID: Actor.workspace(),
},
currency: "usd",
invoice_creation: {
enabled: true,
},
payment_intent_data: {
setup_future_usage: "on_session",
},
payment_method_types: ["card"],
payment_method_data: {
allow_redisplay: "always",
@@ -28,8 +28,10 @@ export const PaymentTable = mysqlTable(
...workspaceColumns,
...timestamps,
customerID: varchar("customer_id", { length: 255 }),
invoiceID: varchar("invoice_id", { length: 255 }),
paymentID: varchar("payment_id", { length: 255 }),
amount: bigint("amount", { mode: "number" }).notNull(),
timeRefunded: utc("time_refunded"),
},
(table) => [...workspaceIndexes(table)],
)
@@ -1,4 +1,4 @@
import { primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { primaryKey, mysqlTable, uniqueIndex, varchar, boolean } from "drizzle-orm/mysql-core"
import { timestamps, ulid } from "../drizzle/types"
export const WorkspaceTable = mysqlTable(
-1
View File
@@ -1,6 +1,5 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { centsToMicroCents } from "./util/price"
import { Actor } from "./actor"
import { Database, eq } from "./drizzle"
import { Identifier } from "./identifier"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "0.10.1",
"version": "0.11.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
@@ -9,7 +9,12 @@ export default {
if (event.event.request.method !== "POST") continue
const url = new URL(event.event.request.url)
if (url.pathname !== "/zen/v1/chat/completions") return
if (
url.pathname !== "/zen/v1/chat/completions" &&
url.pathname !== "/zen/v1/messages" &&
url.pathname !== "/zen/v1/responses"
)
return
let metrics = {
event_type: "completions",
+1 -17
View File
@@ -6,18 +6,10 @@
import "sst"
declare module "sst" {
export interface Resource {
ANTHROPIC_API_KEY: {
type: "sst.sst.Secret"
value: string
}
AUTH_API_URL: {
type: "sst.sst.Linkable"
value: string
}
BASETEN_API_KEY: {
type: "sst.sst.Secret"
value: string
}
Console: {
type: "sst.cloudflare.SolidStart"
url: string
@@ -30,10 +22,6 @@ declare module "sst" {
type: "sst.sst.Linkable"
username: string
}
FIREWORKS_API_KEY: {
type: "sst.sst.Secret"
value: string
}
GITHUB_APP_ID: {
type: "sst.sst.Secret"
value: string
@@ -58,10 +46,6 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
OPENAI_API_KEY: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -74,7 +58,7 @@ declare module "sst" {
type: "sst.cloudflare.Astro"
url: string
}
XAI_API_KEY: {
ZEN_MODELS: {
type: "sst.sst.Secret"
value: string
}
+1 -17
View File
@@ -6,18 +6,10 @@
import "sst"
declare module "sst" {
export interface Resource {
ANTHROPIC_API_KEY: {
type: "sst.sst.Secret"
value: string
}
AUTH_API_URL: {
type: "sst.sst.Linkable"
value: string
}
BASETEN_API_KEY: {
type: "sst.sst.Secret"
value: string
}
Console: {
type: "sst.cloudflare.SolidStart"
url: string
@@ -30,10 +22,6 @@ declare module "sst" {
type: "sst.sst.Linkable"
username: string
}
FIREWORKS_API_KEY: {
type: "sst.sst.Secret"
value: string
}
GITHUB_APP_ID: {
type: "sst.sst.Secret"
value: string
@@ -58,10 +46,6 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
OPENAI_API_KEY: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -74,7 +58,7 @@ declare module "sst" {
type: "sst.cloudflare.Astro"
url: string
}
XAI_API_KEY: {
ZEN_MODELS: {
type: "sst.sst.Secret"
value: string
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-scripts",
"version": "0.10.1",
"version": "0.11.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "0.10.1",
"version": "0.11.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -17
View File
@@ -6,18 +6,10 @@
import "sst"
declare module "sst" {
export interface Resource {
ANTHROPIC_API_KEY: {
type: "sst.sst.Secret"
value: string
}
AUTH_API_URL: {
type: "sst.sst.Linkable"
value: string
}
BASETEN_API_KEY: {
type: "sst.sst.Secret"
value: string
}
Console: {
type: "sst.cloudflare.SolidStart"
url: string
@@ -30,10 +22,6 @@ declare module "sst" {
type: "sst.sst.Linkable"
username: string
}
FIREWORKS_API_KEY: {
type: "sst.sst.Secret"
value: string
}
GITHUB_APP_ID: {
type: "sst.sst.Secret"
value: string
@@ -58,10 +46,6 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
OPENAI_API_KEY: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -74,7 +58,7 @@ declare module "sst" {
type: "sst.cloudflare.Astro"
url: string
}
XAI_API_KEY: {
ZEN_MODELS: {
type: "sst.sst.Secret"
value: string
}
+2 -1
View File
@@ -1,11 +1,12 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.10.1",
"version": "0.11.6",
"name": "opencode",
"type": "module",
"private": true,
"scripts": {
"typecheck": "tsc --noEmit",
"build": "./script/build.ts",
"dev": "bun run --conditions=development ./src/index.ts"
},
"bin": {
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bun
const dir = new URL("..", import.meta.url).pathname
process.chdir(dir)
import { $ } from "bun"
import pkg from "../package.json"
const GOARCH: Record<string, string> = {
arm64: "arm64",
x64: "amd64",
"x64-baseline": "amd64",
}
const targets = [
["windows", "x64"],
["linux", "arm64"],
["linux", "x64"],
["linux", "x64-baseline"],
["darwin", "x64"],
["darwin", "x64-baseline"],
["darwin", "arm64"],
]
await $`rm -rf dist`
const binaries: Record<string, string> = {}
const version = process.env["OPENCODE_VERSION"] ?? "dev"
for (const [os, arch] of targets) {
console.log(`building ${os}-${arch}`)
const name = `${pkg.name}-${os}-${arch}`
await $`mkdir -p dist/${name}/bin`
await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`
.cwd("../tui")
.quiet()
await Bun.build({
compile: {
target: `bun-${os}-${arch}` as any,
outfile: `dist/${name}/bin/opencode`,
execArgv: [`--user-agent=opencode/${version}`, `--env-file=""`, `--`],
windows: {},
},
entrypoints: ["./src/index.ts"],
define: {
OPENCODE_VERSION: `'${version}'`,
OPENCODE_TUI_PATH: `'../../../dist/${name}/bin/tui'`,
},
})
await $`rm -rf ./dist/${name}/bin/tui`
await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify(
{
name,
version,
os: [os === "windows" ? "win32" : os],
cpu: [arch],
},
null,
2,
),
)
binaries[name] = version
}
export { binaries }
+20 -71
View File
@@ -5,76 +5,22 @@ import { $ } from "bun"
import pkg from "../package.json"
const dry = process.env["OPENCODE_DRY"] === "true"
const version = process.env["OPENCODE_VERSION"]!
const snapshot = process.env["OPENCODE_SNAPSHOT"] === "true"
let version = process.env["OPENCODE_VERSION"]
const tag = process.env["OPENCODE_TAG"] ?? (snapshot ? "snapshot" : "latest")
if (!version && snapshot) {
version = `0.0.0-${tag}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}`
process.env["OPENCODE_VERSION"] = version
}
if (!version) throw new Error("OPENCODE_VERSION is required")
console.log(`publishing ${version}`)
const GOARCH: Record<string, string> = {
arm64: "arm64",
x64: "amd64",
"x64-baseline": "amd64",
}
const targets = [
["windows", "x64"],
["linux", "arm64"],
["linux", "x64"],
["linux", "x64-baseline"],
["darwin", "x64"],
["darwin", "x64-baseline"],
["darwin", "arm64"],
]
await $`rm -rf dist`
const optionalDependencies: Record<string, string> = {}
const npmTag = snapshot ? "snapshot" : "latest"
for (const [os, arch] of targets) {
console.log(`building ${os}-${arch}`)
const name = `${pkg.name}-${os}-${arch}`
await $`mkdir -p dist/${name}/bin`
await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
"../tui",
)
await Bun.build({
compile: {
target: `bun-${os}-${arch}` as any,
outfile: `dist/${name}/bin/opencode`,
execArgv: [`--user-agent=opencode/${version}`, `--env-file=""`, `--`],
windows: {},
},
entrypoints: ["./src/index.ts"],
define: {
OPENCODE_VERSION: `'${version}'`,
OPENCODE_TUI_PATH: `'../../../dist/${name}/bin/tui'`,
},
})
// await $`bun build --define OPENCODE_TUI_PATH="'../../../dist/${name}/bin/tui'" --define OPENCODE_VERSION="'${version}'" --compile --target=bun-${os}-${arch} --outfile=dist/${name}/bin/opencode ./src/index.ts`
// Run the binary only if it matches current OS/arch
if (
process.platform === (os === "windows" ? "win32" : os) &&
(process.arch === arch || (process.arch === "x64" && arch === "x64-baseline"))
) {
console.log(`smoke test: running dist/${name}/bin/opencode --version`)
await $`./dist/${name}/bin/opencode --version`
}
await $`rm -rf ./dist/${name}/bin/tui`
await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify(
{
name,
version,
os: [os === "windows" ? "win32" : os],
cpu: [arch],
},
null,
2,
),
)
if (!dry) await $`cd dist/${name} && chmod 777 -R . && bun publish --access public --tag ${npmTag}`
optionalDependencies[name] = version
const { binaries } = await import("./build.ts")
{
const name = `${pkg.name}-${process.platform}-${process.arch}`
console.log(`smoke test: running dist/${name}/bin/opencode --version`)
await $`./dist/${name}/bin/opencode --version`
}
await $`mkdir -p ./dist/${pkg.name}`
@@ -93,16 +39,19 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
postinstall: "node ./postinstall.mjs",
},
version,
optionalDependencies,
optionalDependencies: binaries,
},
null,
2,
),
)
if (!dry) await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
for (const [name] of Object.entries(binaries)) {
await $`cd dist/${name} && chmod 777 -R . && bun publish --access public --tag ${tag}`
}
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${tag}`
if (!snapshot) {
for (const key of Object.keys(optionalDependencies)) {
for (const key of Object.keys(binaries)) {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
}
@@ -195,7 +144,7 @@ if (!snapshot) {
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
await $`cd ./dist/aur-${pkg} && git push`
}
// Homebrew formula
@@ -254,5 +203,5 @@ if (!snapshot) {
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
await $`cd ./dist/homebrew-tap && git add opencode.rb`
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/homebrew-tap && git push`
await $`cd ./dist/homebrew-tap && git push`
}
+9 -14
View File
@@ -1,19 +1,14 @@
import { Format } from "../format"
import { LSP } from "../lsp"
import { Plugin } from "../plugin"
import { InstanceBootstrap } from "../project/bootstrap"
import { Instance } from "../project/instance"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide(directory, async () => {
await Plugin.init()
Share.init()
Format.init()
LSP.init()
Snapshot.init()
const result = await cb()
await Instance.dispose()
return result
return Instance.provide({
directory,
init: InstanceBootstrap,
fn: async () => {
const result = await cb()
await Instance.dispose()
return result
},
})
}
+104 -101
View File
@@ -11,121 +11,124 @@ const AgentCreateCommand = cmd({
command: "create",
describe: "create a new agent",
async handler() {
await Instance.provide(process.cwd(), async () => {
UI.empty()
prompts.intro("Create agent")
const project = Instance.project
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Create agent")
const project = Instance.project
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: Instance.worktree,
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const generated = await Agent.generate({ description: query }).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
const availableTools = [
"bash",
"read",
"write",
"edit",
"list",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"todoread",
]
const selectedTools = await prompts.multiselect({
message: "Select tools to enable",
options: availableTools.map((tool) => ({
label: tool,
value: tool,
})),
initialValues: availableTools,
})
if (prompts.isCancel(selectedTools)) throw new UI.CancelledError()
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "Current project",
value: "project" as const,
hint: Instance.worktree,
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all",
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const generated = await Agent.generate({ description: query }).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
const availableTools = [
"bash",
"read",
"write",
"edit",
"list",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"todoread",
]
const selectedTools = await prompts.multiselect({
message: "Select tools to enable",
options: availableTools.map((tool) => ({
label: tool,
value: tool,
})),
initialValues: availableTools,
})
if (prompts.isCancel(selectedTools)) throw new UI.CancelledError()
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all",
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
const tools: Record<string, boolean> = {}
for (const tool of availableTools) {
if (!selectedTools.includes(tool)) {
tools[tool] = false
const tools: Record<string, boolean> = {}
for (const tool of availableTools) {
if (!selectedTools.includes(tool)) {
tools[tool] = false
}
}
}
const frontmatter: any = {
description: generated.whenToUse,
mode: modeResult,
}
if (Object.keys(tools).length > 0) {
frontmatter.tools = tools
}
const frontmatter: any = {
description: generated.whenToUse,
mode: modeResult,
}
if (Object.keys(tools).length > 0) {
frontmatter.tools = tools
}
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(
scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
`agent`,
`${generated.identifier}.md`,
)
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(
scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
`agent`,
`${generated.identifier}.md`,
)
await Bun.write(filePath, content)
await Bun.write(filePath, content)
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
},
})
},
})
+185 -182
View File
@@ -74,196 +74,199 @@ export const AuthLoginCommand = cmd({
type: "string",
}),
async handler(args) {
await Instance.provide(process.cwd(), async () => {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({
cmd: wellknown.auth.command,
stdout: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const token = await new Response(proc.stdout).text()
await Auth.set(args.url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + args.url)
prompts.outro("Done")
return
}
await ModelsDev.refresh().catch(() => {})
const providers = await ModelsDev.get()
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] <= 1 ? "recommended" : undefined,
})),
),
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
let index = 0
if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
],
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({
cmd: wellknown.auth.command,
stdout: "pipe",
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
if (method.type === "oauth") {
await new Promise((resolve) => setTimeout(resolve, 10))
const authorize = await method.authorize()
if (authorize.url) {
prompts.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
if ("refresh" in result) {
await Auth.set(provider, {
type: "oauth",
refresh: result.refresh,
access: result.access,
expires: result.expires,
})
}
if ("key" in result) {
await Auth.set(provider, {
type: "api",
key: result.key,
})
}
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
if ("refresh" in result) {
await Auth.set(provider, {
type: "oauth",
refresh: result.refresh,
access: result.access,
expires: result.expires,
})
}
if ("key" in result) {
await Auth.set(provider, {
type: "api",
key: result.key,
})
}
prompts.log.success("Login successful")
}
const exit = await proc.exited
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const token = await new Response(proc.stdout).text()
await Auth.set(args.url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + args.url)
prompts.outro("Done")
return
}
}
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
await ModelsDev.refresh().catch(() => {})
const providers = await ModelsDev.get()
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] <= 1 ? "recommended" : undefined,
})),
),
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
let index = 0
if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
],
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
if (method.type === "oauth") {
await new Promise((resolve) => setTimeout(resolve, 10))
const authorize = await method.authorize()
if (authorize.url) {
prompts.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
if ("refresh" in result) {
await Auth.set(provider, {
type: "oauth",
refresh: result.refresh,
access: result.access,
expires: result.expires,
})
}
if ("key" in result) {
await Auth.set(provider, {
type: "api",
key: result.key,
})
}
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
if ("refresh" in result) {
await Auth.set(provider, {
type: "oauth",
refresh: result.refresh,
access: result.access,
expires: result.expires,
})
}
if ("key" in result) {
await Auth.set(provider, {
type: "api",
key: result.key,
})
}
prompts.log.success("Login successful")
}
}
prompts.outro("Done")
return
}
}
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
)
prompts.outro("Done")
return
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await Auth.set(provider, {
type: "api",
key,
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
)
prompts.outro("Done")
return
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await Auth.set(provider, {
type: "api",
key,
})
prompts.outro("Done")
},
})
},
})
+180 -175
View File
@@ -21,190 +21,194 @@ export const GithubInstallCommand = cmd({
command: "install",
describe: "install the GitHub agent",
async handler() {
await Instance.provide(process.cwd(), async () => {
UI.empty()
prompts.intro("Install GitHub agent")
const app = await getAppInfo()
await installGitHubApp()
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Install GitHub agent")
const app = await getAppInfo()
await installGitHubApp()
const providers = await ModelsDev.get()
const provider = await promptProvider()
const model = await promptModel()
//const key = await promptKey()
const providers = await ModelsDev.get()
const provider = await promptProvider()
const model = await promptModel()
//const key = await promptKey()
await addWorkflowFiles()
printNextSteps()
await addWorkflowFiles()
printNextSteps()
function printNextSteps() {
let step2
if (provider === "amazon-bedrock") {
step2 =
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
} else {
step2 = [
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
"",
...providers[provider].env.map((e) => ` - ${e}`),
].join("\n")
}
prompts.outro(
[
"Next steps:",
"",
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
step2,
"",
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
"",
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
].join("\n"),
)
}
async function getAppInfo() {
const project = Instance.project
if (project.vcs !== "git") {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
// Get repo info
const info = await $`git remote get-url origin`
.quiet()
.nothrow()
.text()
.then((text) => text.trim())
// match https or git pattern
// ie. https://github.com/sst/opencode.git
// ie. https://github.com/sst/opencode
// ie. git@github.com:sst/opencode.git
// ie. git@github.com:sst/opencode
// ie. ssh://git@github.com/sst/opencode.git
// ie. ssh://git@github.com/sst/opencode
const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
const [, owner, repo] = parsed
return { owner, repo, root: Instance.worktree }
}
async function promptProvider() {
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
let provider = await prompts.select({
message: "Select provider",
maxItems: 8,
options: pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] <= 1 ? "recommended" : undefined,
})),
),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
return provider
}
async function promptModel() {
const providerData = providers[provider]!
const model = await prompts.select({
message: "Select model",
maxItems: 8,
options: pipe(
providerData.models,
values(),
sortBy((x) => x.name ?? x.id),
map((x) => ({
label: x.name ?? x.id,
value: x.id,
})),
),
})
if (prompts.isCancel(model)) throw new UI.CancelledError()
return model
}
async function installGitHubApp() {
const s = prompts.spinner()
s.start("Installing GitHub app")
// Get installation
const installation = await getInstallation()
if (installation) return s.stop("GitHub app already installed")
// Open browser
const url = "https://github.com/apps/opencode-agent"
const command =
process.platform === "darwin"
? `open "${url}"`
: process.platform === "win32"
? `start "${url}"`
: `xdg-open "${url}"`
exec(command, (error) => {
if (error) {
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
function printNextSteps() {
let step2
if (provider === "amazon-bedrock") {
step2 =
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
} else {
step2 = [
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
"",
...providers[provider].env.map((e) => ` - ${e}`),
].join("\n")
}
})
// Wait for installation
s.message("Waiting for GitHub app to be installed")
const MAX_RETRIES = 120
let retries = 0
do {
const installation = await getInstallation()
if (installation) break
prompts.outro(
[
"Next steps:",
"",
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
step2,
"",
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
"",
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
].join("\n"),
)
}
if (retries > MAX_RETRIES) {
s.stop(
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
)
async function getAppInfo() {
const project = Instance.project
if (project.vcs !== "git") {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
retries++
await new Promise((resolve) => setTimeout(resolve, 1000))
} while (true)
s.stop("Installed GitHub app")
async function getInstallation() {
return await fetch(`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`)
.then((res) => res.json())
.then((data) => data.installation)
// Get repo info
const info = await $`git remote get-url origin`
.quiet()
.nothrow()
.text()
.then((text) => text.trim())
// match https or git pattern
// ie. https://github.com/sst/opencode.git
// ie. https://github.com/sst/opencode
// ie. git@github.com:sst/opencode.git
// ie. git@github.com:sst/opencode
// ie. ssh://git@github.com/sst/opencode.git
// ie. ssh://git@github.com/sst/opencode
const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
const [, owner, repo] = parsed
return { owner, repo, root: Instance.worktree }
}
}
async function addWorkflowFiles() {
const envStr =
provider === "amazon-bedrock"
? ""
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
async function promptProvider() {
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
let provider = await prompts.select({
message: "Select provider",
maxItems: 8,
options: pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] <= 1 ? "recommended" : undefined,
})),
),
})
await Bun.write(
path.join(app.root, WORKFLOW_FILE),
`
if (prompts.isCancel(provider)) throw new UI.CancelledError()
return provider
}
async function promptModel() {
const providerData = providers[provider]!
const model = await prompts.select({
message: "Select model",
maxItems: 8,
options: pipe(
providerData.models,
values(),
sortBy((x) => x.name ?? x.id),
map((x) => ({
label: x.name ?? x.id,
value: x.id,
})),
),
})
if (prompts.isCancel(model)) throw new UI.CancelledError()
return model
}
async function installGitHubApp() {
const s = prompts.spinner()
s.start("Installing GitHub app")
// Get installation
const installation = await getInstallation()
if (installation) return s.stop("GitHub app already installed")
// Open browser
const url = "https://github.com/apps/opencode-agent"
const command =
process.platform === "darwin"
? `open "${url}"`
: process.platform === "win32"
? `start "${url}"`
: `xdg-open "${url}"`
exec(command, (error) => {
if (error) {
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
}
})
// Wait for installation
s.message("Waiting for GitHub app to be installed")
const MAX_RETRIES = 120
let retries = 0
do {
const installation = await getInstallation()
if (installation) break
if (retries > MAX_RETRIES) {
s.stop(
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
)
throw new UI.CancelledError()
}
retries++
await new Promise((resolve) => setTimeout(resolve, 1000))
} while (true)
s.stop("Installed GitHub app")
async function getInstallation() {
return await fetch(
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
)
.then((res) => res.json())
.then((data) => data.installation)
}
}
async function addWorkflowFiles() {
const envStr =
provider === "amazon-bedrock"
? ""
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
await Bun.write(
path.join(app.root, WORKFLOW_FILE),
`
name: opencode
on:
@@ -231,10 +235,11 @@ jobs:
with:
model: ${provider}/${model}
`.trim(),
)
)
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
}
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
}
},
})
},
})
+9 -6
View File
@@ -6,14 +6,17 @@ export const ModelsCommand = cmd({
command: "models",
describe: "list all available models",
handler: async () => {
await Instance.provide(process.cwd(), async () => {
const providers = await Provider.list()
await Instance.provide({
directory: process.cwd(),
async fn() {
const providers = await Provider.list()
for (const [providerID, provider] of Object.entries(providers)) {
for (const modelID of Object.keys(provider.info.models)) {
console.log(`${providerID}/${modelID}`)
for (const [providerID, provider] of Object.entries(providers)) {
for (const modelID of Object.keys(provider.info.models)) {
console.log(`${providerID}/${modelID}`)
}
}
}
},
})
},
})
+24
View File
@@ -64,6 +64,12 @@ export const RunCommand = cmd({
type: "string",
describe: "agent to use",
})
.option("format", {
type: "string",
choices: ["default", "json"],
default: "default",
describe: "format: default (formatted) or json (raw JSON events)",
})
},
handler: async (args) => {
let message = args.message.join(" ")
@@ -144,6 +150,20 @@ export const RunCommand = cmd({
)
}
function outputJsonEvent(type: string, data: any) {
if (args.format === "json") {
const jsonEvent = {
type,
timestamp: Date.now(),
sessionID: session?.id,
...data,
}
process.stdout.write(JSON.stringify(jsonEvent) + "\n")
return true
}
return false
}
let text = ""
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
@@ -152,6 +172,7 @@ export const RunCommand = cmd({
const part = evt.properties.part
if (part.type === "tool" && part.state.status === "completed") {
if (outputJsonEvent("tool_use", { part })) return
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title =
part.state.title ||
@@ -169,6 +190,7 @@ export const RunCommand = cmd({
text = part.text
if (part.time?.end) {
if (outputJsonEvent("text", { part })) return
UI.empty()
UI.println(UI.markdown(text))
UI.empty()
@@ -189,6 +211,7 @@ export const RunCommand = cmd({
}
errorMsg = errorMsg ? errorMsg + "\n" + err : err
if (outputJsonEvent("error", { error })) return
UI.error(err)
})
@@ -225,6 +248,7 @@ export const RunCommand = cmd({
const isPiped = !process.stdout.isTTY
if (isPiped) {
const match = result.parts.findLast((x: any) => x.type === "text") as any
if (outputJsonEvent("text", { text: match })) return
if (match) process.stdout.write(UI.markdown(match.text))
if (errorMsg) process.stdout.write(errorMsg)
}
+1 -3
View File
@@ -1,7 +1,6 @@
import { Global } from "../../global"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { cmd } from "./cmd"
import path from "path"
@@ -10,12 +9,12 @@ import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
import { Log } from "../../util/log"
import { FileWatcher } from "../../file/watcher"
import { Ide } from "../../ide"
import { Flag } from "../../flag/flag"
import { Session } from "../../session"
import { $ } from "bun"
import { bootstrap } from "../bootstrap"
declare global {
const OPENCODE_TUI_PATH: string
@@ -178,7 +177,6 @@ export const TuiCommand = cmd({
.then(() => Bus.publish(Ide.Event.Installed, { ide }))
.catch(() => {})
})()
FileWatcher.init()
await proc.exited
server.stop()
+154 -110
View File
@@ -14,6 +14,7 @@ import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { Instance } from "../project/instance"
import { LSPServer } from "../lsp/server"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -48,6 +49,8 @@ export namespace Config {
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = [
Global.Path.config,
@@ -56,109 +59,14 @@ export namespace Config {
)),
]
const markdownAgents = [
...(await Filesystem.globUp("agent/**/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/agent/**/*.md", Instance.directory, Instance.worktree)),
]
for (const item of markdownAgents) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
// Extract relative path from agent folder for nested agents
let agentName = path.basename(item, ".md")
const agentFolderPath = item.includes("/.opencode/agent/")
? item.split("/.opencode/agent/")[1]
: item.includes("/agent/")
? item.split("/agent/")[1]
: agentName + ".md"
// If agent is in a subfolder, include folder path in name
if (agentFolderPath.includes("/")) {
const relativePath = agentFolderPath.replace(".md", "")
const pathParts = relativePath.split("/")
agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1]
}
const config = {
name: agentName,
...md.data,
prompt: md.content.trim(),
}
const parsed = Agent.safeParse(config)
if (parsed.success) {
result.agent = mergeDeep(result.agent, {
[config.name]: parsed.data,
})
continue
}
throw new InvalidError({ path: item }, { cause: parsed.error })
for (const dir of directories) {
await assertValid(dir)
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir))
result.agent = mergeDeep(result.agent, await loadMode(dir))
result.plugin.push(...(await loadPlugin(dir)))
}
// Load mode markdown files
result.mode = result.mode || {}
const markdownModes = [
...(await Filesystem.globUp("mode/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/mode/*.md", Instance.directory, Instance.worktree)),
]
for (const item of markdownModes) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
const config = {
name: path.basename(item, ".md"),
...md.data,
prompt: md.content.trim(),
}
const parsed = Agent.safeParse(config)
if (parsed.success) {
result.agent = mergeDeep(result.mode, {
[config.name]: {
...parsed.data,
mode: "primary" as const,
},
})
continue
}
}
// Load command markdown files
result.command = result.command || {}
const markdownCommands = [
...(await Filesystem.globUp("command/**/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/command/**/*.md", Instance.directory, Instance.worktree)),
]
for (const item of markdownCommands) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
const name = (() => {
const patterns = ["/.opencode/command/", "/command/"]
const pattern = patterns.find((p) => item.includes(p))
if (pattern) {
const index = item.indexOf(pattern)
return item.slice(index + pattern.length, -3)
}
return path.basename(item, ".md")
})()
const config = {
name,
...md.data,
template: md.content.trim(),
}
const parsed = Command.safeParse(config)
if (parsed.success) {
result.command = mergeDeep(result.command, {
[config.name]: parsed.data,
})
continue
}
throw new InvalidError({ path: item }, { cause: parsed.error })
}
// Migrate deprecated mode field to agent field
for (const [name, mode] of Object.entries(result.mode)) {
result.agent = mergeDeep(result.agent ?? {}, {
@@ -169,14 +77,6 @@ export namespace Config {
})
}
result.plugin = result.plugin || []
result.plugin.push(
...[
...(await Filesystem.globUp("plugin/*.{ts,js}", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/plugin/*.{ts,js}", Instance.directory, Instance.worktree)),
].map((x) => "file://" + x),
)
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
@@ -217,6 +117,128 @@ export namespace Config {
}
})
async function assertValid(dir: string) {
const ALLOWED_DIRS = new Set(["agent", "command", "mode", "plugin", "tool"])
const UNEXPECTED_DIR_GLOB = new Bun.Glob("*/")
for await (const item of UNEXPECTED_DIR_GLOB.scan({ absolute: true, cwd: dir, onlyFiles: false })) {
const dirname = path.basename(item)
if (!ALLOWED_DIRS.has(dirname)) {
throw new InvalidError({
path: dir,
message: `Unexpected directory "${dirname}" found in "${dir}". Only ${ALLOWED_DIRS.values().toArray().join(", ")} directories are allowed.`,
})
}
}
}
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
async function loadCommand(dir: string) {
const result: Record<string, Command> = {}
for await (const item of COMMAND_GLOB.scan({ absolute: true, cwd: dir })) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
const name = (() => {
const patterns = ["/.opencode/command/", "/command/"]
const pattern = patterns.find((p) => item.includes(p))
if (pattern) {
const index = item.indexOf(pattern)
return item.slice(index + pattern.length, -3)
}
return path.basename(item, ".md")
})()
const config = {
name,
...md.data,
template: md.content.trim(),
}
const parsed = Command.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item }, { cause: parsed.error })
}
return result
}
const AGENT_GLOB = new Bun.Glob("agent/**/*.md")
async function loadAgent(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of AGENT_GLOB.scan({ absolute: true, cwd: dir })) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
// Extract relative path from agent folder for nested agents
let agentName = path.basename(item, ".md")
const agentFolderPath = item.includes("/.opencode/agent/")
? item.split("/.opencode/agent/")[1]
: item.includes("/agent/")
? item.split("/agent/")[1]
: agentName + ".md"
// If agent is in a subfolder, include folder path in name
if (agentFolderPath.includes("/")) {
const relativePath = agentFolderPath.replace(".md", "")
const pathParts = relativePath.split("/")
agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1]
}
const config = {
name: agentName,
...md.data,
prompt: md.content.trim(),
}
const parsed = Agent.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item }, { cause: parsed.error })
}
return result
}
const MODE_GLOB = new Bun.Glob("mode/*.md")
async function loadMode(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of MODE_GLOB.scan({ absolute: true, cwd: dir })) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
const config = {
name: path.basename(item, ".md"),
...md.data,
prompt: md.content.trim(),
}
const parsed = Agent.safeParse(config)
if (parsed.success) {
result[config.name] = {
...parsed.data,
mode: "primary" as const,
}
continue
}
}
return result
}
const PLUGIN_GLOB = new Bun.Glob("plugin/*.{ts,js}")
async function loadPlugin(dir: string) {
const plugins: string[] = []
for await (const item of PLUGIN_GLOB.scan({ absolute: true, cwd: dir })) {
plugins.push("file://" + item)
}
return plugins
}
export const McpLocal = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
@@ -482,7 +504,22 @@ export namespace Config {
}),
]),
)
.optional(),
.optional()
.refine(
(data) => {
if (!data) return true
const serverIds = new Set(Object.values(LSPServer).map((s) => s.id))
return Object.entries(data).every(([id, config]) => {
if (config.disabled) return true
if (serverIds.has(id)) return true
return Boolean(config.extensions)
})
},
{
error: "For custom LSP servers, 'extensions' array is required.",
},
),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: z
@@ -670,6 +707,13 @@ export namespace Config {
return state().then((x) => x.config)
}
export async function update(config: Info) {
const filepath = path.join(Instance.directory, "config.json")
const existing = await loadFile(filepath)
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
await Instance.dispose()
}
export async function directories() {
return state().then((x) => x.directories)
}
+4 -3
View File
@@ -6,9 +6,6 @@ export namespace FileIgnore {
"**/.pnpm-store/**",
"**/vendor/**",
// vcs
"**/.git/**",
// Build outputs
"**/dist/**",
"**/build/**",
@@ -50,8 +47,12 @@ export namespace FileIgnore {
filepath: string,
opts: {
extra?: Bun.Glob[]
whitelist?: Bun.Glob[]
},
) {
for (const glob of opts.whitelist || []) {
if (glob.match(filepath)) return false
}
const extra = opts.extra || []
for (const glob of [...GLOBS, ...extra]) {
if (glob.match(filepath)) return true
+1 -1
View File
@@ -28,9 +28,9 @@ export namespace FileWatcher {
const ignore = (cfg.watcher?.ignore ?? []).map((v) => new Bun.Glob(v))
const watcher = chokidar.watch(Instance.directory, {
ignoreInitial: true,
awaitWriteFinish: true,
ignored: (filepath) => {
return FileIgnore.match(filepath, {
whitelist: [new Bun.Glob("**/.git/{index,logs/HEAD}")],
extra: ignore,
})
},
+16 -6
View File
@@ -72,7 +72,7 @@ export namespace LSP {
...existing,
id: name,
root: existing?.root ?? (async () => Instance.directory),
extensions: item.extensions ?? existing.extensions,
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => {
return {
process: spawn(item.command[0], item.command.slice(1), {
@@ -126,12 +126,22 @@ export namespace LSP {
result.push(match)
continue
}
const handle = await server.spawn(root).catch((err) => {
s.broken.add(root + server.id)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined
})
const handle = await server
.spawn(root)
.then((h) => {
if (h === undefined) {
s.broken.add(root + server.id)
}
return h
})
.catch((err) => {
s.broken.add(root + server.id)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined
})
if (!handle) continue
log.info("spawned lsp server", { serverID: server.id })
const client = await LSPClient.create({
serverID: server.id,
server: handle,
+88
View File
@@ -1,5 +1,6 @@
import { spawn, type ChildProcessWithoutNullStreams } from "child_process"
import path from "path"
import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
@@ -706,4 +707,91 @@ export namespace LSPServer {
}
},
}
export const JDTLS: Info = {
id: "jdtls",
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
extensions: [".java"],
async spawn(root) {
const java = Bun.which("java")
if (!java) {
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
return
}
const javaMajorVersion = await $`java -version`
.quiet()
.nothrow()
.then(({ stderr }) => {
const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString())
return !m ? undefined : parseInt(m[1])
})
if (javaMajorVersion == null || javaMajorVersion < 21) {
log.error("JDTLS requires at least Java 21.")
return
}
const distPath = path.join(Global.Path.bin, "jdtls")
const launcherDir = path.join(distPath, "plugins")
const installed = await fs.exists(launcherDir)
if (!installed) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("Downloading JDTLS LSP server.")
await fs.mkdir(distPath, { recursive: true })
const releaseURL =
"https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz"
const archivePath = path.join(distPath, "release.tar.gz")
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
await $`tar -xzf ${archivePath}`.cwd(distPath).quiet().nothrow()
await fs.rm(archivePath, { force: true })
}
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
.cwd(launcherDir)
.quiet()
.nothrow()
.then(({ stdout }) => stdout.toString().trim())
const launcherJar = path.join(launcherDir, jarFileName)
if (!(await fs.exists(launcherJar))) {
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
return
}
const configFile = path.join(
distPath,
(() => {
switch (process.platform) {
case "darwin":
return "config_mac"
case "linux":
return "config_linux"
case "win32":
return "config_windows"
default:
return "config_linux"
}
})(),
)
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jdtls-data"))
return {
process: spawn(
java,
[
"-jar",
launcherJar,
"-configuration",
configFile,
"-data",
dataDir,
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Dlog.level=ALL",
"--add-modules=ALL-SYSTEM",
"--add-opens java.base/java.util=ALL-UNNAMED",
"--add-opens java.base/java.lang=ALL-UNNAMED",
],
{
cwd: root,
},
),
}
},
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ export namespace Plugin {
}
const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push("opencode-copilot-auth@0.0.2")
plugins.push("opencode-copilot-auth@0.0.3")
plugins.push("opencode-anthropic-auth@0.0.2")
}
for (let plugin of plugins) {
@@ -0,0 +1,15 @@
import { Plugin } from "../plugin"
import { Share } from "../share/share"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Snapshot } from "../snapshot"
import { FileWatcher } from "../file/watcher"
export async function InstanceBootstrap() {
await Plugin.init()
Share.init()
Format.init()
LSP.init()
Snapshot.init()
FileWatcher.init()
}
+24 -4
View File
@@ -2,12 +2,32 @@ import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
const context = Context.create<{ directory: string; worktree: string; project: Project.Info }>("path")
interface Context {
directory: string
worktree: string
project: Project.Info
}
const context = Context.create<Context>("instance")
const cache = new Map<string, Context>()
export const Instance = {
async provide<R>(directory: string, cb: () => R): Promise<R> {
const project = await Project.fromDirectory(directory)
return context.provide({ directory, worktree: project.worktree, project }, cb)
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
let existing = cache.get(input.directory)
if (!existing) {
const project = await Project.fromDirectory(input.directory)
existing = {
directory: input.directory,
worktree: project.worktree,
project,
}
}
return context.provide(existing, async () => {
if (!cache.has(input.directory)) {
await input.init?.()
cache.set(input.directory, existing)
}
return input.fn()
})
},
get directory() {
return context.use().directory
+48 -57
View File
@@ -22,73 +22,64 @@ export namespace Project {
})
export type Info = z.infer<typeof Info>
const cache = new Map<string, Info>()
export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory })
const fn = async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
const git = await matches.next().then((x) => x.value)
await matches.return()
if (!git) {
const project: Info = {
id: "global",
worktree: "/",
time: {
created: Date.now(),
},
}
await Storage.write<Info>(["project", "global"], project)
return project
}
let worktree = path.dirname(git)
const [id] = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
if (!id) {
const project: Info = {
id: "global",
worktree: "/",
time: {
created: Date.now(),
},
}
await Storage.write<Info>(["project", "global"], project)
return project
}
worktree = path.dirname(
await $`git rev-parse --path-format=absolute --git-common-dir`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => x.trim()),
)
const matches = Filesystem.up({ targets: [".git"], start: directory })
const git = await matches.next().then((x) => x.value)
await matches.return()
if (!git) {
const project: Info = {
id,
worktree,
vcs: "git",
id: "global",
worktree: "/",
time: {
created: Date.now(),
},
}
await Storage.write<Info>(["project", id], project)
await Storage.write<Info>(["project", "global"], project)
return project
}
if (cache.has(directory)) {
return cache.get(directory)!
let worktree = path.dirname(git)
const [id] = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
if (!id) {
const project: Info = {
id: "global",
worktree: "/",
time: {
created: Date.now(),
},
}
await Storage.write<Info>(["project", "global"], project)
return project
}
const result = await fn()
cache.set(directory, result)
return result
worktree = path.dirname(
await $`git rev-parse --path-format=absolute --git-common-dir`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => x.trim()),
)
const project: Info = {
id,
worktree,
vcs: "git",
time: {
created: Date.now(),
},
}
await Storage.write<Info>(["project", id], project)
return project
}
export async function setInitialized(projectID: string) {
+17 -4
View File
@@ -16,10 +16,7 @@ import { Flag } from "../flag/flag"
export namespace Provider {
const log = Log.create({ service: "provider" })
type CustomLoader = (
provider: ModelsDev.Provider,
api?: string,
) => Promise<{
type CustomLoader = (provider: ModelsDev.Provider) => Promise<{
autoload: boolean
getModel?: (sdk: any, modelID: string) => Promise<any>
options?: Record<string, any>
@@ -40,6 +37,19 @@ export namespace Provider {
}
},
async opencode(input) {
const hasKey = await (async () => {
if (input.env.some((item) => process.env[item])) return true
if (await Auth.get(input.id)) return true
return false
})()
if (!hasKey) {
for (const [key, value] of Object.entries(input.models)) {
if (value.cost.input === 0) continue
delete input.models[key]
}
}
return {
autoload: Object.keys(input.models).length > 0,
options: {},
@@ -328,6 +338,9 @@ export namespace Provider {
const s = await state()
const pkg = model.provider?.npm ?? provider.npm ?? provider.id
const options = { ...s.providers[provider.id]?.options }
if (pkg.includes("@ai-sdk/openai-compatible") && options["includeUsage"] === undefined) {
options["includeUsage"] = true
}
const key = Bun.hash.xxHash32(JSON.stringify({ pkg, options }))
const existing = s.sdk.get(key)
if (existing) return existing
+4 -4
View File
@@ -92,14 +92,14 @@ export namespace ProviderTransform {
}
if (modelID.includes("gpt-5") && !modelID.includes("gpt-5-chat")) {
result["reasoningEffort"] = "high"
result["reasoningEffort"] = "medium"
if (providerID !== "azure") {
result["textVerbosity"] = "low"
result["textVerbosity"] = modelID.includes("codex") ? "medium" : "low"
}
if (providerID === "opencode") {
result["promptCacheKey"] = sessionID
// result["include"] = ["reasoning.encrypted_content"]
// result["reasoningSummary"] = "detailed"
result["include"] = ["reasoning.encrypted_content"]
result["reasoningSummary"] = "detailed"
}
}
return result
+31 -2
View File
@@ -29,6 +29,7 @@ import { SessionPrompt } from "../session/prompt"
import { SessionCompaction } from "../session/compaction"
import { SessionRevert } from "../session/revert"
import { lazy } from "../util/lazy"
import { InstanceBootstrap } from "../project/bootstrap"
const ERRORS = {
400: {
@@ -90,8 +91,12 @@ export namespace Server {
})
.use(async (c, next) => {
const directory = c.req.query("directory") ?? process.cwd()
return Instance.provide(directory, async () => {
return next()
return Instance.provide({
directory,
init: InstanceBootstrap,
async fn() {
return next()
},
})
})
.use(cors())
@@ -130,6 +135,30 @@ export namespace Server {
return c.json(await Config.get())
},
)
.patch(
"/config",
describeRoute({
description: "Update config",
operationId: "config.update",
responses: {
200: {
description: "Successfully updated config",
content: {
"application/json": {
schema: resolver(Config.Info),
},
},
},
...ERRORS,
},
}),
validator("json", Config.Info),
async (c) => {
const config = c.req.valid("json")
await Config.update(config)
return c.json(config)
},
)
.get(
"/experimental/tool/ids",
describeRoute({
@@ -115,6 +115,7 @@ export namespace MessageV2 {
end: z.number().optional(),
})
.optional(),
metadata: z.record(z.string(), z.any()).optional(),
}).meta({
ref: "TextPart",
})
@@ -138,6 +139,7 @@ export namespace MessageV2 {
callID: z.string(),
tool: z.string(),
state: ToolState,
metadata: z.record(z.string(), z.any()).optional(),
}).meta({
ref: "ToolPart",
})
@@ -519,6 +521,7 @@ export namespace MessageV2 {
{
type: "text",
text: part.text,
providerMetadata: part.metadata,
},
]
if (part.type === "step-start")
@@ -536,6 +539,7 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
callProviderMetadata: part.metadata,
},
]
if (part.state.status === "error")
@@ -546,6 +550,7 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
errorText: part.state.error,
callProviderMetadata: part.metadata,
},
]
}
+6
View File
@@ -892,6 +892,7 @@ export namespace SessionPrompt {
time: {
start: Date.now(),
},
metadata: value.providerMetadata,
}
break
@@ -913,6 +914,7 @@ export namespace SessionPrompt {
...part.time,
end: Date.now(),
}
if (value.providerMetadata) part.metadata = value.providerMetadata
await Session.updatePart(part)
delete reasoningMap[value.id]
}
@@ -952,6 +954,7 @@ export namespace SessionPrompt {
start: Date.now(),
},
},
metadata: value.providerMetadata,
})
toolcalls[value.toolCallId] = part as MessageV2.ToolPart
}
@@ -1054,12 +1057,14 @@ export namespace SessionPrompt {
time: {
start: Date.now(),
},
metadata: value.providerMetadata,
}
break
case "text-delta":
if (currentText) {
currentText.text += value.text
if (value.providerMetadata) currentText.metadata = value.providerMetadata
if (currentText.text) await Session.updatePart(currentText)
}
break
@@ -1071,6 +1076,7 @@ export namespace SessionPrompt {
start: Date.now(),
end: Date.now(),
}
if (value.providerMetadata) currentText.metadata = value.providerMetadata
await Session.updatePart(currentText)
}
currentText = undefined
+4 -1
View File
@@ -14,7 +14,10 @@ export namespace Storage {
const MIGRATIONS: Migration[] = [
async (dir) => {
const project = path.resolve(dir, "../project")
for await (const projectDir of new Bun.Glob("*").scan({ cwd: project, onlyFiles: false })) {
for await (const projectDir of new Bun.Glob("*").scan({
cwd: project,
onlyFiles: false,
})) {
log.info(`migrating project ${projectDir}`)
let projectID = projectDir
const fullProjectDir = path.join(project, projectDir)
+320 -242
View File
@@ -27,355 +27,418 @@ async function bootstrap() {
test("tracks deleted files correctly", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`rm ${tmp.dir}/a.txt`.quiet()
await $`rm ${tmp.dir}/a.txt`.quiet()
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/a.txt`)
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/a.txt`)
},
})
})
test("revert should remove new files", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/new.txt`, "NEW")
await Bun.write(`${tmp.dir}/new.txt`, "NEW")
await Snapshot.revert([await Snapshot.patch(before!)])
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(false)
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(false)
},
})
})
test("revert in subdirectory", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.dir}/sub`.quiet()
await Bun.write(`${tmp.dir}/sub/file.txt`, "SUB")
await $`mkdir -p ${tmp.dir}/sub`.quiet()
await Bun.write(`${tmp.dir}/sub/file.txt`, "SUB")
await Snapshot.revert([await Snapshot.patch(before!)])
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/sub/file.txt`).exists()).toBe(false)
// Note: revert currently only removes files, not directories
// The empty subdirectory will remain
expect(await Bun.file(`${tmp.dir}/sub/file.txt`).exists()).toBe(false)
// Note: revert currently only removes files, not directories
// The empty subdirectory will remain
},
})
})
test("multiple file operations", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/c.txt`, "C")
await $`mkdir -p ${tmp.dir}/dir`.quiet()
await Bun.write(`${tmp.dir}/dir/d.txt`, "D")
await Bun.write(`${tmp.dir}/b.txt`, "MODIFIED")
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/c.txt`, "C")
await $`mkdir -p ${tmp.dir}/dir`.quiet()
await Bun.write(`${tmp.dir}/dir/d.txt`, "D")
await Bun.write(`${tmp.dir}/b.txt`, "MODIFIED")
await Snapshot.revert([await Snapshot.patch(before!)])
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
expect(await Bun.file(`${tmp.dir}/c.txt`).exists()).toBe(false)
// Note: revert currently only removes files, not directories
// The empty directory will remain
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
expect(await Bun.file(`${tmp.dir}/c.txt`).exists()).toBe(false)
// Note: revert currently only removes files, not directories
// The empty directory will remain
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
},
})
})
test("empty directory handling", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir ${tmp.dir}/empty`.quiet()
await $`mkdir ${tmp.dir}/empty`.quiet()
expect((await Snapshot.patch(before!)).files.length).toBe(0)
expect((await Snapshot.patch(before!)).files.length).toBe(0)
},
})
})
test("binary file handling", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/image.png`, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
await Bun.write(`${tmp.dir}/image.png`, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/image.png`)
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/image.png`)
await Snapshot.revert([patch])
expect(await Bun.file(`${tmp.dir}/image.png`).exists()).toBe(false)
await Snapshot.revert([patch])
expect(await Bun.file(`${tmp.dir}/image.png`).exists()).toBe(false)
},
})
})
test("symlink handling", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`ln -s ${tmp.dir}/a.txt ${tmp.dir}/link.txt`.quiet()
await $`ln -s ${tmp.dir}/a.txt ${tmp.dir}/link.txt`.quiet()
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/link.txt`)
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/link.txt`)
},
})
})
test("large file handling", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/large.txt`, "x".repeat(1024 * 1024))
await Bun.write(`${tmp.dir}/large.txt`, "x".repeat(1024 * 1024))
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/large.txt`)
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/large.txt`)
},
})
})
test("nested directory revert", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.dir}/level1/level2/level3`.quiet()
await Bun.write(`${tmp.dir}/level1/level2/level3/deep.txt`, "DEEP")
await $`mkdir -p ${tmp.dir}/level1/level2/level3`.quiet()
await Bun.write(`${tmp.dir}/level1/level2/level3/deep.txt`, "DEEP")
await Snapshot.revert([await Snapshot.patch(before!)])
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/level1/level2/level3/deep.txt`).exists()).toBe(false)
expect(await Bun.file(`${tmp.dir}/level1/level2/level3/deep.txt`).exists()).toBe(false)
},
})
})
test("special characters in filenames", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/file with spaces.txt`, "SPACES")
await Bun.write(`${tmp.dir}/file-with-dashes.txt`, "DASHES")
await Bun.write(`${tmp.dir}/file_with_underscores.txt`, "UNDERSCORES")
await Bun.write(`${tmp.dir}/file with spaces.txt`, "SPACES")
await Bun.write(`${tmp.dir}/file-with-dashes.txt`, "DASHES")
await Bun.write(`${tmp.dir}/file_with_underscores.txt`, "UNDERSCORES")
const files = (await Snapshot.patch(before!)).files
expect(files).toContain(`${tmp.dir}/file with spaces.txt`)
expect(files).toContain(`${tmp.dir}/file-with-dashes.txt`)
expect(files).toContain(`${tmp.dir}/file_with_underscores.txt`)
const files = (await Snapshot.patch(before!)).files
expect(files).toContain(`${tmp.dir}/file with spaces.txt`)
expect(files).toContain(`${tmp.dir}/file-with-dashes.txt`)
expect(files).toContain(`${tmp.dir}/file_with_underscores.txt`)
},
})
})
test("revert with empty patches", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
// Should not crash with empty patches
expect(Snapshot.revert([])).resolves.toBeUndefined()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
// Should not crash with empty patches
expect(Snapshot.revert([])).resolves.toBeUndefined()
// Should not crash with patches that have empty file lists
expect(Snapshot.revert([{ hash: "dummy", files: [] }])).resolves.toBeUndefined()
// Should not crash with patches that have empty file lists
expect(Snapshot.revert([{ hash: "dummy", files: [] }])).resolves.toBeUndefined()
},
})
})
test("patch with invalid hash", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Create a change
await Bun.write(`${tmp.dir}/test.txt`, "TEST")
// Create a change
await Bun.write(`${tmp.dir}/test.txt`, "TEST")
// Try to patch with invalid hash - should handle gracefully
const patch = await Snapshot.patch("invalid-hash-12345")
expect(patch.files).toEqual([])
expect(patch.hash).toBe("invalid-hash-12345")
// Try to patch with invalid hash - should handle gracefully
const patch = await Snapshot.patch("invalid-hash-12345")
expect(patch.files).toEqual([])
expect(patch.hash).toBe("invalid-hash-12345")
},
})
})
test("revert non-existent file", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Try to revert a file that doesn't exist in the snapshot
// This should not crash
expect(
Snapshot.revert([
{
hash: before!,
files: [`${tmp.dir}/nonexistent.txt`],
},
]),
).resolves.toBeUndefined()
// Try to revert a file that doesn't exist in the snapshot
// This should not crash
expect(
Snapshot.revert([
{
hash: before!,
files: [`${tmp.dir}/nonexistent.txt`],
},
]),
).resolves.toBeUndefined()
},
})
})
test("unicode filenames", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
const unicodeFiles = [
`${tmp.dir}/文件.txt`,
`${tmp.dir}/🚀rocket.txt`,
`${tmp.dir}/café.txt`,
`${tmp.dir}/файл.txt`,
]
const unicodeFiles = [
`${tmp.dir}/文件.txt`,
`${tmp.dir}/🚀rocket.txt`,
`${tmp.dir}/café.txt`,
`${tmp.dir}/файл.txt`,
]
for (const file of unicodeFiles) {
await Bun.write(file, "unicode content")
}
for (const file of unicodeFiles) {
await Bun.write(file, "unicode content")
}
const patch = await Snapshot.patch(before!)
// Note: git escapes unicode characters by default, so we just check that files are detected
// The actual filenames will be escaped like "caf\303\251.txt" but functionality works
expect(patch.files.length).toBe(4)
const patch = await Snapshot.patch(before!)
// Note: git escapes unicode characters by default, so we just check that files are detected
// The actual filenames will be escaped like "caf\303\251.txt" but functionality works
expect(patch.files.length).toBe(4)
// Skip revert test due to git filename escaping issues
// The functionality works but git uses escaped filenames internally
// Skip revert test due to git filename escaping issues
// The functionality works but git uses escaped filenames internally
},
})
})
test("very long filenames", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
const longName = "a".repeat(200) + ".txt"
const longFile = `${tmp.dir}/${longName}`
const longName = "a".repeat(200) + ".txt"
const longFile = `${tmp.dir}/${longName}`
await Bun.write(longFile, "long filename content")
await Bun.write(longFile, "long filename content")
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(longFile)
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(longFile)
await Snapshot.revert([patch])
expect(await Bun.file(longFile).exists()).toBe(false)
await Snapshot.revert([patch])
expect(await Bun.file(longFile).exists()).toBe(false)
},
})
})
test("hidden files", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/.hidden`, "hidden content")
await Bun.write(`${tmp.dir}/.gitignore`, "*.log")
await Bun.write(`${tmp.dir}/.config`, "config content")
await Bun.write(`${tmp.dir}/.hidden`, "hidden content")
await Bun.write(`${tmp.dir}/.gitignore`, "*.log")
await Bun.write(`${tmp.dir}/.config`, "config content")
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/.hidden`)
expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
expect(patch.files).toContain(`${tmp.dir}/.config`)
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/.hidden`)
expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
expect(patch.files).toContain(`${tmp.dir}/.config`)
},
})
})
test("nested symlinks", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.dir}/sub/dir`.quiet()
await Bun.write(`${tmp.dir}/sub/dir/target.txt`, "target content")
await $`ln -s ${tmp.dir}/sub/dir/target.txt ${tmp.dir}/sub/dir/link.txt`.quiet()
await $`ln -s ${tmp.dir}/sub ${tmp.dir}/sub-link`.quiet()
await $`mkdir -p ${tmp.dir}/sub/dir`.quiet()
await Bun.write(`${tmp.dir}/sub/dir/target.txt`, "target content")
await $`ln -s ${tmp.dir}/sub/dir/target.txt ${tmp.dir}/sub/dir/link.txt`.quiet()
await $`ln -s ${tmp.dir}/sub ${tmp.dir}/sub-link`.quiet()
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/sub/dir/link.txt`)
expect(patch.files).toContain(`${tmp.dir}/sub-link`)
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/sub/dir/link.txt`)
expect(patch.files).toContain(`${tmp.dir}/sub-link`)
},
})
})
test("file permissions and ownership changes", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Change permissions multiple times
await $`chmod 600 ${tmp.dir}/a.txt`.quiet()
await $`chmod 755 ${tmp.dir}/a.txt`.quiet()
await $`chmod 644 ${tmp.dir}/a.txt`.quiet()
// Change permissions multiple times
await $`chmod 600 ${tmp.dir}/a.txt`.quiet()
await $`chmod 755 ${tmp.dir}/a.txt`.quiet()
await $`chmod 644 ${tmp.dir}/a.txt`.quiet()
const patch = await Snapshot.patch(before!)
// Note: git doesn't track permission changes on existing files by default
// Only tracks executable bit when files are first added
expect(patch.files.length).toBe(0)
const patch = await Snapshot.patch(before!)
// Note: git doesn't track permission changes on existing files by default
// Only tracks executable bit when files are first added
expect(patch.files.length).toBe(0)
},
})
})
test("circular symlinks", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Create circular symlink
await $`ln -s ${tmp.dir}/circular ${tmp.dir}/circular`.quiet().nothrow()
// Create circular symlink
await $`ln -s ${tmp.dir}/circular ${tmp.dir}/circular`.quiet().nothrow()
const patch = await Snapshot.patch(before!)
expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
const patch = await Snapshot.patch(before!)
expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
},
})
})
test("gitignore changes", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/.gitignore`, "*.ignored")
await Bun.write(`${tmp.dir}/test.ignored`, "ignored content")
await Bun.write(`${tmp.dir}/normal.txt`, "normal content")
await Bun.write(`${tmp.dir}/.gitignore`, "*.ignored")
await Bun.write(`${tmp.dir}/test.ignored`, "ignored content")
await Bun.write(`${tmp.dir}/normal.txt`, "normal content")
const patch = await Snapshot.patch(before!)
const patch = await Snapshot.patch(before!)
// Should track gitignore itself
expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
// Should track normal files
expect(patch.files).toContain(`${tmp.dir}/normal.txt`)
// Should not track ignored files (git won't see them)
expect(patch.files).not.toContain(`${tmp.dir}/test.ignored`)
// Should track gitignore itself
expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
// Should track normal files
expect(patch.files).toContain(`${tmp.dir}/normal.txt`)
// Should not track ignored files (git won't see them)
expect(patch.files).not.toContain(`${tmp.dir}/test.ignored`)
},
})
})
test("concurrent file operations during patch", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Start creating files
const createPromise = (async () => {
for (let i = 0; i < 10; i++) {
await Bun.write(`${tmp.dir}/concurrent${i}.txt`, `concurrent${i}`)
// Small delay to simulate concurrent operations
await new Promise((resolve) => setTimeout(resolve, 1))
}
})()
// Start creating files
const createPromise = (async () => {
for (let i = 0; i < 10; i++) {
await Bun.write(`${tmp.dir}/concurrent${i}.txt`, `concurrent${i}`)
// Small delay to simulate concurrent operations
await new Promise((resolve) => setTimeout(resolve, 1))
}
})()
// Get patch while files are being created
const patchPromise = Snapshot.patch(before!)
// Get patch while files are being created
const patchPromise = Snapshot.patch(before!)
await createPromise
const patch = await patchPromise
await createPromise
const patch = await patchPromise
// Should capture some or all of the concurrent files
expect(patch.files.length).toBeGreaterThanOrEqual(0)
// Should capture some or all of the concurrent files
expect(patch.files.length).toBeGreaterThanOrEqual(0)
},
})
})
@@ -384,75 +447,90 @@ test("snapshot state isolation between projects", async () => {
await using tmp1 = await bootstrap()
await using tmp2 = await bootstrap()
await Instance.provide(tmp1.dir, async () => {
const before1 = await Snapshot.track()
await Bun.write(`${tmp1.dir}/project1.txt`, "project1 content")
const patch1 = await Snapshot.patch(before1!)
expect(patch1.files).toContain(`${tmp1.dir}/project1.txt`)
await Instance.provide({
directory: tmp1.dir,
fn: async () => {
const before1 = await Snapshot.track()
await Bun.write(`${tmp1.dir}/project1.txt`, "project1 content")
const patch1 = await Snapshot.patch(before1!)
expect(patch1.files).toContain(`${tmp1.dir}/project1.txt`)
},
})
await Instance.provide(tmp2.dir, async () => {
const before2 = await Snapshot.track()
await Bun.write(`${tmp2.dir}/project2.txt`, "project2 content")
const patch2 = await Snapshot.patch(before2!)
expect(patch2.files).toContain(`${tmp2.dir}/project2.txt`)
await Instance.provide({
directory: tmp2.dir,
fn: async () => {
const before2 = await Snapshot.track()
await Bun.write(`${tmp2.dir}/project2.txt`, "project2 content")
const patch2 = await Snapshot.patch(before2!)
expect(patch2.files).toContain(`${tmp2.dir}/project2.txt`)
// Ensure project1 files don't appear in project2
expect(patch2.files).not.toContain(`${tmp1?.dir}/project1.txt`)
// Ensure project1 files don't appear in project2
expect(patch2.files).not.toContain(`${tmp1?.dir}/project1.txt`)
},
})
})
test("track with no changes returns same hash", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const hash1 = await Snapshot.track()
expect(hash1).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const hash1 = await Snapshot.track()
expect(hash1).toBeTruthy()
// Track again with no changes
const hash2 = await Snapshot.track()
expect(hash2).toBe(hash1!)
// Track again with no changes
const hash2 = await Snapshot.track()
expect(hash2).toBe(hash1!)
// Track again
const hash3 = await Snapshot.track()
expect(hash3).toBe(hash1!)
// Track again
const hash3 = await Snapshot.track()
expect(hash3).toBe(hash1!)
},
})
})
test("diff function with various changes", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Make various changes
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/new.txt`, "new content")
await Bun.write(`${tmp.dir}/b.txt`, "modified content")
// Make various changes
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/new.txt`, "new content")
await Bun.write(`${tmp.dir}/b.txt`, "modified content")
const diff = await Snapshot.diff(before!)
expect(diff).toContain("deleted")
expect(diff).toContain("modified")
// Note: git diff only shows changes to tracked files, not untracked files like new.txt
const diff = await Snapshot.diff(before!)
expect(diff).toContain("deleted")
expect(diff).toContain("modified")
// Note: git diff only shows changes to tracked files, not untracked files like new.txt
},
})
})
test("restore function", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Instance.provide({
directory: tmp.dir,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Make changes
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/new.txt`, "new content")
await Bun.write(`${tmp.dir}/b.txt`, "modified")
// Make changes
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/new.txt`, "new content")
await Bun.write(`${tmp.dir}/b.txt`, "modified")
// Restore to original state
await Snapshot.restore(before!)
// Restore to original state
await Snapshot.restore(before!)
expect(await Bun.file(`${tmp.dir}/a.txt`).exists()).toBe(true)
expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(true) // New files should remain
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
expect(await Bun.file(`${tmp.dir}/a.txt`).exists()).toBe(true)
expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(true) // New files should remain
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
},
})
})
+26 -20
View File
@@ -19,30 +19,36 @@ Log.init({ print: false })
describe("tool.bash", () => {
test("basic", async () => {
await Instance.provide(projectRoot, async () => {
const result = await bash.execute(
{
command: "echo 'test'",
description: "Echo test message",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain("test")
await Instance.provide({
directory: projectRoot,
fn: async () => {
const result = await bash.execute(
{
command: "echo 'test'",
description: "Echo test message",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain("test")
},
})
})
test("cd ../ should fail outside of project root", async () => {
await Instance.provide(projectRoot, async () => {
expect(
bash.execute(
{
command: "cd ../",
description: "Try to cd to parent directory",
},
ctx,
),
).rejects.toThrow("This command references paths outside of")
await Instance.provide({
directory: projectRoot,
fn: async () => {
expect(
bash.execute(
{
command: "cd ../",
description: "Try to cd to parent directory",
},
ctx,
),
).rejects.toThrow("This command references paths outside of")
},
})
})
})
+32 -23
View File
@@ -20,38 +20,47 @@ const fixturePath = path.join(__dirname, "../fixtures/example")
describe("tool.glob", () => {
test("truncate", async () => {
await Instance.provide(projectRoot, async () => {
let result = await glob.execute(
{
pattern: "**/*",
path: "../../node_modules",
},
ctx,
)
expect(result.metadata.truncated).toBe(true)
await Instance.provide({
directory: projectRoot,
fn: async () => {
let result = await glob.execute(
{
pattern: "**/*",
path: "../../node_modules",
},
ctx,
)
expect(result.metadata.truncated).toBe(true)
},
})
})
test("basic", async () => {
await Instance.provide(projectRoot, async () => {
let result = await glob.execute(
{
pattern: "*.json",
path: undefined,
},
ctx,
)
expect(result.metadata).toMatchObject({
truncated: false,
count: 2,
})
await Instance.provide({
directory: projectRoot,
fn: async () => {
let result = await glob.execute(
{
pattern: "*.json",
path: undefined,
},
ctx,
)
expect(result.metadata).toMatchObject({
truncated: false,
count: 2,
})
},
})
})
})
describe("tool.ls", () => {
test("basic", async () => {
const result = await Instance.provide(projectRoot, async () => {
return await list.execute({ path: fixturePath, ignore: [".git"] }, ctx)
const result = await Instance.provide({
directory: projectRoot,
fn: async () => {
return await list.execute({ path: fixturePath, ignore: [".git"] }, ctx)
},
})
// Normalize absolute path to relative for consistent snapshots
+3 -2
View File
@@ -1,10 +1,11 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.10.1",
"version": "0.11.6",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"build": "tsc"
},
"exports": {
".": {
+3 -2
View File
@@ -1,10 +1,11 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.10.1",
"version": "0.11.6",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"build": "tsc"
},
"exports": {
".": {
+17
View File
@@ -8,6 +8,9 @@ import type {
ProjectCurrentResponses,
ConfigGetData,
ConfigGetResponses,
ConfigUpdateData,
ConfigUpdateResponses,
ConfigUpdateErrors,
ToolIdsData,
ToolIdsResponses,
ToolIdsErrors,
@@ -161,6 +164,20 @@ class Config extends _HeyApiClient {
})
}
/**
* Update config
*/
public update<ThrowOnError extends boolean = false>(options?: Options<ConfigUpdateData, ThrowOnError>) {
return (options?.client ?? this._client).patch<ConfigUpdateResponses, ConfigUpdateErrors, ThrowOnError>({
url: "/config",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
})
}
/**
* List all providers
*/

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