Compare commits

..

1 Commits

Author SHA1 Message Date
opencode dbb7312cf7 release: v1.0.102 2025-11-22 18:14:47 +00:00
142 changed files with 1624 additions and 2682 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Check for duplicate issues
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
-2
View File
@@ -28,7 +28,6 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
- name: Setup Nix
uses: DeterminateSystems/nix-installer-action@v20
@@ -80,7 +79,6 @@ jobs:
git commit -m "Update Nix flake.lock and hashes"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git pull --rebase origin "$BRANCH"
git push origin HEAD:"$BRANCH"
summarize "committed $(git rev-parse --short HEAD)"
+3 -3
View File
@@ -1,9 +1,9 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
"enterprise": {
"url": "http://localhost:3000",
},
// "enterprise": {
// "url": "http://localhost:3000",
// },
"provider": {
"opencode": {
"options": {
-2
View File
@@ -148,5 +148,3 @@
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
+430 -102
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1763934636,
"narHash": "sha256-9glbI7f1uU+yzQCq5LwLgdZqx6svOhZWkd4JRY265fc=",
"lastModified": 1763618868,
"narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ee09932cedcef15aaf476f9343d1dea2cb77e261",
"rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"type": "github"
},
"original": {
-3
View File
@@ -116,8 +116,6 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
// CONSOLE
////////////////
const bucket = new sst.cloudflare.Bucket("ConsoleData")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
@@ -134,7 +132,6 @@ new sst.cloudflare.x.SolidStart("Console", {
domain,
path: "packages/console/app",
link: [
bucket,
database,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
-17
View File
@@ -1,17 +0,0 @@
import { SECRET } from "./secret"
import { domain } from "./stage"
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
const enterprise = new sst.cloudflare.x.SolidStart("Enterprise", {
domain: "enterprise." + domain,
path: "packages/enterprise",
buildCommand: "bun run build:cloudflare",
environment: {
OPENCODE_STORAGE_ADAPTER: "r2",
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
OPENCODE_STORAGE_ACCESS_KEY_ID: SECRET.R2AccessKey.value,
OPENCODE_STORAGE_SECRET_ACCESS_KEY: SECRET.R2SecretKey.value,
OPENCODE_STORAGE_BUCKET: storage.name,
},
})
-4
View File
@@ -1,4 +0,0 @@
export const SECRET = {
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
}
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bun
import solidPlugin from "./node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
import fs from "fs"
const dir = process.cwd()
const parser = fs.realpathSync(path.join(dir, "node_modules/@opentui/core/parser.worker.js"))
const worker = "./src/cli/cmd/tui/worker.ts"
const version = process.env.OPENCODE_VERSION ?? "local"
const channel = process.env.OPENCODE_CHANNEL ?? "local"
fs.rmSync(path.join(dir, "dist"), { recursive: true, force: true })
const result = await Bun.build({
entrypoints: ["./src/index.ts", worker, parser],
outdir: "./dist",
target: "bun",
sourcemap: "none",
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
external: ["@opentui/core"],
define: {
OPENCODE_VERSION: `'${version}'`,
OPENCODE_CHANNEL: `'${channel}'`,
// Leave undefined so runtime picks bundled/dist worker or fallback in code.
OPENCODE_WORKER_PATH: "undefined",
OTUI_TREE_SITTER_WORKER_PATH: 'new URL("./cli/cmd/tui/parser.worker.js", import.meta.url).href',
},
})
if (!result.success) {
console.error("bundle failed")
for (const log of result.logs) console.error(log)
process.exit(1)
}
const parserOut = path.join(dir, "dist/src/cli/cmd/tui/parser.worker.js")
fs.mkdirSync(path.dirname(parserOut), { recursive: true })
await Bun.write(parserOut, Bun.file(parser))
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-EikMl2CESyJpTT48CGq8xr/N+WTkvCj05N6enXQmRbI="
"nodeModules": "sha256-N33FQyKF6IgGIRZ8NFd9o1/sjHMwbQ6KQcnMFyN0WmI="
}
+35 -60
View File
@@ -1,4 +1,4 @@
{ lib, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
{ lib, stdenv, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
args:
let
scripts = args.scripts;
@@ -28,89 +28,64 @@ stdenvNoCC.mkDerivation (finalAttrs: {
makeBinaryWrapper
];
configurePhase = ''
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
runHook postConfigure
'';
env.MODELS_DEV_API_JSON = args.modelsDev;
env.OPENCODE_VERSION = args.version;
env.OPENCODE_CHANNEL = "stable";
dontConfigure = true;
buildPhase = ''
runHook preBuild
cp -r ${finalAttrs.node_modules}/node_modules .
cp -r ${finalAttrs.node_modules}/packages .
cp ${scripts + "/bun-build.ts"} bun-build.ts
(
cd packages/opencode
substituteInPlace bun-build.ts \
--replace '@VERSION@' "${finalAttrs.version}"
chmod -R u+w ./node_modules
mkdir -p ./node_modules/@opencode-ai
rm -f ./node_modules/@opencode-ai/{script,sdk,plugin}
ln -s $(pwd)/../../packages/script ./node_modules/@opencode-ai/script
ln -s $(pwd)/../../packages/sdk/js ./node_modules/@opencode-ai/sdk
ln -s $(pwd)/../../packages/plugin ./node_modules/@opencode-ai/plugin
cp ${./bundle.ts} ./bundle.ts
chmod +x ./bundle.ts
bun run ./bundle.ts
)
export BUN_COMPILE_TARGET=${args.target}
bun --bun bun-build.ts
runHook postBuild
'';
dontStrip = true;
installPhase = ''
runHook preInstall
cd packages/opencode
if [ ! -d dist ]; then
echo "ERROR: dist directory missing after bundle step"
if [ ! -f opencode ]; then
echo "ERROR: opencode binary not found in $(pwd)"
ls -la
exit 1
fi
if [ ! -f opencode-worker.js ]; then
echo "ERROR: opencode worker bundle not found in $(pwd)"
ls -la
exit 1
fi
mkdir -p $out/lib/opencode
cp -r dist $out/lib/opencode/
chmod -R u+w $out/lib/opencode/dist
# Select bundled worker assets deterministically (sorted find output)
worker_file=$(find "$out/lib/opencode/dist" -type f \( -path '*/tui/worker.*' -o -name 'worker.*' \) | sort | head -n1)
parser_worker_file=$(find "$out/lib/opencode/dist" -type f -name 'parser.worker.*' | sort | head -n1)
if [ -z "$worker_file" ]; then
echo "ERROR: bundled worker not found"
exit 1
install -Dm755 opencode $out/bin/opencode
install -Dm644 opencode-worker.js $out/bin/opencode-worker.js
if [ -f opencode-assets.manifest ]; then
while IFS= read -r asset; do
[ -z "$asset" ] && continue
if [ ! -f "$asset" ]; then
echo "ERROR: referenced asset \"$asset\" missing"
exit 1
fi
install -Dm644 "$asset" "$out/bin/$(basename "$asset")"
done < opencode-assets.manifest
fi
main_wasm=$(printf '%s\n' "$out"/lib/opencode/dist/tree-sitter-*.wasm | sort | head -n1)
wasm_list=$(find "$out/lib/opencode/dist" -maxdepth 1 -name 'tree-sitter-*.wasm' -print)
for patch_file in "$worker_file" "$parser_worker_file"; do
[ -z "$patch_file" ] && continue
[ ! -f "$patch_file" ] && continue
if [ -n "$wasm_list" ] && grep -q 'tree-sitter' "$patch_file"; then
# Rewrite wasm references to absolute store paths to avoid runtime resolve failures.
bun --bun ${scripts + "/patch-wasm.ts"} "$patch_file" "$main_wasm" $wasm_list
fi
done
mkdir -p $out/lib/opencode/node_modules
cp -r ../../node_modules/.bun $out/lib/opencode/node_modules/
mkdir -p $out/lib/opencode/node_modules/@opentui
mkdir -p $out/bin
makeWrapper ${bun}/bin/bun $out/bin/opencode \
--add-flags "run" \
--add-flags "$out/lib/opencode/dist/src/index.js" \
--prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]} \
--argv0 opencode
runHook postInstall
'';
postInstall = ''
for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do
if [ -d "$pkg" ]; then
pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/')
ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \
$out/lib/opencode/node_modules/@opentui/$pkgName
fi
done
postFixup = ''
wrapProgram "$out/bin/opencode" --prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]}
'';
meta = {
+7 -24
View File
@@ -24,13 +24,15 @@ for (const entry of directories) {
if (!info.isDirectory()) {
continue
}
const parsed = parseEntry(entry)
if (!parsed) {
const marker = entry.lastIndexOf("@")
if (marker <= 0) {
continue
}
const list = versions.get(parsed.name) ?? []
list.push({ dir: full, version: parsed.version, label: entry })
versions.set(parsed.name, list)
const slug = entry.slice(0, marker).replace(/\+/g, "/")
const version = entry.slice(marker + 1)
const list = versions.get(slug) ?? []
list.push({ dir: full, version, label: entry })
versions.set(slug, list)
}
const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
@@ -77,12 +79,6 @@ for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0]
await mkdir(parent, { recursive: true })
const linkPath = join(parent, leaf)
const desired = join(entry.dir, "node_modules", slug)
const exists = await lstat(desired)
.then((info) => info.isDirectory())
.catch(() => false)
if (!exists) {
continue
}
const relativeTarget = relative(parent, desired)
const resolved = relativeTarget.length === 0 ? "." : relativeTarget
await rm(linkPath, { recursive: true, force: true })
@@ -98,16 +94,3 @@ for (const line of rewrites.slice(0, 20)) {
if (rewrites.length > 20) {
console.log(" ...")
}
function parseEntry(label: string) {
const marker = label.startsWith("@") ? label.indexOf("@", 1) : label.indexOf("@")
if (marker <= 0) {
return null
}
const name = label.slice(0, marker).replace(/\+/g, "/")
const version = label.slice(marker + 1)
if (!name || !version) {
return null
}
return { name, version }
}
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
/**
* Rewrite tree-sitter wasm references inside a JS file to absolute paths.
* argv: [node, script, file, mainWasm, ...wasmPaths]
*/
const [, , file, mainWasm, ...wasmPaths] = process.argv
if (!file || !mainWasm) {
console.error("usage: patch-wasm <file> <mainWasm> [wasmPaths...]")
process.exit(1)
}
const content = fs.readFileSync(file, "utf8")
const byName = new Map<string, string>()
for (const wasm of wasmPaths) {
const name = path.basename(wasm)
byName.set(name, wasm)
}
let next = content
for (const [name, wasmPath] of byName) {
next = next.replaceAll(name, wasmPath)
}
next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
next = next.replace(/(\.\/)+/g, "./")
next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2")
next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3")
next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3")
if (next !== content) fs.writeFileSync(file, next)
+4 -1
View File
@@ -48,7 +48,7 @@
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dbff19d",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10"
}
@@ -82,6 +82,9 @@
"tree-sitter-bash",
"web-tree-sitter"
],
"patchedDependencies": {
"@solidjs/start@1.1.7": "patches/@solidjs%2Fstart@1.1.7.patch"
},
"overrides": {
"@types/bun": "catalog:",
"@types/node": "catalog:"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.110",
"version": "1.0.102",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
-1
View File
@@ -1 +0,0 @@
../../../ui/src/assets/favicon/favicon.svg

Before

Width:  |  Height:  |  Size: 42 B

After

Width:  |  Height:  |  Size: 269 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="400" fill="#0E0E0E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M312 340H88V60H312V340ZM256 116H144V284H256V116Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 42 B

After

Width:  |  Height:  |  Size: 269 B

+1 -1
View File
@@ -202,7 +202,7 @@ export function IconZai(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
export function IconGemini(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
export function IconGoogle(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 50 50" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M49.04,24.001l-1.082-0.043h-0.001C36.134,23.492,26.508,13.866,26.042,2.043L25.999,0.96C25.978,0.424,25.537,0,25,0 s-0.978,0.424-0.999,0.96l-0.043,1.083C23.492,13.866,13.866,23.492,2.042,23.958L0.96,24.001C0.424,24.022,0,24.463,0,25 c0,0.537,0.424,0.978,0.961,0.999l1.082,0.042c11.823,0.467,21.449,10.093,21.915,21.916l0.043,1.083C24.022,49.576,24.463,50,25,50 s0.978-0.424,0.999-0.96l0.043-1.083c0.466-11.823,10.092-21.449,21.915-21.916l1.082-0.042C49.576,25.978,50,25.537,50,25 C50,24.463,49.576,24.022,49.04,24.001z"></path>
+1 -1
View File
@@ -9,7 +9,7 @@ export default createHandler(
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.svg" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
{assets}
@@ -19,7 +19,6 @@ export async function GET(input: APIEvent) {
return {
...value,
account: {
...value.account,
[id]: {
id,
email: decoded.subject.properties.email,
@@ -1,17 +0,0 @@
import { redirect } from "@solidjs/router"
import { APIEvent } from "@solidjs/start"
import { useAuthSession } from "~/context/auth.session"
export async function GET(event: APIEvent) {
const auth = await useAuthSession()
const current = auth.data.current
if (current)
await auth.update((val) => {
delete val.account?.[current]
const first = Object.keys(val.account ?? {})[0]
val.current = first
event!.locals.actor = undefined
return val
})
return redirect("/zen")
}
@@ -1,7 +0,0 @@
import { APIEvent } from "@solidjs/start"
import { useAuthSession } from "~/context/auth.session"
export async function GET(input: APIEvent) {
const session = await useAuthSession()
return Response.json(session.data)
}
@@ -45,6 +45,7 @@ export default function Home() {
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
<Title>OpenCode | The AI coding agent built for the terminal</Title>
<Link rel="canonical" href={config.baseUrl} />
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<Meta property="og:image" content="/social-share.png" />
<Meta name="twitter:image" content="/social-share.png" />
<div data-component="container">
@@ -12,7 +12,6 @@
[data-slot="item"] {
color: var(--color-danger);
text-decoration: none;
}
}
}
@@ -1,4 +1,4 @@
import { action } from "@solidjs/router"
import { action, redirect } from "@solidjs/router"
import { getRequestEvent } from "solid-js/web"
import { useAuthSession } from "~/context/auth.session"
import { Dropdown } from "~/component/dropdown"
@@ -17,15 +17,18 @@ const logout = action(async () => {
event!.locals.actor = undefined
return val
})
}, "auth.logout")
throw redirect("/zen")
})
export function UserMenu(props: { email: string | null | undefined }) {
return (
<div data-component="user-menu">
<Dropdown trigger={props.email ?? ""} align="right">
<a href="/auth/logout" data-slot="item">
Logout
</a>
<form action={logout} method="post">
<button type="submit" formaction={logout} data-slot="item">
Logout
</button>
</form>
</Dropdown>
</div>
)
@@ -22,7 +22,7 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
const userEmail = createAsync(() => getUserEmail(params.id!))
return (
<main data-page="workspace">
<Link rel="shortcut icon" type="image/svg+xml" href="/favicon-zen.svg" />
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
<header data-component="workspace-header">
<div data-slot="header-brand">
<A href="/" data-component="site-title">
@@ -8,7 +8,7 @@ import { querySessionInfo } from "../common"
import {
IconAlibaba,
IconAnthropic,
IconGemini,
IconGoogle,
IconMoonshotAI,
IconOpenAI,
IconStealth,
@@ -117,7 +117,7 @@ export function ModelSection() {
case "Anthropic":
return <IconAnthropic width={16} height={16} />
case "Google":
return <IconGemini width={16} height={16} />
return <IconGoogle width={16} height={16} />
case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} />
case "Z.ai":
@@ -8,7 +8,6 @@ import styles from "./provider-section.module.css"
const PROVIDERS = [
{ name: "OpenAI", key: "openai", prefix: "sk-" },
{ name: "Anthropic", key: "anthropic", prefix: "sk-ant-" },
{ name: "Google Gemini", key: "google", prefix: "AI" },
] as const
type Provider = (typeof PROVIDERS)[number]
@@ -50,10 +50,6 @@ export function UsageSection() {
return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
}
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.outputTokens + (u.reasoningTokens ?? 0)
}
const goPrev = async () => {
const usage = await getUsageInfo(params.id!, store.page - 1)
setStore({
@@ -99,11 +95,8 @@ export function UsageSection() {
{(usage, index) => {
const date = createMemo(() => new Date(usage.timeCreated))
const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
const totalOutputTokens = createMemo(() => calculateTotalOutputTokens(usage))
const inputBreakdownId = `input-breakdown-${index()}`
const outputBreakdownId = `output-breakdown-${index()}`
const isInputOpen = createMemo(() => openBreakdownId() === inputBreakdownId)
const isOutputOpen = createMemo(() => openBreakdownId() === outputBreakdownId)
const breakdownId = `breakdown-${index()}`
const isOpen = createMemo(() => openBreakdownId() === breakdownId)
const isClaude = usage.model.toLowerCase().includes("claude")
return (
<tr>
@@ -117,13 +110,13 @@ export function UsageSection() {
data-slot="breakdown-button"
onClick={(e) => {
e.stopPropagation()
setOpenBreakdownId(isInputOpen() ? null : inputBreakdownId)
setOpenBreakdownId(isOpen() ? null : breakdownId)
}}
>
<IconBreakdown />
</button>
<span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
<Show when={isInputOpen()}>
<Show when={isOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Input</span>
@@ -143,32 +136,7 @@ export function UsageSection() {
</Show>
</div>
</td>
<td data-slot="usage-tokens">
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
<button
data-slot="breakdown-button"
onClick={(e) => {
e.stopPropagation()
setOpenBreakdownId(isOutputOpen() ? null : outputBreakdownId)
}}
>
<IconBreakdown />
</button>
<span onClick={() => setOpenBreakdownId(null)}>{totalOutputTokens()}</span>
<Show when={isOutputOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Output</span>
<span data-slot="breakdown-value">{usage.outputTokens}</span>
</div>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Reasoning</span>
<span data-slot="breakdown-value">{usage.reasoningTokens ?? 0}</span>
</div>
</div>
</Show>
</div>
</td>
<td data-slot="usage-tokens">{usage.outputTokens}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr>
)
@@ -18,25 +18,23 @@ import { Legal } from "~/component/legal"
import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { getLastSeenWorkspaceID } from "../workspace/common"
import { IconGemini } from "~/component/icon"
const checkLoggedIn = query(async () => {
"use server"
const workspaceID = await getLastSeenWorkspaceID().catch(() => {})
const workspaceID = await getLastSeenWorkspaceID()
if (workspaceID) throw redirect(`/workspace/${workspaceID}`)
}, "checkLoggedIn.get")
export default function Home() {
const loggedin = createAsync(() => checkLoggedIn())
createAsync(() => checkLoggedIn())
return (
<main data-page="zen">
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
<Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title>
<Link rel="canonical" href={`${config.baseUrl}/zen`} />
<Link rel="shortcut icon" type="image/svg+xml" href="/favicon-zen.svg" />
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
<Meta property="og:image" content="/social-share-zen.png" />
<Meta name="twitter:image" content="/social-share-zen.png" />
<Meta name="opencode:auth" content={loggedin() ? "true" : "false"} />
<div data-component="container">
<Header zen />
@@ -83,9 +81,6 @@ export default function Home() {
/>
</svg>
</div>
<div>
<IconGemini width="24" height="24" />
</div>
<div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@@ -1,26 +0,0 @@
import { Resource, waitUntil } from "@opencode-ai/console-resource"
export function createDataDumper(sessionId: string, requestId: string) {
if (Resource.App.stage !== "production") return
let data: Record<string, any> = {}
let modelName: string | undefined
return {
provideModel: (model?: string) => (modelName = model),
provideRequest: (request: string) => (data.request = request),
provideResponse: (response: string) => (data.response = response),
provideStream: (chunk: string) => (data.response = (data.response ?? "") + chunk),
flush: () => {
if (!modelName) return
const str = new Date().toISOString().replace(/[^0-9]/g, "")
const yyyymmdd = str.substring(0, 8)
const hh = str.substring(8, 10)
waitUntil(
Resource.ConsoleData.put(`${yyyymmdd}/${hh}/${modelName}/${sessionId}/${requestId}.json`, JSON.stringify(data)),
)
},
}
}
@@ -19,7 +19,6 @@ import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter } from "./rateLimiter"
import { createDataDumper } from "./dataDumper"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@@ -49,19 +48,16 @@ export async function handler(
try {
const url = input.request.url
const body = await input.request.json()
const ip = input.request.headers.get("x-real-ip") ?? ""
const model = opts.parseModel(url, body)
const isStream = opts.parseIsStream(url, body)
const ip = input.request.headers.get("x-real-ip") ?? ""
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
const requestId = input.request.headers.get("x-opencode-request") ?? ""
logger.metric({
is_tream: isStream,
session: sessionId,
request: requestId,
session: input.request.headers.get("x-opencode-session"),
request: input.request.headers.get("x-opencode-request"),
})
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId)
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
await rateLimiter?.check()
@@ -108,14 +104,10 @@ export async function handler(
})
}
return { providerInfo, authInfo, reqBody, res, startTimestamp }
return { providerInfo, authInfo, res, startTimestamp }
}
const { providerInfo, authInfo, reqBody, res, startTimestamp } = await retriableRequest()
// Store model request
dataDumper?.provideModel(providerInfo.storeModel)
dataDumper?.provideRequest(reqBody)
const { providerInfo, authInfo, res, startTimestamp } = await retriableRequest()
// Scrub response headers
const resHeaders = new Headers()
@@ -134,8 +126,6 @@ export async function handler(
const body = JSON.stringify(responseConverter(json))
logger.metric({ response_length: body.length })
logger.debug("RESPONSE: " + body)
dataDumper?.provideResponse(body)
dataDumper?.flush()
await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
await reload(authInfo)
@@ -165,7 +155,6 @@ export async function handler(
response_length: responseLength,
"timestamp.last_byte": Date.now(),
})
dataDumper?.flush()
await rateLimiter?.track()
const usage = usageParser.retrieve()
if (usage) {
@@ -185,7 +174,6 @@ export async function handler(
}
responseLength += value.length
buffer += decoder.decode(value, { stream: true })
dataDumper?.provideStream(buffer)
const parts = buffer.split(providerInfo.streamSeparator)
buffer = parts.pop() ?? ""
+1 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.110",
"version": "1.0.102",
"private": true,
"type": "module",
"dependencies": {
@@ -30,7 +30,6 @@
"update-models": "script/update-models.ts",
"promote-models-to-dev": "script/promote-models.ts dev",
"promote-models-to-prod": "script/promote-models.ts production",
"pull-models-from-dev": "script/pull-models.ts dev",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
@@ -1,31 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "path"
import { ZenData } from "../src/model"
const stage = process.argv[2]
if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list --stage ${stage}`.cwd(root).text()
const lines = ret.split("\n")
const value1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[1]
const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1]
const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 not found")
if (!value3) throw new Error("ZEN_MODELS3 not found")
if (!value4) throw new Error("ZEN_MODELS4 not found")
// validate value
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4))
// update the secret
await $`bun sst secret set ZEN_MODELS1 ${value1}`
await $`bun sst secret set ZEN_MODELS2 ${value2}`
await $`bun sst secret set ZEN_MODELS3 ${value3}`
await $`bun sst secret set ZEN_MODELS4 ${value4}`
-1
View File
@@ -32,7 +32,6 @@ export namespace ZenData {
model: z.string(),
weight: z.number().optional(),
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
}),
),
})
-10
View File
@@ -74,14 +74,6 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -120,8 +112,6 @@ declare module "sst" {
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.110",
"version": "1.0.102",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-10
View File
@@ -74,14 +74,6 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -120,8 +112,6 @@ declare module "sst" {
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.110",
"version": "1.0.102",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -1,5 +1,4 @@
import { env } from "cloudflare:workers"
export { waitUntil } from "cloudflare:workers"
export const Resource = new Proxy(
{},
+39 -51
View File
@@ -2,66 +2,54 @@ import type { KVNamespaceListOptions, KVNamespaceListResult, KVNamespacePutOptio
import { Resource as ResourceBase } from "sst"
import Cloudflare from "cloudflare"
export const waitUntil = async (fn: () => Promise<void>) => {
await fn()
}
export const Resource = new Proxy(
{},
{
get(_target, prop: keyof typeof ResourceBase) {
const value = ResourceBase[prop]
if ("type" in value) {
// @ts-ignore
if ("type" in value && value.type === "sst.cloudflare.Kv") {
const client = new Cloudflare({
apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
})
// @ts-ignore
if (value.type === "sst.cloudflare.Bucket") {
return {
put: async () => {},
}
}
// @ts-ignore
if (value.type === "sst.cloudflare.Kv") {
const client = new Cloudflare({
apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
})
// @ts-ignore
const namespaceId = value.namespaceId
const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
return {
get: (k: string | string[]) => {
const isMulti = Array.isArray(k)
return client.kv.namespaces
.bulkGet(namespaceId, {
keys: Array.isArray(k) ? k : [k],
account_id: accountId,
})
.then((result) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k]))
},
put: (k: string, v: string, opts?: KVNamespacePutOptions) =>
client.kv.namespaces.values.update(namespaceId, k, {
const namespaceId = value.namespaceId
const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
return {
get: (k: string | string[]) => {
const isMulti = Array.isArray(k)
return client.kv.namespaces
.bulkGet(namespaceId, {
keys: Array.isArray(k) ? k : [k],
account_id: accountId,
value: v,
expiration: opts?.expiration,
expiration_ttl: opts?.expirationTtl,
metadata: opts?.metadata,
}),
delete: (k: string) =>
client.kv.namespaces.values.delete(namespaceId, k, {
})
.then((result) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k]))
},
put: (k: string, v: string, opts?: KVNamespacePutOptions) =>
client.kv.namespaces.values.update(namespaceId, k, {
account_id: accountId,
value: v,
expiration: opts?.expiration,
expiration_ttl: opts?.expirationTtl,
metadata: opts?.metadata,
}),
delete: (k: string) =>
client.kv.namespaces.values.delete(namespaceId, k, {
account_id: accountId,
}),
list: (opts?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> =>
client.kv.namespaces.keys
.list(namespaceId, {
account_id: accountId,
prefix: opts?.prefix ?? undefined,
})
.then((result) => {
return {
keys: result.result,
list_complete: true,
cacheStatus: null,
}
}),
list: (opts?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> =>
client.kv.namespaces.keys
.list(namespaceId, {
account_id: accountId,
prefix: opts?.prefix ?? undefined,
})
.then((result) => {
return {
keys: result.result,
list_complete: true,
cacheStatus: null,
}
}),
}
}
}
return value
-10
View File
@@ -74,14 +74,6 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -120,8 +112,6 @@ declare module "sst" {
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.110",
"version": "1.0.102",
"description": "",
"type": "module",
"scripts": {
+3 -3
View File
@@ -5,7 +5,7 @@ import { useSync } from "./sync"
import { makePersisted } from "@solid-primitives/storage"
import { TextSelection } from "./local"
import { pipe, sumBy } from "remeda"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
import { AssistantMessage } from "@opencode-ai/sdk"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@/utils"
@@ -123,8 +123,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
user: userMessages,
last: lastUserMessage,
active: activeMessage,
setActive(message: UserMessage | undefined) {
setStore("messageId", message?.id)
setActive(id: string | undefined) {
setStore("messageId", id)
},
},
usage: {
+9 -38
View File
@@ -1,4 +1,4 @@
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
import { For, onCleanup, onMount, Show, Match, Switch, createResource } from "solid-js"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
@@ -12,8 +12,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Code } from "@opencode-ai/ui/code"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { SessionTimeline } from "@opencode-ai/ui/session-timeline"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import {
@@ -256,8 +255,6 @@ export default function Page() {
return typeof draggable.id === "string" ? draggable.id : undefined
}
const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
return (
<div class="relative bg-background-base size-full overflow-x-hidden">
<DragDropProvider
@@ -333,40 +330,14 @@ export default function Page() {
flex: layout.review.state() === "pane",
}}
>
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-2xl mx-auto">
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-xl mx-auto">
<Switch>
<Match when={session.id}>
<div class="flex items-start justify-start h-full min-h-0">
<Show when={session.messages.user().length > 1}>
<>
<MessageNav
class="@6xl:hidden mt-3 mr-8"
messages={session.messages.user()}
current={session.messages.active()}
onMessageSelect={session.messages.setActive}
size="compact"
working={session.working()}
/>
<MessageNav
classList={{
"hidden @6xl:flex": true,
"mt-0.5 mr-3 absolute right-full": wide(),
"mt-3 mr-8": !wide(),
}}
messages={session.messages.user()}
current={session.messages.active()}
onMessageSelect={session.messages.setActive}
size={wide() ? "normal" : "compact"}
working={session.working()}
/>
</>
</Show>
<SessionTurn
sessionID={session.id!}
messageID={session.messages.active()?.id!}
classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20" }}
/>
</div>
<SessionTimeline
sessionID={session.id!}
expanded={layout.review.state() === "tab" || !session.diffs().length}
classes={{ root: "pb-20", container: "pb-20" }}
/>
</Match>
<Match when={true}>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
@@ -485,7 +456,7 @@ export default function Page() {
</DragOverlay>
</DragDropProvider>
<Show when={session.layout.tabs.active}>
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-6">
<PromptInput
ref={(el) => {
inputRef = el
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.110",
"version": "1.0.102",
"private": true,
"type": "module",
"scripts": {
@@ -24,7 +24,6 @@
"zod": "catalog:"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tailwindcss/vite": "catalog:",
"@typescript/native-preview": "catalog:",
"@types/luxon": "catalog:",
Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

-1
View File
@@ -1 +0,0 @@
../../ui/src/assets/favicon/favicon.svg
+17
View File
@@ -1 +1,18 @@
@import "@opencode-ai/ui/styles/tailwind";
:root {
--background-rgb: 214, 219, 220;
--foreground-rgb: 0, 0, 0;
}
@media (prefers-color-scheme: dark) {
:root {
--background-rgb: 0, 0, 0;
--foreground-rgb: 255, 255, 255;
}
}
body {
/* background: rgb(var(--background-rgb)); */
/* color: rgb(var(--foreground-rgb)); */
}
+11 -6
View File
@@ -1,5 +1,6 @@
import { Router } from "@solidjs/router"
import { FileRoutes } from "@solidjs/start/router"
import { Suspense } from "solid-js"
import { Fonts } from "@opencode-ai/ui/fonts"
import { MetaProvider } from "@solidjs/meta"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
@@ -9,12 +10,16 @@ export default function App() {
return (
<Router
root={(props) => (
<MarkedProvider>
<MetaProvider>
<Fonts />
{props.children}
</MetaProvider>
</MarkedProvider>
<>
<Suspense>
<MarkedProvider>
<MetaProvider>
<Fonts />
{props.children}
</MetaProvider>
</MarkedProvider>
</Suspense>
</>
)}
>
<FileRoutes />
+1 -8
View File
@@ -1,4 +1,4 @@
import { FileDiff, Message, Model, Part, Session, SessionStatus } from "@opencode-ai/sdk"
import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk"
import { fn } from "@opencode-ai/util/fn"
import { iife } from "@opencode-ai/util/iife"
import z from "zod"
@@ -32,10 +32,6 @@ export namespace Share {
type: z.literal("session_status"),
data: z.custom<SessionStatus>(),
}),
z.object({
type: z.literal("model"),
data: z.custom<Model[]>(),
}),
])
export type Data = z.infer<typeof Data>
@@ -115,9 +111,6 @@ export namespace Share {
case "session_status":
await Storage.write(["share_data", input.share.id, "session_status"], item.data)
break
case "model":
await Storage.write(["share_data", input.share.id, "model"], item.data)
break
}
}),
)
+1 -1
View File
@@ -8,7 +8,7 @@ export default createHandler(() => (
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<title>OpenCode</title>
{assets}
</head>
@@ -1,29 +1,15 @@
import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk"
import { SessionTimeline } from "@opencode-ai/ui/session-timeline"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context"
import { DataProvider, useData } from "@opencode-ai/ui/context"
import { createAsync, query, RouteDefinition, useParams } from "@solidjs/router"
import { createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { createMemo, Show } from "solid-js"
import { Share } from "~/core/share"
import { Logo, Mark } from "@opencode-ai/ui/logo"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary"
import { NamedError } from "@opencode-ai/util/error"
import { DateTime } from "luxon"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { createStore } from "solid-js/store"
import z from "zod"
import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
const SessionDataMissingError = NamedError.create(
"SessionDataMissingError",
z.object({
sessionID: z.string(),
message: z.string().optional(),
}),
)
const getData = query(async (sessionID) => {
const data = await Share.data(sessionID)
@@ -41,9 +27,6 @@ const getData = query(async (sessionID) => {
part: {
[messageID: string]: Part[]
}
model: {
[sessionID: string]: Model[]
}
} = {
session: [],
session_diff: {
@@ -56,8 +39,8 @@ const getData = query(async (sessionID) => {
},
message: {},
part: {},
model: {},
}
for (const item of data) {
switch (item.type) {
case "session":
@@ -77,13 +60,8 @@ const getData = query(async (sessionID) => {
result.part[item.data.messageID] = result.part[item.data.messageID] ?? []
result.part[item.data.messageID].push(item.data)
break
case "model":
result.model[sessionID] = item.data
break
}
}
const match = Binary.search(result.session, sessionID!, (s) => s.id)
if (!match.found) throw new SessionDataMissingError({ sessionID })
return result
}, "getShareData")
@@ -94,214 +72,101 @@ export const route = {
export default function () {
const params = useParams()
const data = createAsync(async () => {
if (!params.sessionID) throw new Error("Missing sessionID")
if (!params.sessionID) return
return getData(params.sessionID)
})
return (
<ErrorBoundary
fallback={(e) => {
return (
<Show when={e.message === "SessionDataMissingError"}>
<NotFound />
</Show>
)
}}
>
<Show when={data()}>
{(data) => (
<DataProvider data={data()}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const match = createMemo(() => Binary.search(data().session, params.sessionID!, (s) => s.id))
if (!match().found) throw new Error(`Session ${params.sessionID} not found`)
const info = createMemo(() => data().session[match().index])
const messages = createMemo(() =>
params.sessionID
? (data().message[params.sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => b.time.created - a.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[params.sessionID!]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => data().session_diff[params.sessionID!] ?? [])
<Show when={data()}>
{(data) => (
<DataProvider data={data()}>
{iife(() => {
const data = useData()
const match = createMemo(() => Binary.search(data.session, params.sessionID!, (s) => s.id))
if (!match().found) throw new Error(`Session ${params.sessionID} not found`)
const info = createMemo(() => data.session[match().index])
const firstUserMessage = createMemo(() =>
data.message[params.sessionID!]?.filter((m) => m.role === "user")?.at(0),
)
const provider = createMemo(() => firstUserMessage()?.model?.providerID)
const model = createMemo(() => firstUserMessage()?.model?.modelID)
const diffs = createMemo(() => data.session_diff[params.sessionID!] ?? [])
const title = () => (
<div class="flex flex-col gap-4 shrink-0">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" />
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
const turns = () => (
<div class="relative mt-2 pt-6 pb-8 px-4 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
{title()}
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={params.sessionID!}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
}}
/>
)}
</For>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
<div class="flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
)
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div class="hidden md:flex w-full flex-1 min-h-0">
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
"px-21 @4xl:px-6 max-w-2xl": !wide(),
"px-6 max-w-2xl": wide(),
}}
>
{title()}
<div class="flex items-start justify-start h-full min-h-0">
<Show when={messages().length > 1}>
<>
<div class="xl:hidden absolute right-full">
<MessageNav
class="mt-2 mr-3"
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
size="compact"
/>
</div>
<div
classList={{
"hidden xl:block": true,
"absolute right-[90%]": !wide(),
"absolute right-full": wide(),
}}
>
<MessageNav
classList={{
"mt-2.5 mr-3": !wide(),
"mt-0.5 mr-8": wide(),
}}
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
size={wide() ? "normal" : "compact"}
/>
</div>
</>
</Show>
<SessionTurn
sessionID={params.sessionID!}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
>
<div class="flex items-center justify-center pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div class="w-full flex-1 min-h-0 flex">
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
"px-21 @4xl:px-6 max-w-2xl": diffs().length > 0,
"px-6 max-w-2xl": diffs().length === 0,
}}
>
<div class="flex flex-col gap-4 shrink-0">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<img
src={`https://models.dev/logos/${provider()}.svg`}
class="size-4 shrink-0 dark:invert"
/>
<div class="text-12-regular text-text-base">{model()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
<Show when={diffs().length > 0}>
<div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview diffs={diffs()} class="pb-20" />
<SessionTimeline
sessionID={params.sessionID!}
classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
expanded
>
<div class="flex items-center justify-center pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</Show>
</SessionTimeline>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs class="md:hidden">
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger value="review" class="w-1/2" classes={{ button: "w-full" }}>
5 Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content value="review" class="!overflow-hidden">
<div class="relative px-4 pt-8 h-full overflow-y-auto no-scrollbar">
<SessionReview diffs={diffs()} class="pb-20" />
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div class="md:hidden !overflow-hidden">{turns()}</div>
</Match>
</Switch>
<Show when={diffs().length}>
<div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview diffs={diffs()} class="pb-20" />
</div>
</Show>
</div>
</div>
)
})}
</DataProvider>
)}
</Show>
</ErrorBoundary>
</div>
)
})}
</DataProvider>
)}
</Show>
)
}
-131
View File
@@ -1,131 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
import "sst"
declare module "sst" {
export interface Resource {
ADMIN_SECRET: {
type: "sst.sst.Secret"
value: string
}
AUTH_API_URL: {
type: "sst.sst.Linkable"
value: string
}
AWS_SES_ACCESS_KEY_ID: {
type: "sst.sst.Secret"
value: string
}
AWS_SES_SECRET_ACCESS_KEY: {
type: "sst.sst.Secret"
value: string
}
CLOUDFLARE_API_TOKEN: {
type: "sst.sst.Secret"
value: string
}
CLOUDFLARE_DEFAULT_ACCOUNT_ID: {
type: "sst.sst.Secret"
value: string
}
Console: {
type: "sst.cloudflare.SolidStart"
url: string
}
Database: {
database: string
host: string
password: string
port: number
type: "sst.sst.Linkable"
username: string
}
Desktop: {
type: "sst.cloudflare.StaticSite"
url: string
}
EMAILOCTOPUS_API_KEY: {
type: "sst.sst.Secret"
value: string
}
GITHUB_APP_ID: {
type: "sst.sst.Secret"
value: string
}
GITHUB_APP_PRIVATE_KEY: {
type: "sst.sst.Secret"
value: string
}
GITHUB_CLIENT_ID_CONSOLE: {
type: "sst.sst.Secret"
value: string
}
GITHUB_CLIENT_SECRET_CONSOLE: {
type: "sst.sst.Secret"
value: string
}
GOOGLE_CLIENT_ID: {
type: "sst.sst.Secret"
value: string
}
HONEYCOMB_API_KEY: {
type: "sst.sst.Secret"
value: string
}
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
}
STRIPE_WEBHOOK_SECRET: {
type: "sst.sst.Linkable"
value: string
}
Web: {
type: "sst.cloudflare.Astro"
url: string
}
ZEN_MODELS1: {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS2: {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types"
declare module "sst" {
export interface Resource {
Api: cloudflare.Service
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
}
import "sst"
export {}
+1 -1
View File
@@ -11,7 +11,7 @@
"allowJs": true,
"noEmit": true,
"strict": true,
"types": ["@cloudflare/workers-types", "vite/client"],
"types": ["vite/client"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
+1 -1
View File
@@ -3,7 +3,7 @@ import { solidStart } from "@solidjs/start/config"
import { nitro } from "nitro/vite"
import tailwindcss from "@tailwindcss/vite"
const nitroConfig: any = (() => {
const nitroConfig = (() => {
const target = process.env.OPENCODE_DEPLOYMENT_TARGET
if (target === "cloudflare") {
return {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The AI coding agent built for the terminal"
version = "1.0.110"
version = "1.0.102"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.110/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.102/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.110/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.102/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.110/opencode-linux-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.102/opencode-linux-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.110/opencode-linux-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.102/opencode-linux-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.110/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.102/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.110",
"version": "1.0.102",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-10
View File
@@ -74,14 +74,6 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -120,8 +112,6 @@ declare module "sst" {
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
+5 -12
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.110",
"version": "1.0.102",
"name": "opencode",
"type": "module",
"private": true,
@@ -18,6 +18,8 @@
"./*": "./src/*.ts"
},
"devDependencies": {
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/google-vertex": "3.0.16",
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -42,14 +44,7 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.57",
"@ai-sdk/anthropic": "2.0.45",
"@ai-sdk/azure": "2.0.73",
"@ai-sdk/google": "2.0.42",
"@ai-sdk/google-vertex": "3.0.74",
"@ai-sdk/mcp": "0.0.8",
"@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -61,9 +56,8 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.2.5",
"@opentui/core": "0.1.50",
"@opentui/solid": "0.1.50",
"@opentui/core": "0.1.47",
"@opentui/solid": "0.1.47",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -82,7 +76,6 @@
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.5",
"partial-json": "0.1.7",
"remeda": "catalog:",
"solid-js": "catalog:",
+1 -25
View File
@@ -2,14 +2,11 @@ import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import { readableStreamToText } from "bun"
import { createRequire } from "module"
import { Lock } from "../util/lock"
export namespace BunProc {
const log = Log.create({ service: "bun" })
const req = createRequire(import.meta.url)
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
log.info("running", {
@@ -61,9 +58,6 @@ export namespace BunProc {
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
const parsed = await pkgjson.json().catch(async () => {
@@ -131,22 +125,4 @@ export namespace BunProc {
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
return mod
}
export async function resolve(pkg: string) {
const local = workspace(pkg)
if (local) return local
const dir = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjson = Bun.file(path.join(dir, "package.json"))
const exists = await pkgjson.exists()
if (exists) return dir
}
function workspace(pkg: string) {
try {
const target = req.resolve(`${pkg}/package.json`)
return path.dirname(target)
} catch {
return
}
}
}
-10
View File
@@ -1,7 +1,6 @@
import type { Argv } from "yargs"
import { Instance } from "../../project/instance"
import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
import { cmd } from "./cmd"
import { UI } from "../ui"
import { EOL } from "os"
@@ -20,17 +19,8 @@ export const ModelsCommand = cmd({
describe: "use more verbose model output (includes metadata like costs)",
type: "boolean",
})
.option("refresh", {
describe: "refresh the models cache from models.dev",
type: "boolean",
})
},
handler: async (args) => {
if (args.refresh) {
await ModelsDev.refresh()
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
}
await Instance.provide({
directory: process.cwd(),
async fn() {
-24
View File
@@ -342,21 +342,6 @@ function App() {
dialog.clear()
},
},
{
title: "Suspend terminal",
value: "terminal.suspend",
keybind: "terminal_suspend",
category: "System",
onSelect: () => {
process.once("SIGCONT", () => {
renderer.resume()
})
renderer.suspend()
// pid=0 means send the signal to all processes in the process group
process.kill(0, "SIGTSTP")
},
},
])
createEffect(() => {
@@ -426,15 +411,6 @@ function App() {
})
})
event.on(Installation.Event.UpdateAvailable.type, (evt) => {
toast.show({
variant: "info",
title: "Update Available",
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
duration: 10000,
})
})
return (
<box
width={dimensions().width}
@@ -5,7 +5,7 @@ import { createMemo, createResource, createEffect, onMount, For, Show } from "so
import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import { Locale } from "@/util/locale"
@@ -455,7 +455,7 @@ export function Autocomplete(props: {
{...SplitBorder}
borderColor={theme.border}
>
<box backgroundColor={theme.backgroundMenu} height={height()}>
<box backgroundColor={theme.backgroundElement} height={height()}>
<For
each={options()}
fallback={
@@ -471,11 +471,11 @@ export function Autocomplete(props: {
backgroundColor={index() === store.selected ? theme.primary : undefined}
flexDirection="row"
>
<text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
<text fg={index() === store.selected ? theme.background : theme.text} flexShrink={0}>
{option.display}
</text>
<Show when={option.description}>
<text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
<text fg={index() === store.selected ? theme.background : theme.textMuted} wrapMode="none">
{option.description}
</text>
</Show>
@@ -1,6 +1,5 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { EmptyBorder } from "@tui/component/border"
@@ -21,7 +20,7 @@ import type { FilePart } from "@opencode-ai/sdk"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { createColors, createFrames } from "../../ui/spinner.ts"
import { Shimmer } from "../../ui/shimmer"
export type PromptProps = {
sessionID?: string
@@ -546,20 +545,8 @@ export function Prompt(props: PromptProps) {
return local.agent.color(local.agent.current().name)
})
const spinnerDef = createMemo(() => {
const color = local.agent.color(local.agent.current().name)
return {
frames: createFrames({
color,
style: "blocks",
inactiveFactor: 0.25,
}),
color: createColors({
color,
style: "blocks",
inactiveFactor: 0.25,
}),
}
createEffect(() => {
renderer.setCursorColor(highlight())
})
return (
@@ -773,7 +760,7 @@ export function Prompt(props: PromptProps) {
ref={(r: TextareaRenderable) => (input = r)}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={highlight()}
cursorColor={theme.primary}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
@@ -804,17 +791,10 @@ export function Prompt(props: PromptProps) {
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={
theme.background.a != 0
? {
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
customBorderChars={{
...EmptyBorder,
horizontal: "▀",
}}
/>
</box>
<box flexDirection="row" justifyContent="space-between">
@@ -826,7 +806,7 @@ export function Prompt(props: PromptProps) {
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
<Loader />
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
@@ -896,3 +876,38 @@ export function Prompt(props: PromptProps) {
</>
)
}
function Loader() {
const FRAMES = [
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▰▱▱▱▱▱▱",
"▰▰▱▱▱▱▱",
"▰▰▰▱▱▱▱",
"▱▰▰▰▱▱▱",
"▱▱▰▰▰▱▱",
"▱▱▱▰▰▰▱",
"▱▱▱▱▰▰▰",
"▱▱▱▱▱▰▰",
"▱▱▱▱▱▱▰",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
]
const [frame, setFrame] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
setFrame((frame() + 1) % FRAMES.length)
}, 100)
onCleanup(() => {
clearInterval(timer)
})
})
const { theme } = useTheme()
return <text fg={theme.diffAdded}>{FRAMES[frame()]}</text>
}
@@ -94,7 +94,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = agents().find((x) => x.name === name)
if (agent?.color) return RGBA.fromHex(agent.color)
const index = agents().findIndex((x) => x.name === name)
if (index === -1) return colors()[0]
return colors()[index % colors().length]
},
}
@@ -144,7 +144,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
break
}
case "session.updated": {
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))
@@ -157,7 +157,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}),
)
break
}
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
@@ -289,7 +288,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
bootstrap()
})
const fullSyncedSessions = new Set<string>()
const result = {
data: store,
set: setStore,
@@ -316,13 +314,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
if (store.message[sessionID]) return
const now = Date.now()
console.log("syncing", sessionID)
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }),
sdk.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }),
sdk.client.session.todo({ path: { id: sessionID } }),
sdk.client.session.diff({ path: { id: sessionID } }),
])
console.log("fetched in " + (Date.now() - now), sessionID)
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
@@ -336,7 +337,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
draft.session_diff[sessionID] = diff.data ?? []
}),
)
fullSyncedSessions.add(sessionID)
console.log("synced in " + (Date.now() - now), sessionID)
},
},
bootstrap,
@@ -33,7 +33,7 @@ import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
type ThemeColors = {
type Theme = {
primary: RGBA
secondary: RGBA
accent: RGBA
@@ -43,11 +43,9 @@ type ThemeColors = {
info: RGBA
text: RGBA
textMuted: RGBA
selectedListItemText: RGBA
background: RGBA
backgroundPanel: RGBA
backgroundElement: RGBA
backgroundMenu: RGBA
border: RGBA
borderActive: RGBA
borderSubtle: RGBA
@@ -88,27 +86,6 @@ type ThemeColors = {
syntaxPunctuation: RGBA
}
type Theme = ThemeColors & {
_hasSelectedListItemText: boolean
}
export function selectedForeground(theme: Theme): RGBA {
// If theme explicitly defines selectedListItemText, use it
if (theme._hasSelectedListItemText) {
return theme.selectedListItemText
}
// For transparent backgrounds, calculate contrast based on primary color
if (theme.background.a === 0) {
const { r, g, b } = theme.primary
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
}
// Fall back to background color
return theme.background
}
type HexColor = `#${string}`
type RefName = string
type Variant = {
@@ -119,10 +96,7 @@ type ColorValue = HexColor | RefName | Variant | RGBA
type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
}
theme: Record<keyof Theme, ColorValue>
}
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
@@ -163,44 +137,19 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
if (defs[c]) {
return resolveColor(defs[c])
} else if (theme.theme[c as keyof ThemeColors] !== undefined) {
return resolveColor(theme.theme[c as keyof ThemeColors]!)
} else if (theme.theme[c as keyof Theme]) {
return resolveColor(theme.theme[c as keyof Theme])
} else {
throw new Error(`Color reference "${c}" not found in defs or theme`)
}
}
return resolveColor(c[mode])
}
const resolved = Object.fromEntries(
Object.entries(theme.theme)
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu")
.map(([key, value]) => {
return [key, resolveColor(value)]
}),
) as Partial<ThemeColors>
// Handle selectedListItemText separately since it's optional
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
if (hasSelectedListItemText) {
resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!)
} else {
// Backward compatibility: if selectedListItemText is not defined, use background color
// This preserves the current behavior for all existing themes
resolved.selectedListItemText = resolved.background
}
// Handle backgroundMenu - optional with fallback to backgroundElement
if (theme.theme.backgroundMenu !== undefined) {
resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu)
} else {
resolved.backgroundMenu = resolved.backgroundElement
}
return {
...resolved,
_hasSelectedListItemText: hasSelectedListItemText,
} as Theme
return Object.fromEntries(
Object.entries(theme.theme).map(([key, value]) => {
return [key, resolveColor(value)]
}),
) as Theme
}
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
@@ -210,7 +159,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const kv = useKV()
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
mode: props.mode,
active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
@@ -262,7 +211,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
},
setMode(mode: "dark" | "light") {
setStore("mode", mode)
kv.set("theme_mode", mode)
},
set(theme: string) {
setStore("active", theme)
@@ -340,13 +288,11 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
// Text colors
text: fg,
textMuted,
selectedListItemText: bg,
// Background colors
background: bg,
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],
// Border colors
borderSubtle: grays[6],
@@ -177,7 +177,6 @@ export function Session() {
const first = permissions()[0]
if (first) {
const response = iife(() => {
if (evt.ctrl || evt.meta) return
if (evt.name === "return") return "once"
if (evt.name === "a") return "always"
if (evt.name === "d") return "reject"
@@ -970,8 +969,8 @@ function UserMessage(props: {
fallback={
<span style={{ fg: theme.textMuted }}>
{ctx.showTimestamps()
? Locale.todayTimeOrDateTime(props.message.time.created)
: Locale.time(props.message.time.created)}
? `· ${Locale.todayTimeOrDateTime(props.message.time.created)}`
: `· ${Locale.time(props.message.time.created)}`}
</span>
}
>
@@ -1000,15 +999,10 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
const sync = useSync()
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
const final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
})
const duration = createMemo(() => {
if (!final()) return 0
if (!props.message.time.completed) return 0
const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID)
if (!user || !user.time) return 0
if (!user) return 0
return props.message.time.completed - user.time.created
})
@@ -1044,14 +1038,22 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
</box>
</Show>
<Switch>
<Match when={props.last || final()}>
<Match
when={
(props.message.time.completed &&
props.parts.some(
(item) => item.type === "step-finish" && !["tool-calls", "unknown"].includes(item.reason),
)) ||
props.last
}
>
<box paddingLeft={3}>
<text marginTop={1}>
<span style={{ fg: local.agent.color(props.message.mode) }}> </span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
<span style={{ fg: local.agent.color(props.message.mode) }}></span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>{" "}
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
<Show when={props.message.time.completed}>
<span style={{ fg: theme.textMuted }}> {Locale.duration(duration())}</span>
</Show>
</text>
</box>
@@ -1098,7 +1100,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { theme, syntax } = useTheme()
const { syntax } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
@@ -1109,7 +1111,6 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
syntaxStyle={syntax()}
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.text}
/>
</box>
</Show>
@@ -18,9 +18,6 @@ export function Sidebar(props: { sessionID: string }) {
const [todoExpanded, setTodoExpanded] = createSignal(true)
const [lspExpanded, setLspExpanded] = createSignal(true)
// Sort MCP servers alphabetically for consistent display order
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
const cost = createMemo(() => {
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
@@ -61,22 +58,22 @@ export function Sidebar(props: { sessionID: string }) {
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={mcpEntries().length > 0}>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => mcpEntries().length > 2 && setMcpExpanded(!mcpExpanded())}
onMouseDown={() => Object.keys(sync.data.mcp).length > 2 && setMcpExpanded(!mcpExpanded())}
>
<Show when={mcpEntries().length > 2}>
<Show when={Object.keys(sync.data.mcp).length > 2}>
<text fg={theme.text}>{mcpExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>MCP</b>
</text>
</box>
<Show when={mcpEntries().length <= 2 || mcpExpanded()}>
<For each={mcpEntries()}>
<Show when={Object.keys(sync.data.mcp).length <= 2 || mcpExpanded()}>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
@@ -191,7 +188,6 @@ export function Sidebar(props: { sessionID: string }) {
const splits = item.file.split(path.sep).filter(Boolean)
const last = splits.at(-1)!
const rest = splits.slice(0, -1).join(path.sep)
if (!rest) return last
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
})
return (
+9 -14
View File
@@ -58,14 +58,16 @@ export const TuiThreadCommand = cmd({
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
const localWorker = new URL("./worker.ts", import.meta.url)
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
const defaultWorker = new URL("./worker.ts", import.meta.url)
// Nix build creates a bundled worker next to the binary; prefer it when present.
const execDir = path.dirname(process.execPath)
const workerPath = await iife(async () => {
const bundledWorker = path.join(execDir, "opencode-worker.js")
const hasBundledWorker = await Bun.file(bundledWorker).exists()
const workerPath = (() => {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
if (await Bun.file(distWorker).exists()) return distWorker
return localWorker
})
if (hasBundledWorker) return bundledWorker
return defaultWorker
})()
try {
process.chdir(cwd)
} catch (e) {
@@ -97,8 +99,7 @@ export const TuiThreadCommand = cmd({
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})
const tuiPromise = tui({
await tui({
url: server.url,
args: {
continue: args.continue,
@@ -111,11 +112,5 @@ export const TuiThreadCommand = cmd({
await client.call("shutdown", undefined)
},
})
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000)
await tuiPromise
},
})
@@ -38,7 +38,7 @@ export function DialogAlert(props: DialogAlertProps) {
dialog.clear()
}}
>
<text fg={theme.selectedListItemText}>ok</text>
<text fg={theme.background}>ok</text>
</box>
</box>
</box>
@@ -53,9 +53,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
dialog.clear()
}}
>
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
{Locale.titlecase(key)}
</text>
<text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text>
</box>
)}
</For>
@@ -28,7 +28,7 @@ export function DialogHelp() {
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}>
<text fg={theme.selectedListItemText}>ok</text>
<text fg={theme.background}>ok</text>
</box>
</box>
</box>
@@ -1,5 +1,5 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { batch, createEffect, createMemo, For, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
@@ -262,29 +262,32 @@ function Option(props: {
onMouseOver?: () => void
}) {
const { theme } = useTheme()
const fg = selectedForeground(theme)
return (
<>
<Show when={props.current}>
<text flexShrink={0} fg={props.active ? fg : props.current ? theme.primary : theme.text} marginRight={0.5}>
<text
flexShrink={0}
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
marginRight={0.5}
>
</text>
</Show>
<text
flexGrow={1}
fg={props.active ? fg : props.current ? theme.primary : theme.text}
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
paddingLeft={3}
>
{Locale.truncate(props.title, 62)}
<span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text fg={props.active ? fg : theme.textMuted}>{props.footer}</text>
<text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text>
</box>
</Show>
</>
@@ -0,0 +1,56 @@
import { RGBA } from "@opentui/core"
import { useTimeline } from "@opentui/solid"
import { createMemo, createSignal } from "solid-js"
export type ShimmerProps = {
text: string
color: RGBA
}
const DURATION = 2_500
export function Shimmer(props: ShimmerProps) {
const timeline = useTimeline({
duration: DURATION,
loop: true,
})
const characters = props.text.split("")
const color = props.color
const shimmerSignals = characters.map((_, i) => {
const [shimmer, setShimmer] = createSignal(0.4)
const target = {
shimmer: shimmer(),
setShimmer,
}
timeline!.add(
target,
{
shimmer: 1,
duration: DURATION / (props.text.length + 1),
ease: "linear",
alternate: true,
loop: 2,
onUpdate: () => {
target.setShimmer(target.shimmer)
},
},
(i * (DURATION / (props.text.length + 1))) / 2,
)
return shimmer
})
return (
<text>
{(() => {
return characters.map((ch, i) => {
const shimmer = shimmerSignals[i]
const fg = RGBA.fromInts(color.r * 255, color.g * 255, color.b * 255, shimmer() * 255)
return <span style={{ fg }}>{ch}</span>
})
})()}
</text>
)
}
@@ -1,355 +0,0 @@
import type { ColorInput } from "@opentui/core"
import { RGBA } from "@opentui/core"
import type { ColorGenerator } from "opentui-spinner"
interface AdvancedGradientOptions {
colors: ColorInput[]
trailLength: number
defaultColor?: ColorInput
direction?: "forward" | "backward" | "bidirectional"
holdFrames?: { start?: number; end?: number }
}
interface ScannerState {
activePosition: number
isHolding: boolean
holdProgress: number
holdTotal: number
movementProgress: number
movementTotal: number
isMovingForward: boolean
}
function getScannerState(
frameIndex: number,
totalChars: number,
options: Pick<AdvancedGradientOptions, "direction" | "holdFrames">,
): ScannerState {
const { direction = "forward", holdFrames = {} } = options
if (direction === "bidirectional") {
const forwardFrames = totalChars
const holdEndFrames = holdFrames.end ?? 0
const backwardFrames = totalChars - 1
if (frameIndex < forwardFrames) {
// Moving forward
return {
activePosition: frameIndex,
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: frameIndex,
movementTotal: forwardFrames,
isMovingForward: true,
}
} else if (frameIndex < forwardFrames + holdEndFrames) {
// Holding at end
return {
activePosition: totalChars - 1,
isHolding: true,
holdProgress: frameIndex - forwardFrames,
holdTotal: holdEndFrames,
movementProgress: 0,
movementTotal: 0,
isMovingForward: true,
}
} else if (frameIndex < forwardFrames + holdEndFrames + backwardFrames) {
// Moving backward
const backwardIndex = frameIndex - forwardFrames - holdEndFrames
return {
activePosition: totalChars - 2 - backwardIndex,
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: backwardIndex,
movementTotal: backwardFrames,
isMovingForward: false,
}
} else {
// Holding at start
return {
activePosition: 0,
isHolding: true,
holdProgress: frameIndex - forwardFrames - holdEndFrames - backwardFrames,
holdTotal: holdFrames.start ?? 0,
movementProgress: 0,
movementTotal: 0,
isMovingForward: false,
}
}
} else if (direction === "backward") {
return {
activePosition: totalChars - 1 - (frameIndex % totalChars),
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: frameIndex % totalChars,
movementTotal: totalChars,
isMovingForward: false,
}
} else {
return {
activePosition: frameIndex % totalChars,
isHolding: false,
holdProgress: 0,
holdTotal: 0,
movementProgress: frameIndex % totalChars,
movementTotal: totalChars,
isMovingForward: true,
}
}
}
function calculateColorIndex(
frameIndex: number,
charIndex: number,
totalChars: number,
options: Pick<AdvancedGradientOptions, "direction" | "holdFrames" | "trailLength">,
state?: ScannerState,
): number {
const { trailLength } = options
const { activePosition, isHolding, holdProgress, isMovingForward } =
state ?? getScannerState(frameIndex, totalChars, options)
// Calculate directional distance (positive means trailing behind)
const directionalDistance = isMovingForward
? activePosition - charIndex // For forward: trail is to the left (lower indices)
: charIndex - activePosition // For backward: trail is to the right (higher indices)
// Handle hold frame fading: keep the lead bright, fade the trail
if (isHolding) {
// Shift the color index by how long we've been holding
return directionalDistance + holdProgress
}
// Normal movement - show gradient trail only behind the movement direction
if (directionalDistance > 0 && directionalDistance < trailLength) {
return directionalDistance
}
// At the active position, show the brightest color
if (directionalDistance === 0) {
return 0
}
return -1
}
function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerator {
const { colors, defaultColor } = options
// Use the provided defaultColor if it's an RGBA instance, otherwise convert/default
// We use RGBA.fromHex for the fallback to ensure we have an RGBA object.
// Note: If defaultColor is a string, we convert it once here.
const defaultRgba = defaultColor instanceof RGBA ? defaultColor : RGBA.fromHex((defaultColor as string) || "#000000")
let cachedFrameIndex = -1
let cachedState: ScannerState | null = null
return (frameIndex: number, charIndex: number, _totalFrames: number, totalChars: number) => {
if (frameIndex !== cachedFrameIndex) {
cachedFrameIndex = frameIndex
cachedState = getScannerState(frameIndex, totalChars, options)
}
const state = cachedState!
const index = calculateColorIndex(frameIndex, charIndex, totalChars, options, state)
// Calculate global fade for inactive dots during hold or movement
const { isHolding, holdProgress, holdTotal, movementProgress, movementTotal } = state
let alpha = 1.0
if (isHolding && holdTotal > 0) {
// Fade out linearly
const progress = Math.min(holdProgress / holdTotal, 1)
alpha = Math.max(0, 1 - progress)
} else if (!isHolding && movementTotal > 0) {
// Fade in linearly during movement
const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1)
alpha = progress
}
// Mutate the alpha of the default RGBA object
// This assumes single-threaded, synchronous rendering per frame
// where we can modify the state for the current frame.
// Since this is run for every char in the frame, setting it repeatedly to the same value is fine.
defaultRgba.a = alpha
if (index === -1) {
return defaultRgba
}
return colors[index] ?? defaultRgba
}
}
/**
* Derives a gradient of tail colors from a single bright color
* @param brightColor The brightest color (center/head of the scanner)
* @param steps Number of gradient steps (default: 6)
* @returns Array of RGBA colors from brightest to darkest
*/
export function deriveTrailColors(brightColor: ColorInput, steps: number = 6): RGBA[] {
const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
const colors: RGBA[] = []
for (let i = 0; i < steps; i++) {
// Progressive darkening:
// i=0: 100% brightness (original color)
// i=1: add slight bloom/glare (lighten)
// i=2+: progressively darken
let factor: number
if (i === 0) {
factor = 1.0 // Original brightness
} else if (i === 1) {
factor = 1.2 // Slight bloom/glare effect
} else {
// Exponential decay for natural-looking trail fade
factor = Math.pow(0.6, i - 1)
}
const r = Math.min(1.0, baseRgba.r * factor)
const g = Math.min(1.0, baseRgba.g * factor)
const b = Math.min(1.0, baseRgba.b * factor)
colors.push(RGBA.fromValues(r, g, b, 1.0))
}
return colors
}
/**
* Derives the inactive/default color from a bright color
* @param brightColor The brightest color (center/head of the scanner)
* @param factor Brightness factor for inactive color (default: 0.2)
* @returns A much darker version suitable for inactive dots
*/
export function deriveInactiveColor(brightColor: ColorInput, factor: number = 0.2): RGBA {
const baseRgba = brightColor instanceof RGBA ? brightColor : RGBA.fromHex(brightColor as string)
const r = baseRgba.r * factor
const g = baseRgba.g * factor
const b = baseRgba.b * factor
return RGBA.fromValues(r, g, b, 1.0)
}
export type KnightRiderStyle = "blocks" | "diamonds"
export interface KnightRiderOptions {
width?: number
style?: KnightRiderStyle
holdStart?: number
holdEnd?: number
colors?: ColorInput[]
/** Single color to derive trail from (alternative to providing colors array) */
color?: ColorInput
/** Number of trail steps when using single color (default: 6) */
trailSteps?: number
defaultColor?: ColorInput
/** Brightness factor for inactive color when using single color (default: 0.2) */
inactiveFactor?: number
}
/**
* Creates frame strings for a Knight Rider style scanner animation
* @param options Configuration options for the Knight Rider effect
* @returns Array of frame strings
*/
export function createFrames(options: KnightRiderOptions = {}): string[] {
const width = options.width ?? 8
const style = options.style ?? "diamonds"
const holdStart = options.holdStart ?? 30
const holdEnd = options.holdEnd ?? 9
const colors =
options.colors ??
(options.color
? deriveTrailColors(options.color, options.trailSteps)
: [
RGBA.fromHex("#ff0000"), // Brightest Red (Center)
RGBA.fromHex("#ff5555"), // Glare/Bloom
RGBA.fromHex("#dd0000"), // Trail 1
RGBA.fromHex("#aa0000"), // Trail 2
RGBA.fromHex("#770000"), // Trail 3
RGBA.fromHex("#440000"), // Trail 4
])
const defaultColor =
options.defaultColor ??
(options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000"))
const trailOptions = {
colors,
trailLength: colors.length,
defaultColor,
direction: "bidirectional" as const,
holdFrames: { start: holdStart, end: holdEnd },
}
// Bidirectional cycle: Forward (width) + Hold End + Backward (width-1) + Hold Start
const totalFrames = width + holdEnd + (width - 1) + holdStart
// Generate dynamic frames where inactive pixels are dots and active ones are blocks
const frames = Array.from({ length: totalFrames }, (_, frameIndex) => {
return Array.from({ length: width }, (_, charIndex) => {
const index = calculateColorIndex(frameIndex, charIndex, width, trailOptions)
if (style === "diamonds") {
const shapes = ["⬥", "◆", "⬩", "⬪"]
if (index >= 0 && index < trailOptions.colors.length) {
return shapes[Math.min(index, shapes.length - 1)]
}
return "·"
}
// Default to blocks
// It's active if we have a valid color index that is within our colors array
const isActive = index >= 0 && index < trailOptions.colors.length
return isActive ? "■" : "⬝"
}).join("")
})
return frames
}
/**
* Creates a color generator function for Knight Rider style scanner animation
* @param options Configuration options for the Knight Rider effect
* @returns ColorGenerator function
*/
export function createColors(options: KnightRiderOptions = {}): ColorGenerator {
const holdStart = options.holdStart ?? 30
const holdEnd = options.holdEnd ?? 9
const colors =
options.colors ??
(options.color
? deriveTrailColors(options.color, options.trailSteps)
: [
RGBA.fromHex("#ff0000"), // Brightest Red (Center)
RGBA.fromHex("#ff5555"), // Glare/Bloom
RGBA.fromHex("#dd0000"), // Trail 1
RGBA.fromHex("#aa0000"), // Trail 2
RGBA.fromHex("#770000"), // Trail 3
RGBA.fromHex("#440000"), // Trail 4
])
const defaultColor =
options.defaultColor ??
(options.color ? deriveInactiveColor(options.color, options.inactiveFactor) : RGBA.fromHex("#330000"))
const trailOptions = {
colors,
trailLength: colors.length,
defaultColor,
direction: "bidirectional" as const,
holdFrames: { start: holdStart, end: holdEnd },
}
return createKnightRiderTrail(trailOptions)
}
+2 -8
View File
@@ -26,6 +26,8 @@ process.on("uncaughtException", (e) => {
})
})
upgrade()
let server: Bun.Server<undefined>
export const rpc = {
async server(input: { port: number; hostname: string }) {
@@ -40,14 +42,6 @@ export const rpc = {
throw e
}
},
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
directory: input.directory,
fn: async () => {
await upgrade().catch(() => {})
},
})
},
async shutdown() {
Log.Default.info("worker shutting down")
await Instance.disposeAll()
+1 -1
View File
@@ -1,6 +1,6 @@
import z from "zod"
import { EOL } from "os"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
export namespace UI {
const LOGO = [
+1 -9
View File
@@ -5,18 +5,10 @@ import { Installation } from "@/installation"
export async function upgrade() {
const config = await Config.global()
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const latest = await Installation.latest().catch(() => {})
if (!latest) return
if (Installation.VERSION === latest) return
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) {
return
}
if (config.autoupdate === "notify") {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return
}
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
+2 -8
View File
@@ -8,7 +8,7 @@ import { mergeDeep, pipe } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
@@ -430,7 +430,6 @@ export namespace Config {
history_next: z.string().optional().default("down").describe("Next history item"),
session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
})
.strict()
.meta({
@@ -479,12 +478,7 @@ export namespace Config {
.boolean()
.optional()
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
autoupdate: z
.union([z.boolean(), z.literal("notify")])
.optional()
.describe(
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
),
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
enabled_providers: z
.array(z.string())
+1 -1
View File
@@ -1,4 +1,4 @@
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "@/util/error"
import matter from "gray-matter"
import { z } from "zod"
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { Log } from "../util/log"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
+1 -1
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
+1 -1
View File
@@ -30,7 +30,7 @@ await Promise.all([
fs.mkdir(Global.Path.bin, { recursive: true }),
])
const CACHE_VERSION = "11"
const CACHE_VERSION = "9"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()
+1 -1
View File
@@ -1,6 +1,6 @@
import { spawn } from "bun"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import { Log } from "../util/log"
import { Bus } from "../bus"
+1 -1
View File
@@ -9,7 +9,7 @@ import { UpgradeCommand } from "./cli/cmd/upgrade"
import { ModelsCommand } from "./cli/cmd/models"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "./util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { DebugCommand } from "./cli/cmd/debug"
+1 -7
View File
@@ -1,7 +1,7 @@
import path from "path"
import { $ } from "bun"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import { Bus } from "../bus"
import { Log } from "../util/log"
@@ -22,12 +22,6 @@ export namespace Installation {
version: z.string(),
}),
),
UpdateAvailable: Bus.event(
"installation.update-available",
z.object({
version: z.string(),
}),
),
}
export const Info = z
+1 -1
View File
@@ -6,7 +6,7 @@ import { LANGUAGE_EXTENSIONS } from "./language"
import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import { withTimeout } from "../util/timeout"
import { Instance } from "../project/instance"
+1 -1
View File
@@ -5,7 +5,7 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
import { withTimeout } from "@/util/timeout"
+1 -1
View File
@@ -28,7 +28,7 @@ export namespace Plugin {
}
const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push("opencode-copilot-auth@0.0.7")
plugins.push("opencode-copilot-auth@0.0.5")
plugins.push("opencode-anthropic-auth@0.0.2")
}
for (let plugin of plugins) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { map, filter, pipe, fromEntries, mapValues } from "remeda"
import z from "zod"
import { fn } from "@/util/fn"
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "@/util/error"
import { Auth } from "@/auth"
export namespace ProviderAuth {
+27 -57
View File
@@ -7,38 +7,15 @@ import { Log } from "../util/log"
import { BunProc } from "../bun"
import { Plugin } from "../plugin"
import { ModelsDev } from "./models"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
// Direct imports for bundled providers
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic"
import { createAzure } from "@ai-sdk/azure"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
export namespace Provider {
const log = Log.create({ service: "provider" })
const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
"@ai-sdk/amazon-bedrock": createAmazonBedrock,
"@ai-sdk/anthropic": createAnthropic,
"@ai-sdk/azure": createAzure,
"@ai-sdk/google": createGoogleGenerativeAI,
"@ai-sdk/google-vertex": createVertex,
"@ai-sdk/google-vertex/anthropic": createVertexAnthropic,
"@ai-sdk/openai": createOpenAI,
"@ai-sdk/openai-compatible": createOpenAICompatible,
"@openrouter/ai-sdk-provider": createOpenRouter,
}
type CustomLoader = (provider: ModelsDev.Provider) => Promise<{
autoload: boolean
getModel?: (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
@@ -516,6 +493,12 @@ export namespace Provider {
continue
}
// TODO: set this in models.dev, not set due to breaking issues on older OC versions
// u have to set include usage to true w/ this provider, setting in models.dev would cause undefined issue when accessing usage in older versions
if (providerID === "openrouter") {
provider.info.npm = "@openrouter/ai-sdk-provider"
}
log.info("found", { providerID, npm: provider.info.npm })
}
@@ -547,6 +530,24 @@ export namespace Provider {
const existing = s.sdk.get(key)
if (existing) return existing
let installedPath: string
if (!pkg.startsWith("file://")) {
installedPath = await BunProc.install(pkg, "latest")
} else {
log.info("loading local provider", { pkg })
installedPath = pkg
}
// The `google-vertex-anthropic` provider points to the `@ai-sdk/google-vertex` package.
// Ref: https://github.com/sst/models.dev/blob/0a87de42ab177bebad0620a889e2eb2b4a5dd4ab/providers/google-vertex-anthropic/provider.toml
// However, the actual export is at the subpath `@ai-sdk/google-vertex/anthropic`.
// Ref: https://ai-sdk.dev/providers/ai-sdk-providers/google-vertex#google-vertex-anthropic-provider-usage
// In addition, Bun's dynamic import logic does not support subpath imports,
// so we patch the import path to load directly from `dist`.
const modPath =
provider.id === "google-vertex-anthropic" ? `${installedPath}/dist/anthropic/index.mjs` : installedPath
const mod = await import(modPath)
const customFetch = options["fetch"]
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
@@ -570,30 +571,6 @@ export namespace Provider {
timeout: false,
})
}
// Special case: google-vertex-anthropic uses a subpath import
const bundledKey = provider.id === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : pkg
const bundledFn = BUNDLED_PROVIDERS[bundledKey]
if (bundledFn) {
log.info("using bundled provider", { providerID: provider.id, pkg: bundledKey })
const loaded = bundledFn({
name: provider.id,
...options,
})
s.sdk.set(key, loaded)
return loaded as SDK
}
let installedPath: string
if (!pkg.startsWith("file://")) {
installedPath = await BunProc.install(pkg, "latest")
} else {
log.info("loading local provider", { pkg })
installedPath = pkg
}
const mod = await import(installedPath)
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn({
name: provider.id,
@@ -694,7 +671,7 @@ export namespace Provider {
if (providerID === "github-copilot") {
priority = priority.filter((m) => m !== "claude-haiku-4.5")
}
if (providerID.startsWith("opencode")) {
if (providerID === "opencode" || providerID === "local") {
priority = ["gpt-5-nano"]
}
for (const item of priority) {
@@ -703,14 +680,7 @@ export namespace Provider {
}
}
}
// Check if opencode provider is available before using it
const opencodeProvider = await state().then((state) => state.providers["opencode"])
if (opencodeProvider && opencodeProvider.info.models["gpt-5-nano"]) {
return getModel("opencode", "gpt-5-nano")
}
return undefined
return getModel("opencode", "gpt-5-nano")
}
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
@@ -272,12 +272,4 @@ export namespace ProviderTransform {
return schema
}
export function error(providerID: string, message: string) {
if (providerID === "github-copilot" && message.includes("The requested model is not supported")) {
message +=
"\n\nMake sure the model is enabled in your copilot settings: https://github.com/settings/copilot/features"
}
return message
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { Session } from "../session"
import z from "zod"
import { Provider } from "../provider/provider"
import { mapValues } from "remeda"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config"
+2 -4
View File
@@ -1,6 +1,6 @@
import z from "zod"
import { Bus } from "../bus"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
import { Message } from "./message"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { Identifier } from "../id/id"
@@ -8,7 +8,6 @@ import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
import { Storage } from "@/storage/storage"
import { ProviderTransform } from "@/provider/transform"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
@@ -738,10 +737,9 @@ export namespace MessageV2 {
{ cause: e },
).toObject()
case APICallError.isInstance(e):
const message = ProviderTransform.error(ctx.providerID, e.message)
return new MessageV2.APIError(
{
message,
message: e.message,
statusCode: e.statusCode,
isRetryable: e.isRetryable,
responseHeaders: e.responseHeaders,
+1 -1
View File
@@ -1,5 +1,5 @@
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "../util/error"
export namespace Message {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
+21 -31
View File
@@ -43,7 +43,7 @@ import { Command } from "../command"
import { $, fileURLToPath } from "bun"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "@/util/error"
import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
@@ -197,7 +197,7 @@ export namespace SessionPrompt {
const message = await createUserMessage(input)
await Session.touch(input.sessionID)
if (input.noReply === true) {
if (input.noReply) {
return message
}
@@ -534,7 +534,7 @@ export namespace SessionPrompt {
}
},
headers: {
...(model.providerID.startsWith("opencode")
...(model.providerID === "opencode"
? {
"x-opencode-session": sessionID,
"x-opencode-request": lastUser.id,
@@ -907,32 +907,22 @@ export namespace SessionPrompt {
extra: { bypassCwdCheck: true, ...info.model },
metadata: async () => {},
})
pieces.push({
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: result.output,
})
if (result.attachments?.length) {
pieces.push(
...result.attachments.map((attachment) => ({
...attachment,
synthetic: true,
filename: attachment.filename ?? part.filename,
messageID: info.id,
sessionID: input.sessionID,
})),
)
} else {
pieces.push({
pieces.push(
{
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: result.output,
},
{
...part,
id: part.id ?? Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
})
}
},
)
})
.catch((error) => {
log.error("failed to read file", { error })
@@ -1408,8 +1398,7 @@ export namespace SessionPrompt {
input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
.length === 1
if (!isFirst) return
const small =
(await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
const small = await Provider.getSmallModel(input.providerID)
const options = pipe(
{},
mergeDeep(ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", input.session.id)),
@@ -1417,8 +1406,7 @@ export namespace SessionPrompt {
mergeDeep(small.info.options),
)
await generateText({
// use higher # for reasoning models since reasoning tokens eat up a lot of the budget
maxOutputTokens: small.info.reasoning ? 3000 : 20,
maxOutputTokens: small.info.reasoning ? 1500 : 20,
providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options),
messages: [
...SystemPrompt.title(small.providerID).map(
@@ -1428,8 +1416,10 @@ export namespace SessionPrompt {
}),
),
{
role: "user",
content: "Generate a title for this conversation:\n",
role: "user" as const,
content: `
The following is the text to summarize:
`,
},
...MessageV2.toModelMessage([
{
+6 -16
View File
@@ -1,29 +1,19 @@
You are a title generator. You output ONLY a thread title. Nothing else.
<task>
Generate a brief title that would help the user find this conversation later.
Follow all rules in <rules>
Use the <examples> so you know what a good title looks like.
Your output must be:
- A single line
- ≤50 characters
- No explanations
Convert the user message into a thread title.
Output: Single line, ≤50 chars, no explanations.
</task>
<rules>
- Focus on the main topic or question the user needs to retrieve
- Use -ing verbs for actions (Debugging, Implementing, Analyzing)
- Keep exact: technical terms, numbers, filenames, HTTP codes
- Remove: the, this, my, a, an
- Never assume tech stack
- Never use tools
- NEVER respond to questions, just generate a title for the conversation
- The title should NEVER include "summarizing" or "generating" when generating a title
- NEVER respond to message content—only extract title
- NEVER say "summarizing"
- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT
- Always output something meaningful, even if the input is minimal.
- If the user message is short or conversational (e.g. “hello”, “lol”, “whats up”, “hey”):
→ create a title that reflects the users tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
</rules>
<examples>
@@ -31,6 +21,6 @@ Your output must be:
"refactor user service" → Refactoring user service
"why is app.js failing" → Analyzing app.js failure
"implement rate limiting" → Implementing rate limiting
"how do I connect postgres to my API" → Connecting Postgres to API
"best practices for React hooks" → React hooks best practices
</examples>
Output the title now:

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