Compare commits

..
61 Commits
Author SHA1 Message Date
opencode 502e85b903 release: v1.0.107
Update Nix Hashes / update (push) Has been cancelled
2025-11-24 07:06:41 +00:00
Aiden Cline ac1e2bfd49 bump copilot plugin, give better error message for copilot (#4678) 2025-11-24 00:40:15 -06:00
Aiden Cline b9b071c744 fix: fatal: undefined is not an object (evaluating 'color.buffer') 2025-11-23 23:56:15 -06:00
GitHub Action 83186b6fed chore: format code 2025-11-24 04:55:13 +00:00
a3a239967f nix: bundle js dist with bun and patch tree-sitter wasm paths (#4644)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Github Action <action@github.com>
2025-11-23 22:54:29 -06:00
Huang QiandAiden Cline b4fd4bb257 fix: add explicit fallback model and prevent direct opencode provider calls (#4653)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-23 22:51:15 -06:00
GitHub Action eb009d5959 chore: format code 2025-11-23 20:59:37 +00:00
Frank 4a81bd0f50 wip: zen 2025-11-23 15:58:55 -05:00
Frank bbc9142fc5 wip: zen 2025-11-23 15:21:47 -05:00
Aiden Cline 7413c2715c tweak: slight improvements to title gen 2025-11-23 14:04:39 -06:00
Dax Raad 7579c3bb15 ci: remove log 2025-11-23 15:00:29 -05:00
Dax Raad 24683058fd ci: ignore 2025-11-23 14:56:24 -05:00
Dax Raad bf81e3108c ci: ignore 2025-11-23 14:56:24 -05:00
Dax Raad 5ade90416e ci: ignore 2025-11-23 14:44:05 -05:00
GitHub Action 8f2a8086c0 chore: format code 2025-11-23 19:37:19 +00:00
Dax Raad 38b70f7877 ci: secret 2025-11-23 14:36:36 -05:00
Dax Raad 5e112a17a5 token 2025-11-23 19:35:39 +00:00
Dax Raad 59a3e7e3cc ci: ignore 2025-11-23 19:35:39 +00:00
Github Action 2c93f065cb Update Nix flake.lock and hashes 2025-11-23 19:35:39 +00:00
opencode 488d33c1ed release: v1.0.106
Update Nix Hashes / update (push) Has been cancelled
2025-11-23 19:35:38 +00:00
Dax Raad de4660ac12 ci: ignore 2025-11-23 14:28:10 -05:00
Aiden Cline 27ae341684 fix bash tool wsl 2025-11-23 12:29:15 -06:00
Github Action 25b3846694 Update Nix flake.lock and hashes 2025-11-23 17:57:34 +00:00
Dax Raad 8e2f9f6544 ci: stuff 2025-11-23 12:55:33 -05:00
Github Action 76b5870f89 Update Nix flake.lock and hashes 2025-11-23 17:45:24 +00:00
Dax Raad 604891e793 ci: stuff 2025-11-23 12:43:23 -05:00
Dax Raad 5814df7eaa sync 2025-11-23 12:43:23 -05:00
Aiden Cline 2509d03f42 tweak: fix bool 2025-11-23 11:43:09 -06:00
Github Action a256df9823 Update Nix flake.lock and hashes 2025-11-23 17:09:33 +00:00
Dax Raad af96ec5a30 ignore: update @solidjs/start dependency and fix console redirect handling
Updates the @solidjs/start dependency to latest version and removes deprecated getResponseHeaders usage from auth callback. Also adds error handling for workspace ID lookup to prevent redirect failures.
2025-11-23 12:07:28 -05:00
GitHub Action 55df80b80e ignore: update download stats 2025-11-23 2025-11-23 12:04:01 +00:00
7d11986a0a feature: optional selectedListItemText element in themes and luminance-based fallback to solve 4369 (#4572)
Co-authored-by: knanao <nao.7ken@gmail.com>
Co-authored-by: knanao <k@knanao.com>
2025-11-23 01:51:07 -06:00
Aiden Cline d75d90c53e ci: fix action 2025-11-23 01:36:45 -06:00
Tyler Limbach 35fead2eca fix: make tui session text respect theme fg (#4618) (#4620) 2025-11-23 01:34:31 -06:00
Github Action 9bb2efd3ef Update Nix flake.lock and hashes 2025-11-23 07:31:17 +00:00
Aiden Cline 30ffcaa667 tweak: start bundling in some ai-sdk packages (#4649) 2025-11-23 01:29:20 -06:00
ba11455786 fix: prevent permission shortcuts when ctrl/meta pressed (#4631)
Co-authored-by: terakael <terakael@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-22 23:58:10 -06:00
Dax Raad d69ba27f84 fix suspense 2025-11-23 00:16:06 -05:00
Dax Raad 448b72d046 fix logout 2025-11-23 00:12:58 -05:00
Dax Raad d96d89bcb8 fix logout 2025-11-22 23:59:50 -05:00
Dax Raad 7a013d4f40 fixed 2025-11-22 23:56:37 -05:00
GitHub Action e4597df3b9 chore: format code 2025-11-23 04:54:26 +00:00
Dax Raad 2b014fcd75 fix auth 2025-11-22 23:53:41 -05:00
Github Action c2bf6975f8 Update Nix flake.lock and hashes 2025-11-23 00:44:43 +00:00
Sebastian Herrlinger 733e1f79ac bump opentui to v0.1.49
- fix stdin handling for suspend/resume
- fix textarea cursor movement segfault for wcdwidth (alacritty/tmux)
2025-11-23 01:42:33 +01:00
GitHub Action 2a6cbfd5fd chore: format code 2025-11-23 00:20:07 +00:00
Adam 6173b69a8b wip(share): more styling 2025-11-22 18:19:02 -06:00
opencode fc72cfe784 release: v1.0.105
Update Nix Hashes / update (push) Has been cancelled
2025-11-22 22:39:18 +00:00
Dax Raad 768c81cdfd core: prevent concurrent bun package installs that could cause corruption or conflicts 2025-11-22 17:32:53 -05:00
Dax Raad bcea8ed593 tui: fix message completion timing and duration display in session view 2025-11-22 17:28:50 -05:00
GitHub Action f93bb1dd21 chore: format code 2025-11-22 22:27:23 +00:00
b92e8510f9 fix: auto upgrade toast message (#4625)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: opencode <opencode@sst.dev>
2025-11-22 16:26:46 -06:00
opencode 1b692ec7eb release: v1.0.104
Update Nix Hashes / update (push) Has been cancelled
2025-11-22 20:03:20 +00:00
Dax Raad 4c97e2e8bb reset cache 2025-11-22 14:56:35 -05:00
Dax Raad b652198979 load desktop app 2025-11-22 14:40:11 -05:00
Dax Raad 31c4a1d853 docs: add Docker installation option for OpenCode 2025-11-22 14:13:04 -05:00
Goni Zahavy 6afdb5c0e5 tui: added ctrl-z terminal suspension support (#3983) 2025-11-22 12:48:23 -06:00
bdcf864678 proper fix for image attachments (#4562)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-22 12:47:57 -06:00
opencode 0dd5039668 release: v1.0.103
Update Nix Hashes / update (push) Has been cancelled
2025-11-22 18:21:49 +00:00
Dax Raad a0831eade1 tui: remove bullet prefix from timestamps in session view for cleaner display 2025-11-22 13:11:08 -05:00
GitHub Action f1dc9818b6 chore: format code 2025-11-22 18:04:34 +00:00
93 changed files with 1633 additions and 1319 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Check for duplicate issues - name: Check for duplicate issues
env: env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: | OPENCODE_PERMISSION: |
{ {
+1
View File
@@ -148,3 +148,4 @@
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) | | 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-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-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) |
+97 -430
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -116,6 +116,8 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
// CONSOLE // 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_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") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
@@ -132,6 +134,7 @@ new sst.cloudflare.x.SolidStart("Console", {
domain, domain,
path: "packages/console/app", path: "packages/console/app",
link: [ link: [
bucket,
database, database,
AUTH_API_URL, AUTH_API_URL,
STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET,
+17
View File
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,4 @@
export const SECRET = {
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
}
+40
View File
@@ -0,0 +1,40 @@
#!/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-N33FQyKF6IgGIRZ8NFd9o1/sjHMwbQ6KQcnMFyN0WmI=" "nodeModules": "sha256-/ZkyVHgRMjhzBpnDNTR6X+TomtTMarVU7gmq9Z8Czr8="
} }
+60 -35
View File
@@ -1,4 +1,4 @@
{ lib, stdenv, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }: { lib, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
args: args:
let let
scripts = args.scripts; scripts = args.scripts;
@@ -28,64 +28,89 @@ stdenvNoCC.mkDerivation (finalAttrs: {
makeBinaryWrapper makeBinaryWrapper
]; ];
configurePhase = ''
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
runHook postConfigure
'';
env.MODELS_DEV_API_JSON = args.modelsDev; env.MODELS_DEV_API_JSON = args.modelsDev;
env.OPENCODE_VERSION = args.version; env.OPENCODE_VERSION = args.version;
env.OPENCODE_CHANNEL = "stable"; env.OPENCODE_CHANNEL = "stable";
dontConfigure = true;
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
cp ${scripts + "/bun-build.ts"} bun-build.ts cp -r ${finalAttrs.node_modules}/node_modules .
cp -r ${finalAttrs.node_modules}/packages .
substituteInPlace bun-build.ts \ (
--replace '@VERSION@' "${finalAttrs.version}" cd packages/opencode
export BUN_COMPILE_TARGET=${args.target} chmod -R u+w ./node_modules
bun --bun bun-build.ts 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
)
runHook postBuild runHook postBuild
''; '';
dontStrip = true;
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
cd packages/opencode cd packages/opencode
if [ ! -f opencode ]; then if [ ! -d dist ]; then
echo "ERROR: opencode binary not found in $(pwd)" echo "ERROR: dist directory missing after bundle step"
ls -la
exit 1
fi
if [ ! -f opencode-worker.js ]; then
echo "ERROR: opencode worker bundle not found in $(pwd)"
ls -la
exit 1 exit 1
fi fi
install -Dm755 opencode $out/bin/opencode mkdir -p $out/lib/opencode
install -Dm644 opencode-worker.js $out/bin/opencode-worker.js cp -r dist $out/lib/opencode/
if [ -f opencode-assets.manifest ]; then chmod -R u+w $out/lib/opencode/dist
while IFS= read -r asset; do
[ -z "$asset" ] && continue # Select bundled worker assets deterministically (sorted find output)
if [ ! -f "$asset" ]; then worker_file=$(find "$out/lib/opencode/dist" -type f \( -path '*/tui/worker.*' -o -name 'worker.*' \) | sort | head -n1)
echo "ERROR: referenced asset \"$asset\" missing" parser_worker_file=$(find "$out/lib/opencode/dist" -type f -name 'parser.worker.*' | sort | head -n1)
exit 1 if [ -z "$worker_file" ]; then
fi echo "ERROR: bundled worker not found"
install -Dm644 "$asset" "$out/bin/$(basename "$asset")" exit 1
done < opencode-assets.manifest
fi 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 runHook postInstall
''; '';
postFixup = '' postInstall = ''
wrapProgram "$out/bin/opencode" --prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]} 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
''; '';
meta = { meta = {
+24 -7
View File
@@ -24,15 +24,13 @@ for (const entry of directories) {
if (!info.isDirectory()) { if (!info.isDirectory()) {
continue continue
} }
const marker = entry.lastIndexOf("@") const parsed = parseEntry(entry)
if (marker <= 0) { if (!parsed) {
continue continue
} }
const slug = entry.slice(0, marker).replace(/\+/g, "/") const list = versions.get(parsed.name) ?? []
const version = entry.slice(marker + 1) list.push({ dir: full, version: parsed.version, label: entry })
const list = versions.get(slug) ?? [] versions.set(parsed.name, list)
list.push({ dir: full, version, label: entry })
versions.set(slug, list)
} }
const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
@@ -79,6 +77,12 @@ for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0]
await mkdir(parent, { recursive: true }) await mkdir(parent, { recursive: true })
const linkPath = join(parent, leaf) const linkPath = join(parent, leaf)
const desired = join(entry.dir, "node_modules", slug) 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 relativeTarget = relative(parent, desired)
const resolved = relativeTarget.length === 0 ? "." : relativeTarget const resolved = relativeTarget.length === 0 ? "." : relativeTarget
await rm(linkPath, { recursive: true, force: true }) await rm(linkPath, { recursive: true, force: true })
@@ -94,3 +98,16 @@ for (const line of rewrites.slice(0, 20)) {
if (rewrites.length > 20) { if (rewrites.length > 20) {
console.log(" ...") 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
@@ -0,0 +1,39 @@
#!/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)
+1 -4
View File
@@ -48,7 +48,7 @@
"vite": "7.1.4", "vite": "7.1.4",
"@solidjs/meta": "0.29.4", "@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dbff19d", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10" "vite-plugin-solid": "2.11.10"
} }
@@ -82,9 +82,6 @@
"tree-sitter-bash", "tree-sitter-bash",
"web-tree-sitter" "web-tree-sitter"
], ],
"patchedDependencies": {
"@solidjs/start@1.1.7": "patches/@solidjs%2Fstart@1.1.7.patch"
},
"overrides": { "overrides": {
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:" "@types/node": "catalog:"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.0.102", "version": "1.0.107",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
@@ -19,6 +19,7 @@ export async function GET(input: APIEvent) {
return { return {
...value, ...value,
account: { account: {
...value.account,
[id]: { [id]: {
id, id,
email: decoded.subject.properties.email, email: decoded.subject.properties.email,
@@ -0,0 +1,17 @@
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")
}
@@ -0,0 +1,7 @@
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)
}
@@ -12,6 +12,7 @@
[data-slot="item"] { [data-slot="item"] {
color: var(--color-danger); color: var(--color-danger);
text-decoration: none;
} }
} }
} }
@@ -1,4 +1,4 @@
import { action, redirect } from "@solidjs/router" import { action } from "@solidjs/router"
import { getRequestEvent } from "solid-js/web" import { getRequestEvent } from "solid-js/web"
import { useAuthSession } from "~/context/auth.session" import { useAuthSession } from "~/context/auth.session"
import { Dropdown } from "~/component/dropdown" import { Dropdown } from "~/component/dropdown"
@@ -17,18 +17,15 @@ const logout = action(async () => {
event!.locals.actor = undefined event!.locals.actor = undefined
return val return val
}) })
throw redirect("/zen") }, "auth.logout")
})
export function UserMenu(props: { email: string | null | undefined }) { export function UserMenu(props: { email: string | null | undefined }) {
return ( return (
<div data-component="user-menu"> <div data-component="user-menu">
<Dropdown trigger={props.email ?? ""} align="right"> <Dropdown trigger={props.email ?? ""} align="right">
<form action={logout} method="post"> <a href="/auth/logout" data-slot="item">
<button type="submit" formaction={logout} data-slot="item"> Logout
Logout </a>
</button>
</form>
</Dropdown> </Dropdown>
</div> </div>
) )
@@ -21,12 +21,12 @@ import { getLastSeenWorkspaceID } from "../workspace/common"
const checkLoggedIn = query(async () => { const checkLoggedIn = query(async () => {
"use server" "use server"
const workspaceID = await getLastSeenWorkspaceID() const workspaceID = await getLastSeenWorkspaceID().catch(() => {})
if (workspaceID) throw redirect(`/workspace/${workspaceID}`) if (workspaceID) throw redirect(`/workspace/${workspaceID}`)
}, "checkLoggedIn.get") }, "checkLoggedIn.get")
export default function Home() { export default function Home() {
createAsync(() => checkLoggedIn()) const loggedin = createAsync(() => checkLoggedIn())
return ( return (
<main data-page="zen"> <main data-page="zen">
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/} {/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
@@ -35,6 +35,7 @@ export default function Home() {
<Link rel="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 property="og:image" content="/social-share-zen.png" />
<Meta name="twitter: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"> <div data-component="container">
<Header zen /> <Header zen />
@@ -0,0 +1,26 @@
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,6 +19,7 @@ import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai" import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible" import { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter } from "./rateLimiter" import { createRateLimiter } from "./rateLimiter"
import { createDataDumper } from "./dataDumper"
type ZenData = Awaited<ReturnType<typeof ZenData.list>> type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = { type RetryOptions = {
@@ -48,16 +49,19 @@ export async function handler(
try { try {
const url = input.request.url const url = input.request.url
const body = await input.request.json() const body = await input.request.json()
const ip = input.request.headers.get("x-real-ip") ?? ""
const model = opts.parseModel(url, body) const model = opts.parseModel(url, body)
const isStream = opts.parseIsStream(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({ logger.metric({
is_tream: isStream, is_tream: isStream,
session: input.request.headers.get("x-opencode-session"), session: sessionId,
request: input.request.headers.get("x-opencode-request"), request: requestId,
}) })
const zenData = ZenData.list() const zenData = ZenData.list()
const modelInfo = validateModel(zenData, model) const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId)
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip) const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
await rateLimiter?.check() await rateLimiter?.check()
@@ -104,10 +108,14 @@ export async function handler(
}) })
} }
return { providerInfo, authInfo, res, startTimestamp } return { providerInfo, authInfo, reqBody, res, startTimestamp }
} }
const { providerInfo, authInfo, res, startTimestamp } = await retriableRequest() const { providerInfo, authInfo, reqBody, res, startTimestamp } = await retriableRequest()
// Store model request
dataDumper?.provideModel(providerInfo.storeModel)
dataDumper?.provideRequest(reqBody)
// Scrub response headers // Scrub response headers
const resHeaders = new Headers() const resHeaders = new Headers()
@@ -126,6 +134,8 @@ export async function handler(
const body = JSON.stringify(responseConverter(json)) const body = JSON.stringify(responseConverter(json))
logger.metric({ response_length: body.length }) logger.metric({ response_length: body.length })
logger.debug("RESPONSE: " + body) logger.debug("RESPONSE: " + body)
dataDumper?.provideResponse(body)
dataDumper?.flush()
await rateLimiter?.track() await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, json.usage) await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
await reload(authInfo) await reload(authInfo)
@@ -155,6 +165,7 @@ export async function handler(
response_length: responseLength, response_length: responseLength,
"timestamp.last_byte": Date.now(), "timestamp.last_byte": Date.now(),
}) })
dataDumper?.flush()
await rateLimiter?.track() await rateLimiter?.track()
const usage = usageParser.retrieve() const usage = usageParser.retrieve()
if (usage) { if (usage) {
@@ -174,6 +185,7 @@ export async function handler(
} }
responseLength += value.length responseLength += value.length
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
dataDumper?.provideStream(buffer)
const parts = buffer.split(providerInfo.streamSeparator) const parts = buffer.split(providerInfo.streamSeparator)
buffer = parts.pop() ?? "" buffer = parts.pop() ?? ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.102", "version": "1.0.107",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+1
View File
@@ -32,6 +32,7 @@ export namespace ZenData {
model: z.string(), model: z.string(),
weight: z.number().optional(), weight: z.number().optional(),
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
storeModel: z.string().optional(),
}), }),
), ),
}) })
+10
View File
@@ -74,6 +74,14 @@ declare module "sst" {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
} }
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: { STRIPE_SECRET_KEY: {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
@@ -112,6 +120,8 @@ declare module "sst" {
AuthApi: cloudflare.Service AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service LogProcessor: cloudflare.Service
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.102", "version": "1.0.107",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+10
View File
@@ -74,6 +74,14 @@ declare module "sst" {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
} }
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: { STRIPE_SECRET_KEY: {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
@@ -112,6 +120,8 @@ declare module "sst" {
AuthApi: cloudflare.Service AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service LogProcessor: cloudflare.Service
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.102", "version": "1.0.107",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -1,4 +1,5 @@
import { env } from "cloudflare:workers" import { env } from "cloudflare:workers"
export { waitUntil } from "cloudflare:workers"
export const Resource = new Proxy( export const Resource = new Proxy(
{}, {},
+52 -40
View File
@@ -2,54 +2,66 @@ import type { KVNamespaceListOptions, KVNamespaceListResult, KVNamespacePutOptio
import { Resource as ResourceBase } from "sst" import { Resource as ResourceBase } from "sst"
import Cloudflare from "cloudflare" import Cloudflare from "cloudflare"
export const waitUntil = async (fn: () => Promise<void>) => {
await fn()
}
export const Resource = new Proxy( export const Resource = new Proxy(
{}, {},
{ {
get(_target, prop: keyof typeof ResourceBase) { get(_target, prop: keyof typeof ResourceBase) {
const value = ResourceBase[prop] const value = ResourceBase[prop]
// @ts-ignore if ("type" in value) {
if ("type" in value && value.type === "sst.cloudflare.Kv") {
const client = new Cloudflare({
apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
})
// @ts-ignore // @ts-ignore
const namespaceId = value.namespaceId if (value.type === "sst.cloudflare.Bucket") {
const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value return {
return { put: async () => {},
get: (k: string | string[]) => { }
const isMulti = Array.isArray(k) }
return client.kv.namespaces // @ts-ignore
.bulkGet(namespaceId, { if (value.type === "sst.cloudflare.Kv") {
keys: Array.isArray(k) ? k : [k], 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, {
account_id: accountId, account_id: accountId,
}) value: v,
.then((result) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k])) expiration: opts?.expiration,
}, expiration_ttl: opts?.expirationTtl,
put: (k: string, v: string, opts?: KVNamespacePutOptions) => metadata: opts?.metadata,
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,
}
}), }),
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,
}
}),
}
} }
} }
return value return value
+10
View File
@@ -74,6 +74,14 @@ declare module "sst" {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
} }
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: { STRIPE_SECRET_KEY: {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
@@ -112,6 +120,8 @@ declare module "sst" {
AuthApi: cloudflare.Service AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service LogProcessor: cloudflare.Service
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.0.102", "version": "1.0.107",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
+3 -3
View File
@@ -5,7 +5,7 @@ import { useSync } from "./sync"
import { makePersisted } from "@solid-primitives/storage" import { makePersisted } from "@solid-primitives/storage"
import { TextSelection } from "./local" import { TextSelection } from "./local"
import { pipe, sumBy } from "remeda" import { pipe, sumBy } from "remeda"
import { AssistantMessage } from "@opencode-ai/sdk" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@/utils" import { base64Encode } from "@/utils"
@@ -123,8 +123,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
user: userMessages, user: userMessages,
last: lastUserMessage, last: lastUserMessage,
active: activeMessage, active: activeMessage,
setActive(id: string | undefined) { setActive(message: UserMessage | undefined) {
setStore("messageId", id) setStore("messageId", message?.id)
}, },
}, },
usage: { usage: {
+24 -9
View File
@@ -1,4 +1,4 @@
import { For, onCleanup, onMount, Show, Match, Switch, createResource } from "solid-js" import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
import { useLocal, type LocalFile } from "@/context/local" import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils" import { getDirectory, getFilename } from "@/utils"
@@ -12,7 +12,8 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { Code } from "@opencode-ai/ui/code" import { Code } from "@opencode-ai/ui/code"
import { SessionTimeline } from "@opencode-ai/ui/session-timeline" import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/ui/session-review"
import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import { import {
@@ -255,6 +256,8 @@ export default function Page() {
return typeof draggable.id === "string" ? draggable.id : undefined return typeof draggable.id === "string" ? draggable.id : undefined
} }
const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
return ( return (
<div class="relative bg-background-base size-full overflow-x-hidden"> <div class="relative bg-background-base size-full overflow-x-hidden">
<DragDropProvider <DragDropProvider
@@ -330,14 +333,26 @@ export default function Page() {
flex: layout.review.state() === "pane", 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-xl 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-2xl mx-auto">
<Switch> <Switch>
<Match when={session.id}> <Match when={session.id}>
<SessionTimeline <div class="flex items-start justify-start h-full min-h-0">
sessionID={session.id!} <Show when={session.messages.user().length > 1}>
expanded={layout.review.state() === "tab" || !session.diffs().length} <MessageNav
classes={{ root: "pb-20", container: "pb-20" }} classList={{ "mt-1.5 mr-3": 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>
</Match> </Match>
<Match when={true}> <Match when={true}>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch"> <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
@@ -456,7 +471,7 @@ export default function Page() {
</DragOverlay> </DragOverlay>
</DragDropProvider> </DragDropProvider>
<Show when={session.layout.tabs.active}> <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-6"> <div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<PromptInput <PromptInput
ref={(el) => { ref={(el) => {
inputRef = el inputRef = el
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.0.102", "version": "1.0.107",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -24,6 +24,7 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
@@ -1,5 +1,5 @@
import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk" import { FileDiff, Message, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk"
import { SessionTimeline } from "@opencode-ai/ui/session-timeline" import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider, useData } from "@opencode-ai/ui/context" import { DataProvider, useData } from "@opencode-ai/ui/context"
import { createAsync, query, RouteDefinition, useParams } from "@solidjs/router" import { createAsync, query, RouteDefinition, useParams } from "@solidjs/router"
@@ -10,6 +10,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { iife } from "@opencode-ai/util/iife" import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { createStore } from "solid-js/store"
const getData = query(async (sessionID) => { const getData = query(async (sessionID) => {
const data = await Share.data(sessionID) const data = await Share.data(sessionID)
@@ -40,7 +42,6 @@ const getData = query(async (sessionID) => {
message: {}, message: {},
part: {}, part: {},
} }
for (const item of data) { for (const item of data) {
switch (item.type) { switch (item.type) {
case "session": case "session":
@@ -82,14 +83,28 @@ export default function () {
<DataProvider data={data()}> <DataProvider data={data()}>
{iife(() => { {iife(() => {
const data = useData() const data = useData()
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const match = createMemo(() => Binary.search(data.session, params.sessionID!, (s) => s.id)) const match = createMemo(() => Binary.search(data.session, params.sessionID!, (s) => s.id))
if (!match().found) throw new Error(`Session ${params.sessionID} not found`) if (!match().found) throw new Error(`Session ${params.sessionID} not found`)
const info = createMemo(() => data.session[match().index]) const info = createMemo(() => data.session[match().index])
const firstUserMessage = createMemo(() => const messages = createMemo(() =>
data.message[params.sessionID!]?.filter((m) => m.role === "user")?.at(0), params.sessionID ? (data.message[params.sessionID]?.filter((m) => m.role === "user") ?? []) : [],
) )
const provider = createMemo(() => firstUserMessage()?.model?.providerID) const firstUserMessage = createMemo(() => messages().at(0))
const model = createMemo(() => firstUserMessage()?.model?.modelID) 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 model = createMemo(() => activeMessage()?.model?.modelID)
const diffs = createMemo(() => data.session_diff[params.sessionID!] ?? []) const diffs = createMemo(() => data.session_diff[params.sessionID!] ?? [])
return ( return (
@@ -145,15 +160,26 @@ export default function () {
</div> </div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div> <div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div> </div>
<SessionTimeline <div class="flex items-start justify-start h-full min-h-0">
sessionID={params.sessionID!} <Show when={messages().length > 1}>
classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }} <MessageNav
expanded classList={{ "mt-3 mr-3": true }}
> messages={messages()}
<div class="flex items-center justify-center pb-8 shrink-0"> current={activeMessage()}
<Logo class="w-58.5 opacity-12" /> onMessageSelect={setActiveMessage}
</div> size={!diffs().length ? "normal" : "compact"}
</SessionTimeline> />
</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>
</div>
</div> </div>
<Show when={diffs().length}> <Show when={diffs().length}>
<div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base"> <div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base">
+131
View File
@@ -0,0 +1,131 @@
/* 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, "allowJs": true,
"noEmit": true, "noEmit": true,
"strict": true, "strict": true,
"types": ["vite/client"], "types": ["@cloudflare/workers-types", "vite/client"],
"isolatedModules": true, "isolatedModules": true,
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": ["./src/*"]
+1 -1
View File
@@ -3,7 +3,7 @@ import { solidStart } from "@solidjs/start/config"
import { nitro } from "nitro/vite" import { nitro } from "nitro/vite"
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
const nitroConfig = (() => { const nitroConfig: any = (() => {
const target = process.env.OPENCODE_DEPLOYMENT_TARGET const target = process.env.OPENCODE_DEPLOYMENT_TARGET
if (target === "cloudflare") { if (target === "cloudflare") {
return { return {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The AI coding agent built for the terminal" description = "The AI coding agent built for the terminal"
version = "1.0.102" version = "1.0.107"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/sst/opencode" repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.102/opencode-darwin-arm64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.107/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.102/opencode-darwin-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.107/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.102/opencode-linux-arm64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.107/opencode-linux-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.102/opencode-linux-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.107/opencode-linux-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.102/opencode-windows-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.107/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.102", "version": "1.0.107",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+10
View File
@@ -74,6 +74,14 @@ declare module "sst" {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
} }
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: { STRIPE_SECRET_KEY: {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
@@ -112,6 +120,8 @@ declare module "sst" {
AuthApi: cloudflare.Service AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service LogProcessor: cloudflare.Service
} }
+11 -5
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.0.102", "version": "1.0.107",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -18,8 +18,6 @@
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
"devDependencies": { "devDependencies": {
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/google-vertex": "3.0.16",
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
"@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -44,7 +42,14 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.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/mcp": "0.0.8",
"@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
@@ -56,8 +61,9 @@
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@opentui/core": "0.1.47", "@openrouter/ai-sdk-provider": "1.2.5",
"@opentui/solid": "0.1.47", "@opentui/core": "0.1.49",
"@opentui/solid": "0.1.49",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:", "@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
+24
View File
@@ -4,9 +4,12 @@ import { Log } from "../util/log"
import path from "path" import path from "path"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { readableStreamToText } from "bun" import { readableStreamToText } from "bun"
import { createRequire } from "module"
import { Lock } from "../util/lock"
export namespace BunProc { export namespace BunProc {
const log = Log.create({ service: "bun" }) 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>) { export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
log.info("running", { log.info("running", {
@@ -58,6 +61,9 @@ export namespace BunProc {
) )
export async function install(pkg: string, version = "latest") { 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 mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json")) const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
const parsed = await pkgjson.json().catch(async () => { const parsed = await pkgjson.json().catch(async () => {
@@ -125,4 +131,22 @@ export namespace BunProc {
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2)) await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
return mod 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
}
}
} }
+24
View File
@@ -342,6 +342,21 @@ function App() {
dialog.clear() 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(() => { createEffect(() => {
@@ -411,6 +426,15 @@ 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 ( return (
<box <box
width={dimensions().width} width={dimensions().width}
@@ -5,7 +5,7 @@ import { createMemo, createResource, createEffect, onMount, For, Show } from "so
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme" import { useTheme, selectedForeground } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border" import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command" import { useCommandDialog } from "@tui/component/dialog-command"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
@@ -455,7 +455,7 @@ export function Autocomplete(props: {
{...SplitBorder} {...SplitBorder}
borderColor={theme.border} borderColor={theme.border}
> >
<box backgroundColor={theme.backgroundElement} height={height()}> <box backgroundColor={theme.backgroundMenu} height={height()}>
<For <For
each={options()} each={options()}
fallback={ fallback={
@@ -471,11 +471,11 @@ export function Autocomplete(props: {
backgroundColor={index() === store.selected ? theme.primary : undefined} backgroundColor={index() === store.selected ? theme.primary : undefined}
flexDirection="row" flexDirection="row"
> >
<text fg={index() === store.selected ? theme.background : theme.text} flexShrink={0}> <text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option.display} {option.display}
</text> </text>
<Show when={option.description}> <Show when={option.description}>
<text fg={index() === store.selected ? theme.background : theme.textMuted} wrapMode="none"> <text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option.description} {option.description}
</text> </text>
</Show> </Show>
@@ -791,10 +791,17 @@ export function Prompt(props: PromptProps) {
height={1} height={1}
border={["bottom"]} border={["bottom"]}
borderColor={theme.backgroundElement} borderColor={theme.backgroundElement}
customBorderChars={{ customBorderChars={
...EmptyBorder, theme.background.a != 0
horizontal: "▀", ? {
}} ...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/> />
</box> </box>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
@@ -94,6 +94,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = agents().find((x) => x.name === name) const agent = agents().find((x) => x.name === name)
if (agent?.color) return RGBA.fromHex(agent.color) if (agent?.color) return RGBA.fromHex(agent.color)
const index = agents().findIndex((x) => x.name === name) const index = agents().findIndex((x) => x.name === name)
if (index === -1) return colors()[0]
return colors()[index % colors().length] return colors()[index % colors().length]
}, },
} }
@@ -33,7 +33,7 @@ import { createStore, produce } from "solid-js/store"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
type Theme = { type ThemeColors = {
primary: RGBA primary: RGBA
secondary: RGBA secondary: RGBA
accent: RGBA accent: RGBA
@@ -43,9 +43,11 @@ type Theme = {
info: RGBA info: RGBA
text: RGBA text: RGBA
textMuted: RGBA textMuted: RGBA
selectedListItemText: RGBA
background: RGBA background: RGBA
backgroundPanel: RGBA backgroundPanel: RGBA
backgroundElement: RGBA backgroundElement: RGBA
backgroundMenu: RGBA
border: RGBA border: RGBA
borderActive: RGBA borderActive: RGBA
borderSubtle: RGBA borderSubtle: RGBA
@@ -86,6 +88,27 @@ type Theme = {
syntaxPunctuation: RGBA 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 HexColor = `#${string}`
type RefName = string type RefName = string
type Variant = { type Variant = {
@@ -96,7 +119,10 @@ type ColorValue = HexColor | RefName | Variant | RGBA
type ThemeJson = { type ThemeJson = {
$schema?: string $schema?: string
defs?: Record<string, HexColor | RefName> defs?: Record<string, HexColor | RefName>
theme: Record<keyof Theme, ColorValue> theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
}
} }
export const DEFAULT_THEMES: Record<string, ThemeJson> = { export const DEFAULT_THEMES: Record<string, ThemeJson> = {
@@ -137,19 +163,44 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
if (defs[c]) { if (defs[c]) {
return resolveColor(defs[c]) return resolveColor(defs[c])
} else if (theme.theme[c as keyof Theme]) { } else if (theme.theme[c as keyof ThemeColors] !== undefined) {
return resolveColor(theme.theme[c as keyof Theme]) return resolveColor(theme.theme[c as keyof ThemeColors]!)
} else { } else {
throw new Error(`Color reference "${c}" not found in defs or theme`) throw new Error(`Color reference "${c}" not found in defs or theme`)
} }
} }
return resolveColor(c[mode]) return resolveColor(c[mode])
} }
return Object.fromEntries(
Object.entries(theme.theme).map(([key, value]) => { const resolved = Object.fromEntries(
return [key, resolveColor(value)] Object.entries(theme.theme)
}), .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu")
) as Theme .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
} }
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
@@ -288,11 +339,13 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
// Text colors // Text colors
text: fg, text: fg,
textMuted, textMuted,
selectedListItemText: bg,
// Background colors // Background colors
background: bg, background: bg,
backgroundPanel: grays[2], backgroundPanel: grays[2],
backgroundElement: grays[3], backgroundElement: grays[3],
backgroundMenu: grays[3],
// Border colors // Border colors
borderSubtle: grays[6], borderSubtle: grays[6],
@@ -177,6 +177,7 @@ export function Session() {
const first = permissions()[0] const first = permissions()[0]
if (first) { if (first) {
const response = iife(() => { const response = iife(() => {
if (evt.ctrl || evt.meta) return
if (evt.name === "return") return "once" if (evt.name === "return") return "once"
if (evt.name === "a") return "always" if (evt.name === "a") return "always"
if (evt.name === "d") return "reject" if (evt.name === "d") return "reject"
@@ -969,8 +970,8 @@ function UserMessage(props: {
fallback={ fallback={
<span style={{ fg: theme.textMuted }}> <span style={{ fg: theme.textMuted }}>
{ctx.showTimestamps() {ctx.showTimestamps()
? `· ${Locale.todayTimeOrDateTime(props.message.time.created)}` ? Locale.todayTimeOrDateTime(props.message.time.created)
: `· ${Locale.time(props.message.time.created)}`} : Locale.time(props.message.time.created)}
</span> </span>
} }
> >
@@ -999,7 +1000,12 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
const sync = useSync() const sync = useSync()
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) 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(() => { const duration = createMemo(() => {
if (!final()) return 0
if (!props.message.time.completed) return 0 if (!props.message.time.completed) return 0
const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID) const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID)
if (!user) return 0 if (!user) return 0
@@ -1038,21 +1044,13 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
</box> </box>
</Show> </Show>
<Switch> <Switch>
<Match <Match when={props.last || final()}>
when={
(props.message.time.completed &&
props.parts.some(
(item) => item.type === "step-finish" && !["tool-calls", "unknown"].includes(item.reason),
)) ||
props.last
}
>
<box paddingLeft={3}> <box paddingLeft={3}>
<text marginTop={1}> <text marginTop={1}>
<span style={{ fg: local.agent.color(props.message.mode) }}></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.text }}>{Locale.titlecase(props.message.mode)}</span>{" "}
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span> <span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
<Show when={props.message.time.completed}> <Show when={duration()}>
<span style={{ fg: theme.textMuted }}> {Locale.duration(duration())}</span> <span style={{ fg: theme.textMuted }}> {Locale.duration(duration())}</span>
</Show> </Show>
</text> </text>
@@ -1100,7 +1098,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
const ctx = use() const ctx = use()
const { syntax } = useTheme() const { theme, syntax } = useTheme()
return ( return (
<Show when={props.part.text.trim()}> <Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}> <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
@@ -1111,6 +1109,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
syntaxStyle={syntax()} syntaxStyle={syntax()}
content={props.part.text.trim()} content={props.part.text.trim()}
conceal={ctx.conceal()} conceal={ctx.conceal()}
fg={theme.text}
/> />
</box> </box>
</Show> </Show>
+14 -9
View File
@@ -58,16 +58,14 @@ export const TuiThreadCommand = cmd({
// Resolve relative paths against PWD to preserve behavior when using --cwd flag // Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd() const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd() const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
const defaultWorker = new URL("./worker.ts", import.meta.url) const localWorker = new URL("./worker.ts", import.meta.url)
// Nix build creates a bundled worker next to the binary; prefer it when present. const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
const execDir = path.dirname(process.execPath) const execDir = path.dirname(process.execPath)
const bundledWorker = path.join(execDir, "opencode-worker.js") const workerPath = await iife(async () => {
const hasBundledWorker = await Bun.file(bundledWorker).exists()
const workerPath = (() => {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
if (hasBundledWorker) return bundledWorker if (await Bun.file(distWorker).exists()) return distWorker
return defaultWorker return localWorker
})() })
try { try {
process.chdir(cwd) process.chdir(cwd)
} catch (e) { } catch (e) {
@@ -99,7 +97,8 @@ export const TuiThreadCommand = cmd({
if (!args.prompt) return piped if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt return piped ? piped + "\n" + args.prompt : args.prompt
}) })
await tui({
const tuiPromise = tui({
url: server.url, url: server.url,
args: { args: {
continue: args.continue, continue: args.continue,
@@ -112,5 +111,11 @@ export const TuiThreadCommand = cmd({
await client.call("shutdown", undefined) 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() dialog.clear()
}} }}
> >
<text fg={theme.background}>ok</text> <text fg={theme.selectedListItemText}>ok</text>
</box> </box>
</box> </box>
</box> </box>
@@ -53,7 +53,9 @@ export function DialogConfirm(props: DialogConfirmProps) {
dialog.clear() dialog.clear()
}} }}
> >
<text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text> <text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
{Locale.titlecase(key)}
</text>
</box> </box>
)} )}
</For> </For>
@@ -28,7 +28,7 @@ export function DialogHelp() {
</box> </box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}> <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}> <box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}>
<text fg={theme.background}>ok</text> <text fg={theme.selectedListItemText}>ok</text>
</box> </box>
</box> </box>
</box> </box>
@@ -1,5 +1,5 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme" import { useTheme, selectedForeground } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { batch, createEffect, createMemo, For, Show, type JSX } from "solid-js" import { batch, createEffect, createMemo, For, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -262,32 +262,29 @@ function Option(props: {
onMouseOver?: () => void onMouseOver?: () => void
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
const fg = selectedForeground(theme)
return ( return (
<> <>
<Show when={props.current}> <Show when={props.current}>
<text <text flexShrink={0} fg={props.active ? fg : props.current ? theme.primary : theme.text} marginRight={0.5}>
flexShrink={0}
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
marginRight={0.5}
>
</text> </text>
</Show> </Show>
<text <text
flexGrow={1} flexGrow={1}
fg={props.active ? theme.background : props.current ? theme.primary : theme.text} fg={props.active ? fg : props.current ? theme.primary : theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined} attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden" overflow="hidden"
wrapMode="none" wrapMode="none"
paddingLeft={3} paddingLeft={3}
> >
{Locale.truncate(props.title, 62)} {Locale.truncate(props.title, 62)}
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span> <span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
</text> </text>
<Show when={props.footer}> <Show when={props.footer}>
<box flexShrink={0}> <box flexShrink={0}>
<text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text> <text fg={props.active ? fg : theme.textMuted}>{props.footer}</text>
</box> </box>
</Show> </Show>
</> </>
+8 -2
View File
@@ -26,8 +26,6 @@ process.on("uncaughtException", (e) => {
}) })
}) })
upgrade()
let server: Bun.Server<undefined> let server: Bun.Server<undefined>
export const rpc = { export const rpc = {
async server(input: { port: number; hostname: string }) { async server(input: { port: number; hostname: string }) {
@@ -42,6 +40,14 @@ export const rpc = {
throw e throw e
} }
}, },
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
directory: input.directory,
fn: async () => {
await upgrade().catch(() => {})
},
})
},
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
await Instance.disposeAll() await Instance.disposeAll()
+6 -1
View File
@@ -5,10 +5,15 @@ import { Installation } from "@/installation"
export async function upgrade() { export async function upgrade() {
const config = await Config.global() const config = await Config.global()
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const latest = await Installation.latest().catch(() => {}) const latest = await Installation.latest().catch(() => {})
if (!latest) return if (!latest) return
if (Installation.VERSION === latest) return if (Installation.VERSION === latest) return
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return
}
const method = await Installation.method() const method = await Installation.method()
if (method === "unknown") return if (method === "unknown") return
await Installation.upgrade(method, latest) await Installation.upgrade(method, latest)
+1
View File
@@ -430,6 +430,7 @@ export namespace Config {
history_next: z.string().optional().default("down").describe("Next history item"), 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: z.string().optional().default("<leader>right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous 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() .strict()
.meta({ .meta({
+1 -1
View File
@@ -30,7 +30,7 @@ await Promise.all([
fs.mkdir(Global.Path.bin, { recursive: true }), fs.mkdir(Global.Path.bin, { recursive: true }),
]) ])
const CACHE_VERSION = "9" const CACHE_VERSION = "11"
const version = await Bun.file(path.join(Global.Path.cache, "version")) const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text() .text()
@@ -22,6 +22,12 @@ export namespace Installation {
version: z.string(), version: z.string(),
}), }),
), ),
UpdateAvailable: Bus.event(
"installation.update-available",
z.object({
version: z.string(),
}),
),
} }
export const Info = z export const Info = z
+1 -1
View File
@@ -28,7 +28,7 @@ export namespace Plugin {
} }
const plugins = [...(config.plugin ?? [])] const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push("opencode-copilot-auth@0.0.5") plugins.push("opencode-copilot-auth@0.0.7")
plugins.push("opencode-anthropic-auth@0.0.2") plugins.push("opencode-anthropic-auth@0.0.2")
} }
for (let plugin of plugins) { for (let plugin of plugins) {
+21 -9
View File
@@ -530,13 +530,18 @@ export namespace Provider {
const existing = s.sdk.get(key) const existing = s.sdk.get(key)
if (existing) return existing if (existing) return existing
let installedPath: string const installedPath = await (async () => {
if (!pkg.startsWith("file://")) { if (pkg.startsWith("file://")) {
installedPath = await BunProc.install(pkg, "latest") log.info("loading local provider", { pkg })
} else { return pkg
log.info("loading local provider", { pkg }) }
installedPath = pkg const resolved = await BunProc.resolve(pkg)
} if (resolved) {
log.info("using preinstalled provider", { providerID: provider.id, pkg })
return resolved
}
return BunProc.install(pkg, "latest")
})()
// The `google-vertex-anthropic` provider points to the `@ai-sdk/google-vertex` package. // 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 // Ref: https://github.com/sst/models.dev/blob/0a87de42ab177bebad0620a889e2eb2b4a5dd4ab/providers/google-vertex-anthropic/provider.toml
@@ -671,7 +676,7 @@ export namespace Provider {
if (providerID === "github-copilot") { if (providerID === "github-copilot") {
priority = priority.filter((m) => m !== "claude-haiku-4.5") priority = priority.filter((m) => m !== "claude-haiku-4.5")
} }
if (providerID === "opencode" || providerID === "local") { if (providerID.startsWith("opencode")) {
priority = ["gpt-5-nano"] priority = ["gpt-5-nano"]
} }
for (const item of priority) { for (const item of priority) {
@@ -680,7 +685,14 @@ export namespace Provider {
} }
} }
} }
return getModel("opencode", "gpt-5-nano")
// 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
} }
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
@@ -272,4 +272,12 @@ export namespace ProviderTransform {
return schema 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
}
} }
+3 -1
View File
@@ -8,6 +8,7 @@ import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
import { ProviderTransform } from "@/provider/transform"
export namespace MessageV2 { export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
@@ -737,9 +738,10 @@ export namespace MessageV2 {
{ cause: e }, { cause: e },
).toObject() ).toObject()
case APICallError.isInstance(e): case APICallError.isInstance(e):
const message = ProviderTransform.error(ctx.providerID, e.message)
return new MessageV2.APIError( return new MessageV2.APIError(
{ {
message: e.message, message,
statusCode: e.statusCode, statusCode: e.statusCode,
isRetryable: e.isRetryable, isRetryable: e.isRetryable,
responseHeaders: e.responseHeaders, responseHeaders: e.responseHeaders,
+30 -20
View File
@@ -197,7 +197,7 @@ export namespace SessionPrompt {
const message = await createUserMessage(input) const message = await createUserMessage(input)
await Session.touch(input.sessionID) await Session.touch(input.sessionID)
if (input.noReply) { if (input.noReply === true) {
return message return message
} }
@@ -534,7 +534,7 @@ export namespace SessionPrompt {
} }
}, },
headers: { headers: {
...(model.providerID === "opencode" ...(model.providerID.startsWith("opencode")
? { ? {
"x-opencode-session": sessionID, "x-opencode-session": sessionID,
"x-opencode-request": lastUser.id, "x-opencode-request": lastUser.id,
@@ -907,22 +907,32 @@ export namespace SessionPrompt {
extra: { bypassCwdCheck: true, ...info.model }, extra: { bypassCwdCheck: true, ...info.model },
metadata: async () => {}, metadata: async () => {},
}) })
pieces.push( pieces.push({
{ id: Identifier.ascending("part"),
id: Identifier.ascending("part"), messageID: info.id,
messageID: info.id, sessionID: input.sessionID,
sessionID: input.sessionID, type: "text",
type: "text", synthetic: true,
synthetic: true, text: result.output,
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({
...part, ...part,
id: part.id ?? Identifier.ascending("part"), id: part.id ?? Identifier.ascending("part"),
messageID: info.id, messageID: info.id,
sessionID: input.sessionID, sessionID: input.sessionID,
}, })
) }
}) })
.catch((error) => { .catch((error) => {
log.error("failed to read file", { error }) log.error("failed to read file", { error })
@@ -1398,7 +1408,8 @@ export namespace SessionPrompt {
input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
.length === 1 .length === 1
if (!isFirst) return if (!isFirst) return
const small = await Provider.getSmallModel(input.providerID) const small =
(await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
const options = pipe( const options = pipe(
{}, {},
mergeDeep(ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", input.session.id)), mergeDeep(ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", input.session.id)),
@@ -1406,7 +1417,8 @@ export namespace SessionPrompt {
mergeDeep(small.info.options), mergeDeep(small.info.options),
) )
await generateText({ await generateText({
maxOutputTokens: small.info.reasoning ? 1500 : 20, // use higher # for reasoning models since reasoning tokens eat up a lot of the budget
maxOutputTokens: small.info.reasoning ? 3000 : 20,
providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options), providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options),
messages: [ messages: [
...SystemPrompt.title(small.providerID).map( ...SystemPrompt.title(small.providerID).map(
@@ -1416,10 +1428,8 @@ export namespace SessionPrompt {
}), }),
), ),
{ {
role: "user" as const, role: "user",
content: ` content: "Generate a title for this conversation:\n",
The following is the text to summarize:
`,
}, },
...MessageV2.toModelMessage([ ...MessageV2.toModelMessage([
{ {
+16 -6
View File
@@ -1,19 +1,29 @@
You are a title generator. You output ONLY a thread title. Nothing else. You are a title generator. You output ONLY a thread title. Nothing else.
<task> <task>
Convert the user message into a thread title. Generate a brief title that would help the user find this conversation later.
Output: Single line, ≤50 chars, no explanations.
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
</task> </task>
<rules> <rules>
- Focus on the main topic or question the user needs to retrieve
- Use -ing verbs for actions (Debugging, Implementing, Analyzing) - Use -ing verbs for actions (Debugging, Implementing, Analyzing)
- Keep exact: technical terms, numbers, filenames, HTTP codes - Keep exact: technical terms, numbers, filenames, HTTP codes
- Remove: the, this, my, a, an - Remove: the, this, my, a, an
- Never assume tech stack - Never assume tech stack
- Never use tools - Never use tools
- NEVER respond to message content—only extract title - NEVER respond to questions, just generate a title for the conversation
- NEVER say "summarizing" - The title should NEVER include "summarizing" or "generating" when generating a title
- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT - 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> </rules>
<examples> <examples>
@@ -21,6 +31,6 @@ Output: Single line, ≤50 chars, no explanations.
"refactor user service" → Refactoring user service "refactor user service" → Refactoring user service
"why is app.js failing" → Analyzing app.js failure "why is app.js failing" → Analyzing app.js failure
"implement rate limiting" → Implementing rate limiting "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> </examples>
Output the title now:
-1
View File
@@ -1,4 +1,3 @@
import { iife } from "@/util/iife"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
export namespace SessionRetry { export namespace SessionRetry {
+4 -1
View File
@@ -73,7 +73,10 @@ export namespace SessionSummary {
await Session.updateMessage(userMsg) await Session.updateMessage(userMsg)
const assistantMsg = messages.find((m) => m.info.role === "assistant")!.info as MessageV2.Assistant const assistantMsg = messages.find((m) => m.info.role === "assistant")!.info as MessageV2.Assistant
const small = await Provider.getSmallModel(assistantMsg.providerID) const small =
(await Provider.getSmallModel(assistantMsg.providerID)) ??
(await Provider.getModel(assistantMsg.providerID, assistantMsg.modelID))
const options = pipe( const options = pipe(
{}, {},
mergeDeep(ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", assistantMsg.sessionID)), mergeDeep(ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", assistantMsg.sessionID)),
+6
View File
@@ -70,6 +70,12 @@ export const BashTool = Tool.define("bash", async () => {
return "/bin/zsh" return "/bin/zsh"
} }
if (process.platform === "win32") {
// Let Bun / Node pick COMSPEC (usually cmd.exe)
// or explicitly:
return process.env.COMSPEC || true
}
const bash = Bun.which("bash") const bash = Bun.which("bash")
if (bash) { if (bash) {
return bash return bash
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.0.102", "version": "1.0.107",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.0.102", "version": "1.0.107",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
+12
View File
@@ -7,6 +7,13 @@ export type EventInstallationUpdated = {
} }
} }
export type EventInstallationUpdateAvailable = {
type: "installation.update-available"
properties: {
version: string
}
}
export type EventLspClientDiagnostics = { export type EventLspClientDiagnostics = {
type: "lsp.client.diagnostics" type: "lsp.client.diagnostics"
properties: { properties: {
@@ -642,6 +649,7 @@ export type EventFileWatcherUpdated = {
export type Event = export type Event =
| EventInstallationUpdated | EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventLspClientDiagnostics | EventLspClientDiagnostics
| EventLspUpdated | EventLspUpdated
| EventMessageUpdated | EventMessageUpdated
@@ -846,6 +854,10 @@ export type KeybindsConfig = {
* Previous child session * Previous child session
*/ */
session_child_cycle_reverse?: string session_child_cycle_reverse?: string
/**
* Suspend terminal
*/
terminal_suspend?: string
} }
export type AgentConfig = { export type AgentConfig = {
+12
View File
@@ -39,6 +39,9 @@ class Resource:
class Console: class Console:
type: str type: str
url: str url: str
class ConsoleData:
name: str
type: str
class Database: class Database:
database: str database: str
host: str host: str
@@ -52,6 +55,9 @@ class Resource:
class EMAILOCTOPUS_API_KEY: class EMAILOCTOPUS_API_KEY:
type: str type: str
value: str value: str
class EnterpriseStorage:
name: str
type: str
class GITHUB_APP_ID: class GITHUB_APP_ID:
type: str type: str
value: str value: str
@@ -75,6 +81,12 @@ class Resource:
value: str value: str
class LogProcessor: class LogProcessor:
type: str type: str
class R2AccessKey:
type: str
value: str
class R2SecretKey:
type: str
value: str
class STRIPE_SECRET_KEY: class STRIPE_SECRET_KEY:
type: str type: str
value: str value: str
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.0.102", "version": "1.0.107",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun run src/index.ts", "dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/tauri", "name": "@opencode-ai/tauri",
"private": true, "private": true,
"version": "1.0.102", "version": "1.0.107",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2 -1
View File
@@ -15,7 +15,8 @@
{ {
"title": "opencode", "title": "opencode",
"width": 800, "width": 800,
"height": 600 "height": 600,
"url": "https://desktop.dev.opencode.ai"
} }
], ],
"security": { "security": {
+9
View File
@@ -0,0 +1,9 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.0.102", "version": "1.0.107",
"type": "module", "type": "module",
"exports": { "exports": {
"./*": "./src/components/*.tsx", "./*": "./src/components/*.tsx",
@@ -0,0 +1,95 @@
[data-component="message-nav"] {
/* margin-right: 32px; */
/* margin-top: 12px; */
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
padding-left: 0;
list-style: none;
&[data-size="normal"] {
position: absolute;
right: 100%;
width: 240px;
/* margin-top: 12px; */
@media (min-width: 80rem) {
gap: 8px;
/* margin-top: 4px; */
}
}
}
[data-slot="message-nav-item"] {
display: flex;
align-items: center;
align-self: stretch;
justify-content: flex-end;
[data-component="message-nav"][data-size="normal"] & {
justify-content: flex-start;
}
}
[data-slot="message-nav-tick-button"] {
display: flex;
align-items: center;
justify-content: flex-start;
height: 8px;
width: 32px;
/* margin-right: -12px; */
cursor: pointer;
border: none;
background: none;
padding: 0;
&[data-active] [data-slot="message-nav-tick-line"] {
background-color: var(--icon-strong-base);
width: 100%;
}
}
[data-slot="message-nav-tick-line"] {
height: 1px;
width: 20px;
background-color: var(--icon-base);
transition:
width 0.2s,
background-color 0.2s;
}
[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
width: 100%;
background-color: var(--icon-strong-base);
}
[data-slot="message-nav-message-button"] {
display: flex;
align-items: center;
align-self: stretch;
width: 100%;
column-gap: 8px;
cursor: default;
border: none;
background: none;
padding: 0;
}
[data-slot="message-nav-title-preview"] {
font-size: 14px; /* text-14-regular */
color: var(--text-weak);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
text-align: left;
&[data-active] {
color: var(--text-strong);
}
}
[data-slot="message-nav-item"]:hover [data-slot="message-nav-title-preview"] {
color: var(--text-base);
}
@@ -0,0 +1,66 @@
import { UserMessage } from "@opencode-ai/sdk"
import { ComponentProps, createMemo, For, Match, Show, splitProps, Switch } from "solid-js"
import { DiffChanges } from "./diff-changes"
import { Spinner } from "./spinner"
export function MessageNav(
props: ComponentProps<"ul"> & {
messages: UserMessage[]
current?: UserMessage
size: "normal" | "compact"
working?: boolean
onMessageSelect: (message: UserMessage) => void
},
) {
const [local, others] = splitProps(props, ["messages", "current", "size", "working", "onMessageSelect"])
const lastUserMessage = createMemo(() => {
return local.messages?.at(0)
})
return (
<ul role="list" data-component="message-nav" data-size={local.size} {...others}>
<For each={local.messages}>
{(message) => {
const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && local.working)
const handleClick = () => local.onMessageSelect(message)
return (
<li data-slot="message-nav-item">
<Switch>
<Match when={local.size === "compact"}>
<button
data-slot="message-nav-tick-button"
data-active={message.id === local.current?.id || undefined}
onClick={handleClick}
>
<div data-slot="message-nav-tick-line" />
</button>
</Match>
<Match when={local.size === "normal"}>
<button data-slot="message-nav-message-button" onClick={handleClick}>
<Switch>
<Match when={messageWorking()}>
<Spinner />
</Match>
<Match when={true}>
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
</Match>
</Switch>
<div
data-slot="message-nav-title-preview"
data-active={message.id === local.current?.id || undefined}
>
<Show when={message.summary?.title} fallback="New message">
{message.summary?.title}
</Show>
</div>
</button>
</Match>
</Switch>
</li>
)
}}
</For>
</ul>
)
}
@@ -3,7 +3,6 @@ import { Part } from "./message-part"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
import { useData } from "../context/data" import { useData } from "../context/data"
import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk" import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk"
import "./message-progress.css"
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) { export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
const data = useData() const data = useData()
@@ -1,324 +0,0 @@
[data-component="session-timeline"] {
/* flex: 1; */
min-height: 0;
display: flex;
align-items: flex-start;
justify-content: flex-start;
[data-slot="session-timeline-timeline-list"] {
margin-right: 32px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 12px;
&[data-expanded="true"] {
position: absolute;
right: 100%;
width: 240px;
margin-top: 12px;
@media (min-width: 80rem) {
gap: 8px;
margin-top: 4px;
}
}
}
[data-slot="session-timeline-timeline-item"] {
display: flex;
align-items: center;
align-self: stretch;
justify-content: flex-end;
&[data-expanded="true"] {
@media (min-width: 80rem) {
justify-content: flex-start;
}
}
}
[data-slot="session-timeline-tick-button"] {
display: flex;
align-items: center;
justify-content: flex-start;
height: 8px;
width: 32px;
margin-right: -12px;
cursor: pointer;
border: none;
background: none;
padding: 0;
&[data-active="true"] [data-slot="session-timeline-tick-line"] {
background-color: var(--icon-strong-base);
width: 100%;
}
&[data-expanded="true"] {
@media (min-width: 80rem) {
display: none;
}
}
}
[data-slot="session-timeline-tick-line"] {
height: 1px;
width: 20px;
background-color: var(--icon-base);
transition:
width 0.2s,
background-color 0.2s;
}
[data-slot="session-timeline-tick-button"]:hover [data-slot="session-timeline-tick-line"] {
width: 100%;
background-color: var(--icon-strong-base);
}
[data-slot="session-timeline-message-button"] {
display: none;
align-items: center;
align-self: stretch;
width: 100%;
column-gap: 8px;
cursor: default;
border: none;
background: none;
padding: 0;
&[data-expanded="true"] {
@media (min-width: 80rem) {
display: flex;
}
}
}
[data-slot="session-timeline-message-title-preview"] {
font-size: 14px; /* text-14-regular */
color: var(--text-weak);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
text-align: left;
&[data-active="true"] {
color: var(--text-strong);
}
}
[data-slot="session-timeline-timeline-item"]:hover [data-slot="session-timeline-message-title-preview"] {
color: var(--text-base);
}
[data-slot="session-timeline-content"] {
flex-grow: 1;
width: 100%;
height: 100%;
min-width: 0;
overflow-y: auto;
scrollbar-width: none;
}
[data-slot="session-timeline-content"]::-webkit-scrollbar {
display: none;
}
[data-slot="session-timeline-message-container"] {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
gap: 32px;
}
[data-slot="session-timeline-message-header"] {
display: flex;
align-items: center;
gap: 8px;
align-self: stretch;
position: sticky;
top: 0;
background-color: var(--background-stronger);
z-index: 20;
height: 32px;
}
[data-slot="session-timeline-message-content"] {
margin-top: -24px;
}
[data-slot="session-timeline-message-title"] {
width: 100%;
font-size: 14px; /* text-14-medium */
font-weight: 500;
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-timeline-message-title"] h1 {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
font-size: inherit;
font-weight: inherit;
}
[data-slot="session-timeline-typewriter"] {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-timeline-summary-section"] {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
align-items: flex-start;
align-self: stretch;
}
[data-slot="session-timeline-summary-header"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
}
[data-slot="session-timeline-summary-title"] {
font-size: 12px; /* text-12-medium */
font-weight: 500;
color: var(--text-weak);
}
[data-slot="session-timeline-markdown"] {
&[data-diffs="true"] {
font-size: 14px; /* text-14-regular */
}
&[data-fade="true"] > * {
animation: fade-up-text 0.3s ease-out forwards;
}
}
[data-slot="session-timeline-accordion"] {
width: 100%;
}
[data-component="sticky-accordion-header"] {
top: 40px;
&[data-expanded]::before {
top: -40px;
}
}
[data-slot="session-timeline-accordion-trigger-content"] {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 20px;
}
[data-slot="session-timeline-file-info"] {
flex-grow: 1;
display: flex;
align-items: center;
gap: 20px;
min-width: 0;
}
[data-slot="session-timeline-file-icon"] {
flex-shrink: 0;
width: 16px;
height: 16px;
}
[data-slot="session-timeline-file-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
}
[data-slot="session-timeline-directory"] {
color: var(--text-base);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="session-timeline-filename"] {
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="session-timeline-accordion-actions"] {
flex-shrink: 0;
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
[data-slot="session-timeline-accordion-content"] {
max-height: 240px; /* max-h-60 */
overflow-y: auto;
scrollbar-width: none;
}
[data-slot="session-timeline-accordion-content"]::-webkit-scrollbar {
display: none;
}
[data-slot="session-timeline-response-section"] {
width: 100%;
}
[data-slot="session-timeline-collapsible-trigger-content"] {
color: var(--text-weak);
cursor: pointer;
background: none;
border: none;
padding: 0;
display: flex;
align-items: center;
&:hover {
color: var(--text-strong);
}
display: flex;
align-items: center;
gap: 4px;
align-self: stretch;
}
[data-slot="session-timeline-details-text"] {
font-size: 12px; /* text-12-medium */
font-weight: 500;
}
.error-card {
color: var(--text-on-critical-base);
}
[data-slot="session-timeline-collapsible-content-inner"] {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
gap: 12px;
}
}
@@ -1,289 +0,0 @@
import { AssistantMessage } from "@opencode-ai/sdk"
import { useData } from "../context"
import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { DiffChanges } from "./diff-changes"
import { Spinner } from "./spinner"
import { Typewriter } from "./typewriter"
import { Message } from "./message-part"
import { Markdown } from "./markdown"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { Diff } from "./diff"
import { Card } from "./card"
import { MessageProgress } from "./message-progress"
import { Collapsible } from "./collapsible"
export function SessionTimeline(
props: ParentProps<{
sessionID: string
classes?: {
root?: string
content?: string
container?: string
}
expanded?: boolean
}>,
) {
const data = useData()
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const match = Binary.search(data.session, props.sessionID, (s) => s.id)
if (!match.found) throw new Error(`Session ${props.sessionID} not found`)
// const info = createMemo(() => data.session[match.index])
const messages = createMemo(() => (props.sessionID ? (data.message[props.sessionID] ?? []) : []))
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => b.id.localeCompare(a.id)),
)
const lastUserMessage = createMemo(() => {
return userMessages()?.at(0)
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
return userMessages()?.find((m) => m.id === store.messageId)
})
const status = createMemo(
() =>
data.session_status[props.sessionID] ?? {
type: "idle",
},
)
const working = createMemo(() => status()?.type !== "idle")
return (
<div data-component="session-timeline" class={props.classes?.root}>
<Show when={userMessages().length > 1}>
<ul role="list" data-slot="session-timeline-timeline-list" data-expanded={props.expanded}>
<For each={userMessages()}>
{(message) => {
const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && working())
const handleClick = () => setStore("messageId", message.id)
return (
<li data-slot="session-timeline-timeline-item" data-expanded={props.expanded}>
<button
data-slot="session-timeline-tick-button"
data-active={activeMessage()?.id === message.id}
data-expanded={props.expanded}
onClick={handleClick}
>
<div data-slot="session-timeline-tick-line" />
</button>
<button
data-slot="session-timeline-message-button"
data-expanded={props.expanded}
onClick={handleClick}
>
<Switch>
<Match when={messageWorking()}>
<Spinner />
</Match>
<Match when={true}>
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
</Match>
</Switch>
<div
data-slot="session-timeline-message-title-preview"
data-active={activeMessage()?.id === message.id}
>
<Show when={message.summary?.title} fallback="New message">
{message.summary?.title}
</Show>
</div>
</button>
</li>
)
}}
</For>
</ul>
</Show>
<div data-slot="session-timeline-content" class={props.classes?.content}>
<For each={userMessages()}>
{(message) => {
const isActive = createMemo(() => activeMessage()?.id === message.id)
const titleSeen = createMemo(() => true)
const contentSeen = createMemo(() => true)
{
/* const titleSeen = createSeen(`message-title-${message.id}`) */
}
{
/* const contentSeen = createSeen(`message-content-${message.id}`) */
}
const [titled, setTitled] = createSignal(titleSeen())
const assistantMessages = createMemo(() => {
return messages()?.filter((m) => m.role === "assistant" && m.parentID == message.id) as AssistantMessage[]
})
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const [detailsExpanded, setDetailsExpanded] = createSignal(false)
const parts = createMemo(() => data.part[message.id])
const hasToolPart = createMemo(() =>
assistantMessages()
?.flatMap((m) => data.part[m.id])
.some((p) => p?.type === "tool"),
)
const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && working())
const initialCompleted = !(message.id === lastUserMessage()?.id && working())
const [completed, setCompleted] = createSignal(initialCompleted)
// allowing time for the animations to finish
createEffect(() => {
if (titleSeen()) return
const title = message.summary?.title
if (title) setTimeout(() => setTitled(true), 10_000)
})
createEffect(() => {
const completed = !messageWorking()
setTimeout(() => setCompleted(completed), 1200)
})
return (
<Show when={isActive()}>
<div
data-message={message.id}
data-slot="session-timeline-message-container"
class={props.classes?.container}
>
{/* Title */}
<div data-slot="session-timeline-message-header">
<div data-slot="session-timeline-message-title">
<Show
when={titled()}
fallback={
<Typewriter as="h1" text={message.summary?.title} data-slot="session-timeline-typewriter" />
}
>
<h1>{message.summary?.title}</h1>
</Show>
</div>
</div>
<div data-slot="session-timeline-message-content">
<Message message={message} parts={parts()} />
</div>
{/* Summary */}
<Show when={completed()}>
<div data-slot="session-timeline-summary-section">
<div data-slot="session-timeline-summary-header">
<h2 data-slot="session-timeline-summary-title">
<Switch>
<Match when={message.summary?.diffs?.length}>Summary</Match>
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={message.summary?.body}>
{(summary) => (
<Markdown
data-slot="session-timeline-markdown"
data-diffs={!!message.summary?.diffs?.length}
data-fade={!message.summary?.diffs?.length && !contentSeen()}
text={summary()}
/>
)}
</Show>
</div>
<Accordion data-slot="session-timeline-accordion" multiple>
<For each={message.summary?.diffs ?? []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-timeline-accordion-trigger-content">
<div data-slot="session-timeline-file-info">
<FileIcon
node={{ path: diff.file, type: "file" }}
data-slot="session-timeline-file-icon"
/>
<div data-slot="session-timeline-file-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-timeline-directory">
{getDirectory(diff.file)}&lrm;
</span>
</Show>
<span data-slot="session-timeline-filename">{getFilename(diff.file)}</span>
</div>
</div>
<div data-slot="session-timeline-accordion-actions">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-timeline-accordion-content">
<Diff
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</Show>
<Show when={error() && !detailsExpanded()}>
<Card variant="error" class="error-card">
{error()?.data?.message as string}
</Card>
</Show>
{/* Response */}
<div data-slot="session-timeline-response-section">
<Switch>
<Match when={!completed()}>
<MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} />
</Match>
<Match when={completed() && hasToolPart()}>
<Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}>
<Collapsible.Trigger>
<div data-slot="session-timeline-collapsible-trigger-content">
<div data-slot="session-timeline-details-text">
<Switch>
<Match when={detailsExpanded()}>Hide details</Match>
<Match when={!detailsExpanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div data-slot="session-timeline-collapsible-content-inner">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => data.part[assistantMessage.id])
return <Message message={assistantMessage} parts={parts()} />
}}
</For>
<Show when={error()}>
<Card variant="error" class="error-card">
{error()?.data?.message as string}
</Card>
</Show>
</div>
</Collapsible.Content>
</Collapsible>
</Match>
</Switch>
</div>
</div>
</Show>
)
}}
</For>
{props.children}
</div>
</div>
)
}
+220
View File
@@ -0,0 +1,220 @@
[data-component="session-turn"] {
/* flex: 1; */
height: 100%;
min-height: 0;
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: flex-start;
[data-slot="session-turn-content"] {
flex-grow: 1;
width: 100%;
height: 100%;
min-width: 0;
overflow-y: auto;
scrollbar-width: none;
}
[data-slot="session-turn-content"]::-webkit-scrollbar {
display: none;
}
[data-slot="session-turn-message-container"] {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
min-width: 0;
gap: 32px;
}
[data-slot="session-turn-message-header"] {
display: flex;
align-items: center;
gap: 8px;
align-self: stretch;
position: sticky;
top: 0;
background-color: var(--background-stronger);
z-index: 20;
height: 32px;
}
[data-slot="session-turn-message-content"] {
margin-top: -24px;
}
[data-slot="session-turn-message-title"] {
width: 100%;
font-size: 14px; /* text-14-medium */
font-weight: 500;
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-turn-message-title"] h1 {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
font-size: inherit;
font-weight: inherit;
}
[data-slot="session-turn-typewriter"] {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-turn-summary-section"] {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
align-items: flex-start;
align-self: stretch;
}
[data-slot="session-turn-summary-header"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
}
[data-slot="session-turn-summary-title"] {
font-size: 12px; /* text-12-medium */
font-weight: 500;
color: var(--text-weak);
}
[data-slot="session-turn-markdown"] {
&[data-diffs="true"] {
font-size: 14px; /* text-14-regular */
}
&[data-fade="true"] > * {
animation: fade-up-text 0.3s ease-out forwards;
}
}
[data-slot="session-turn-accordion"] {
width: 100%;
}
[data-component="sticky-accordion-header"] {
top: 40px;
&[data-expanded]::before {
top: -40px;
}
}
[data-slot="session-turn-accordion-trigger-content"] {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 20px;
}
[data-slot="session-turn-file-info"] {
flex-grow: 1;
display: flex;
align-items: center;
gap: 20px;
min-width: 0;
}
[data-slot="session-turn-file-icon"] {
flex-shrink: 0;
width: 16px;
height: 16px;
}
[data-slot="session-turn-file-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
}
[data-slot="session-turn-directory"] {
color: var(--text-base);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="session-turn-filename"] {
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="session-turn-accordion-actions"] {
flex-shrink: 0;
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
[data-slot="session-turn-accordion-content"] {
max-height: 240px; /* max-h-60 */
overflow-y: auto;
scrollbar-width: none;
}
[data-slot="session-turn-accordion-content"]::-webkit-scrollbar {
display: none;
}
[data-slot="session-turn-response-section"] {
width: 100%;
min-width: 0;
}
[data-slot="session-turn-collapsible-trigger-content"] {
color: var(--text-weak);
cursor: pointer;
background: none;
border: none;
padding: 0;
display: flex;
align-items: center;
&:hover {
color: var(--text-strong);
}
display: flex;
align-items: center;
gap: 4px;
align-self: stretch;
}
[data-slot="session-turn-details-text"] {
font-size: 12px; /* text-12-medium */
font-weight: 500;
}
.error-card {
color: var(--text-on-critical-base);
}
[data-slot="session-turn-collapsible-content-inner"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-self: stretch;
gap: 12px;
}
}
+220
View File
@@ -0,0 +1,220 @@
import { AssistantMessage } from "@opencode-ai/sdk"
import { useData } from "../context"
import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch } from "solid-js"
import { DiffChanges } from "./diff-changes"
import { Typewriter } from "./typewriter"
import { Message } from "./message-part"
import { Markdown } from "./markdown"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { Diff } from "./diff"
import { Card } from "./card"
import { MessageProgress } from "./message-progress"
import { Collapsible } from "./collapsible"
export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
classes?: {
root?: string
content?: string
container?: string
}
}>,
) {
const data = useData()
const match = Binary.search(data.session, props.sessionID, (s) => s.id)
if (!match.found) throw new Error(`Session ${props.sessionID} not found`)
const messages = createMemo(() => (props.sessionID ? (data.message[props.sessionID] ?? []) : []))
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => b.id.localeCompare(a.id)),
)
const lastUserMessage = createMemo(() => {
return userMessages()?.at(0)
})
const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
const status = createMemo(
() =>
data.session_status[props.sessionID] ?? {
type: "idle",
},
)
const working = createMemo(() => status()?.type !== "idle")
return (
<div data-component="session-turn" class={props.classes?.root}>
<div data-slot="session-turn-content" class={props.classes?.content}>
<Show when={message()}>
{(msg) => {
const titleSeen = createMemo(() => true)
const contentSeen = createMemo(() => true)
const [titled, setTitled] = createSignal(titleSeen())
const assistantMessages = createMemo(() => {
return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
})
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const [detailsExpanded, setDetailsExpanded] = createSignal(false)
const parts = createMemo(() => data.part[msg().id])
const hasToolPart = createMemo(() =>
assistantMessages()
?.flatMap((m) => data.part[m.id])
.some((p) => p?.type === "tool"),
)
const messageWorking = createMemo(() => msg().id === lastUserMessage()?.id && working())
const initialCompleted = !(msg().id === lastUserMessage()?.id && working())
const [completed, setCompleted] = createSignal(initialCompleted)
// allowing time for the animations to finish
createEffect(() => {
if (titleSeen()) return
const title = msg().summary?.title
if (title) setTimeout(() => setTitled(true), 10_000)
})
createEffect(() => {
const completed = !messageWorking()
setTimeout(() => setCompleted(completed), 1200)
})
return (
<div data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container}>
{/* Title */}
<div data-slot="session-turn-message-header">
<div data-slot="session-turn-message-title">
<Show
when={titled()}
fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
>
<h1>{msg().summary?.title}</h1>
</Show>
</div>
</div>
<div data-slot="session-turn-message-content">
<Message message={msg()} parts={parts()} />
</div>
{/* Summary */}
<Show when={completed()}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<h2 data-slot="session-turn-summary-title">
<Switch>
<Match when={msg().summary?.diffs?.length}>Summary</Match>
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={msg().summary?.body}>
{(summary) => (
<Markdown
data-slot="session-turn-markdown"
data-diffs={!!msg().summary?.diffs?.length}
data-fade={!msg().summary?.diffs?.length && !contentSeen()}
text={summary()}
/>
)}
</Show>
</div>
<Accordion data-slot="session-turn-accordion" multiple>
<For each={msg().summary?.diffs ?? []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-accordion-trigger-content">
<div data-slot="session-turn-file-info">
<FileIcon
node={{ path: diff.file, type: "file" }}
data-slot="session-turn-file-icon"
/>
<div data-slot="session-turn-file-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
</Show>
<span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
</div>
</div>
<div data-slot="session-turn-accordion-actions">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-turn-accordion-content">
<Diff
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</Show>
<Show when={error() && !detailsExpanded()}>
<Card variant="error" class="error-card">
{error()?.data?.message as string}
</Card>
</Show>
{/* Response */}
<div data-slot="session-turn-response-section">
<Switch>
<Match when={!completed()}>
<MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} />
</Match>
<Match when={completed() && hasToolPart()}>
<Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}>
<Collapsible.Trigger>
<div data-slot="session-turn-collapsible-trigger-content">
<div data-slot="session-turn-details-text">
<Switch>
<Match when={detailsExpanded()}>Hide details</Match>
<Match when={!detailsExpanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div data-slot="session-turn-collapsible-content-inner">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => data.part[assistantMessage.id])
return <Message message={assistantMessage} parts={parts()} />
}}
</For>
<Show when={error()}>
<Card variant="error" class="error-card">
{error()?.data?.message as string}
</Card>
</Show>
</div>
</Collapsible.Content>
</Collapsible>
</Match>
</Switch>
</div>
</div>
)
}}
</Show>
{props.children}
</div>
</div>
)
}
+3 -1
View File
@@ -22,12 +22,14 @@
@import "../components/logo.css" layer(components); @import "../components/logo.css" layer(components);
@import "../components/markdown.css" layer(components); @import "../components/markdown.css" layer(components);
@import "../components/message-part.css" layer(components); @import "../components/message-part.css" layer(components);
@import "../components/message-progress.css" layer(components);
@import "../components/message-nav.css" layer(components);
@import "../components/progress-circle.css" layer(components); @import "../components/progress-circle.css" layer(components);
@import "../components/select.css" layer(components); @import "../components/select.css" layer(components);
@import "../components/select-dialog.css" layer(components); @import "../components/select-dialog.css" layer(components);
@import "../components/spinner.css" layer(components); @import "../components/spinner.css" layer(components);
@import "../components/session-review.css" layer(components); @import "../components/session-review.css" layer(components);
@import "../components/session-timeline.css" layer(components); @import "../components/session-turn.css" layer(components);
@import "../components/sticky-accordion-header.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components);
@import "../components/tabs.css" layer(components); @import "../components/tabs.css" layer(components);
@import "../components/tooltip.css" layer(components); @import "../components/tooltip.css" layer(components);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.0.102", "version": "1.0.107",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": { "exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"version": "1.0.102", "version": "1.0.107",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1
View File
@@ -46,6 +46,7 @@
"info": { "$ref": "#/definitions/colorValue" }, "info": { "$ref": "#/definitions/colorValue" },
"text": { "$ref": "#/definitions/colorValue" }, "text": { "$ref": "#/definitions/colorValue" },
"textMuted": { "$ref": "#/definitions/colorValue" }, "textMuted": { "$ref": "#/definitions/colorValue" },
"selectedListItemText": { "$ref": "#/definitions/colorValue" },
"background": { "$ref": "#/definitions/colorValue" }, "background": { "$ref": "#/definitions/colorValue" },
"backgroundPanel": { "$ref": "#/definitions/colorValue" }, "backgroundPanel": { "$ref": "#/definitions/colorValue" },
"backgroundElement": { "$ref": "#/definitions/colorValue" }, "backgroundElement": { "$ref": "#/definitions/colorValue" },
+6
View File
@@ -112,6 +112,12 @@ You can also install it with the following commands:
mise use --pin -g ubi:sst/opencode mise use --pin -g ubi:sst/opencode
``` ```
- **Using Docker**
```bash
docker run -it --rm ghcr.io/sst/opencode
```
Support for installing OpenCode on Windows using Bun is currently in progress. Support for installing OpenCode on Windows using Bun is currently in progress.
You can also grab the binary from the [Releases](https://github.com/sst/opencode/releases). You can also grab the binary from the [Releases](https://github.com/sst/opencode/releases).
-18
View File
@@ -1,18 +0,0 @@
diff --git a/dist/server/StartServer.jsx b/dist/server/StartServer.jsx
index 0c6a9fe3121a88520db252570a8b5997a36e0452..b3479cdb78c70d7a655d302d8e6ab536b4ff07a3 100644
--- a/dist/server/StartServer.jsx
+++ b/dist/server/StartServer.jsx
@@ -52,11 +52,11 @@ export function StartServer(props) {
else if (import.meta.env.DEV)
console.warn("No route matched for preloading js assets");
}
- assets = await Promise.all(assetPromises).then(a =>
+ assets = await Promise.all(assetPromises).then(a =>
// dedupe assets
[...new Map(a.flat().map(item => [item.attrs.key, item])).values()].filter(asset => import.meta.env.START_ISLANDS
? false
- : asset.attrs.rel === "modulepreload" &&
+ : (asset.attrs.rel === "modulepreload" || asset.attrs.rel === "stylesheet") &&
!context.assets.find((a) => a.attrs.key === asset.attrs.key)));
});
useAssets(() => (assets.length ? assets.map(m => renderAsset(m)) : undefined));
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.0.102", "version": "1.0.107",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",
+16
View File
@@ -49,6 +49,10 @@ declare module "sst" {
type: "sst.cloudflare.SolidStart" type: "sst.cloudflare.SolidStart"
url: string url: string
} }
ConsoleData: {
name: string
type: "sst.cloudflare.Bucket"
}
Database: { Database: {
database: string database: string
host: string host: string
@@ -65,6 +69,10 @@ declare module "sst" {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
} }
EnterpriseStorage: {
name: string
type: "sst.cloudflare.Bucket"
}
GITHUB_APP_ID: { GITHUB_APP_ID: {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
@@ -96,6 +104,14 @@ declare module "sst" {
LogProcessor: { LogProcessor: {
type: "sst.cloudflare.Worker" type: "sst.cloudflare.Worker"
} }
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: { STRIPE_SECRET_KEY: {
type: "sst.sst.Secret" type: "sst.sst.Secret"
value: string value: string
+2 -1
View File
@@ -9,7 +9,7 @@ export default $config({
home: "cloudflare", home: "cloudflare",
providers: { providers: {
stripe: { stripe: {
apiKey: process.env.STRIPE_SECRET_KEY, apiKey: process.env.STRIPE_SECRET_KEY!,
}, },
planetscale: "0.4.1", planetscale: "0.4.1",
}, },
@@ -19,5 +19,6 @@ export default $config({
await import("./infra/app.js") await import("./infra/app.js")
await import("./infra/console.js") await import("./infra/console.js")
await import("./infra/desktop.js") await import("./infra/desktop.js")
await import("./infra/enterprise.js")
}, },
}) })