Compare commits

..

106 Commits

Author SHA1 Message Date
opencode 125af820d0 release: v1.0.108 2025-11-24 19:16:14 +00:00
GitHub Action 8167e90801 chore: format code 2025-11-24 18:40:01 +00:00
Sebastian Herrlinger 82ebf66cba non-corpo loading spinner 2025-11-24 19:39:09 +01:00
Frankie Seabrook 883ed4d424 docs: add Ollama Cloud provider setup to providers.mdx (#4693) 2025-11-24 10:51:16 -06:00
GitHub Action e6bf1754c3 ignore: update download stats 2025-11-24 2025-11-24 12:04:34 +00:00
Carli Samuele bcb494d5d1 TUI: fix: add null check for user.time in duration calculation (#4679) 2025-11-24 02:06:09 -06:00
Ariane Emory 75c0c0a098 tweak: display MCPs in alphabetic order in the sidebar. (#4680)
Co-authored-by: Github Action <action@github.com>
2025-11-24 02:02:04 -06:00
GitHub Action 840d2694b4 chore: format code 2025-11-24 07:51:59 +00:00
Aiden Cline abdc7b276a fix: persist light vs dark mode 2025-11-24 01:51:13 -06:00
Aiden Cline d4f6deb9ef tweak: modified files sidebar 2025-11-24 01:51:13 -06:00
opencode 502e85b903 release: v1.0.107 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
Albert O'Shea 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 Qi 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 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
Ariane Emory 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
terakael 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 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
Ravi Kumar 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 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
shuv 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 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
Dax Raad b52b7c6ded more flickering fixes 2025-11-22 13:03:44 -05:00
Dax Raad e03a41144a tui: keep assistant footer from crashing after compaction 2025-11-22 18:00:54 +00:00
opencode 37bb07e7a3 release: v1.0.100 2025-11-22 18:00:54 +00:00
Dax Raad 78a6325b64 improve model footer 2025-11-22 12:54:02 -05:00
GitHub Action c96923d2c9 chore: format code 2025-11-22 17:50:32 +00:00
Valerio Di Maggio 59742fbfee Showed end time for agent loop and changed message time to show date if not current day (#4503)
Co-authored-by: GitHub Action <action@github.com>
2025-11-22 11:49:50 -06:00
Dax Raad 2938a25ec5 sync 2025-11-22 16:43:47 +00:00
opencode d163eb3888 release: v1.0.99 2025-11-22 16:43:46 +00:00
Dax Raad 75c29d4d1c summary optimizaitons 2025-11-22 11:32:49 -05:00
Dax Raad e103fb1f93 ci: add Node.js setup to deploy workflow for consistent runtime environment 2025-11-22 10:47:18 -05:00
Dax Raad bd79ff87cc fix missing vite 2025-11-22 10:45:06 -05:00
Dax Raad ac21ec2f46 upgrade bun lock version 2025-11-22 10:42:09 -05:00
Github Action 5bcf017c10 Update Nix flake.lock and hashes 2025-11-22 15:41:29 +00:00
Brendan Allan 85d99198b5 Use devinxi-ed Solid Start (#4635)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
2025-11-22 10:39:25 -05:00
GitHub Action 7f183f7404 ignore: update download stats 2025-11-22 2025-11-22 12:04:02 +00:00
opencode 85284df725 release: v1.0.98 2025-11-22 06:00:23 +00:00
Dax Raad 87054ee983 fix flickering/layout shift during work 2025-11-22 00:49:35 -05:00
GitHub Action 81245c2548 chore: format code 2025-11-22 05:15:55 +00:00
Dax Raad 6f82b321d8 tauri 2025-11-22 00:15:01 -05:00
opencode f4593c6653 release: v1.0.97 2025-11-22 05:02:16 +00:00
Dax Raad 15902cf54d core: add missing system libraries to docker image so the agent starts successfully 2025-11-21 23:52:01 -05:00
GitHub Action d1102c33ac chore: format code 2025-11-22 04:48:52 +00:00
Dax Raad aabbd3383c fix dockerfile 2025-11-21 23:48:09 -05:00
opencode afb55cb7d4 release: v1.0.95 2025-11-22 04:27:20 +00:00
Dax Raad ade794a937 ci: ignore 2025-11-21 23:08:35 -05:00
Dax Raad 34271a82ff release: v1.0.94 2025-11-21 23:06:37 -05:00
Dax Raad b20a31098a sync 2025-11-21 22:58:20 -05:00
Dax Raad b5a039e5ae ci stuff 2025-11-21 22:53:58 -05:00
GitHub Action 986cc0a01c chore: format code 2025-11-22 03:31:29 +00:00
opencode b20fd36c48 release: v1.0.92 2025-11-22 03:31:28 +00:00
Dax Raad e4e6bf66e1 publish tar.gz for linux 2025-11-21 22:24:36 -05:00
opencode 3ae27273c6 release: v1.0.91 2025-11-22 02:19:56 +00:00
Dax Raad eefb3c43dd fix arg parsing 2025-11-21 21:13:43 -05:00
Github Action cc229e726e Update Nix flake.lock and hashes 2025-11-22 01:43:03 +00:00
Dax 49408c00e9 enterprise (#4617)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-11-21 20:41:27 -05:00
1335 changed files with 10729 additions and 2511 deletions
+4
View File
@@ -17,6 +17,10 @@ jobs:
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Check for duplicate issues
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
+7
View File
@@ -61,6 +61,13 @@ jobs:
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish
run: |
./script/publish.ts
+3
View File
@@ -1,6 +1,9 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "http://localhost:3000",
// },
"provider": {
"opencode": {
"options": {
+3
View File
@@ -147,3 +147,6 @@
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
+732 -681
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1763618868,
"narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"lastModified": 1763806073,
"narHash": "sha256-FHsEKDvfWpzdADWj99z7vBk4D716Ujdyveo5+A048aI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"rev": "878e468e02bfabeda08c79250f7ad583037f2227",
"type": "github"
},
"original": {
+3
View File
@@ -116,6 +116,8 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
// CONSOLE
////////////////
const bucket = new sst.cloudflare.Bucket("ConsoleData")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
@@ -132,6 +134,7 @@ new sst.cloudflare.x.SolidStart("Console", {
domain,
path: "packages/console/app",
link: [
bucket,
database,
AUTH_API_URL,
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"),
}
+21 -5
View File
@@ -25,7 +25,11 @@ elif [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
filename="$APP-$os-$arch.zip"
if [ "$os" = "linux" ]; then
filename="$APP-$os-$arch.tar.gz"
else
filename="$APP-$os-$arch.zip"
fi
case "$filename" in
@@ -44,9 +48,16 @@ case "$filename" in
;;
esac
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
if [ "$os" = "linux" ]; then
if ! command -v tar >/dev/null 2>&1; then
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
exit 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
INSTALL_DIR=$HOME/.opencode/bin
@@ -197,7 +208,12 @@ download_and_install() {
curl -# -L -o "$filename" "$url"
fi
unzip -q "$filename"
if [ "$os" = "linux" ]; then
tar -xzf "$filename"
else
unzip -q "$filename"
fi
mv opencode "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode"
cd .. && rm -rf opencodetmp
+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-bPiUpHGtgwVxHQHXBprpc6fFeJqW6/x7dwtQZBq29oU="
"nodeModules": "sha256-m7hL9Uzqk+oa2/FtgkzEPgi+m/VZP1SvjpgYHNjiS1c="
}
+60 -35
View File
@@ -1,4 +1,4 @@
{ lib, stdenv, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
{ lib, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
args:
let
scripts = args.scripts;
@@ -28,64 +28,89 @@ stdenvNoCC.mkDerivation (finalAttrs: {
makeBinaryWrapper
];
configurePhase = ''
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
runHook postConfigure
'';
env.MODELS_DEV_API_JSON = args.modelsDev;
env.OPENCODE_VERSION = args.version;
env.OPENCODE_CHANNEL = "stable";
dontConfigure = true;
buildPhase = ''
runHook preBuild
cp ${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}
bun --bun bun-build.ts
chmod -R u+w ./node_modules
mkdir -p ./node_modules/@opencode-ai
rm -f ./node_modules/@opencode-ai/{script,sdk,plugin}
ln -s $(pwd)/../../packages/script ./node_modules/@opencode-ai/script
ln -s $(pwd)/../../packages/sdk/js ./node_modules/@opencode-ai/sdk
ln -s $(pwd)/../../packages/plugin ./node_modules/@opencode-ai/plugin
cp ${./bundle.ts} ./bundle.ts
chmod +x ./bundle.ts
bun run ./bundle.ts
)
runHook postBuild
'';
dontStrip = true;
installPhase = ''
runHook preInstall
cd packages/opencode
if [ ! -f opencode ]; then
echo "ERROR: opencode binary not found in $(pwd)"
ls -la
exit 1
fi
if [ ! -f opencode-worker.js ]; then
echo "ERROR: opencode worker bundle not found in $(pwd)"
ls -la
if [ ! -d dist ]; then
echo "ERROR: dist directory missing after bundle step"
exit 1
fi
install -Dm755 opencode $out/bin/opencode
install -Dm644 opencode-worker.js $out/bin/opencode-worker.js
if [ -f opencode-assets.manifest ]; then
while IFS= read -r asset; do
[ -z "$asset" ] && continue
if [ ! -f "$asset" ]; then
echo "ERROR: referenced asset \"$asset\" missing"
exit 1
fi
install -Dm644 "$asset" "$out/bin/$(basename "$asset")"
done < opencode-assets.manifest
mkdir -p $out/lib/opencode
cp -r dist $out/lib/opencode/
chmod -R u+w $out/lib/opencode/dist
# Select bundled worker assets deterministically (sorted find output)
worker_file=$(find "$out/lib/opencode/dist" -type f \( -path '*/tui/worker.*' -o -name 'worker.*' \) | sort | head -n1)
parser_worker_file=$(find "$out/lib/opencode/dist" -type f -name 'parser.worker.*' | sort | head -n1)
if [ -z "$worker_file" ]; then
echo "ERROR: bundled worker not found"
exit 1
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
'';
postFixup = ''
wrapProgram "$out/bin/opencode" --prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]}
postInstall = ''
for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do
if [ -d "$pkg" ]; then
pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/')
ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \
$out/lib/opencode/node_modules/@opentui/$pkgName
fi
done
'';
meta = {
+24 -7
View File
@@ -24,15 +24,13 @@ for (const entry of directories) {
if (!info.isDirectory()) {
continue
}
const marker = entry.lastIndexOf("@")
if (marker <= 0) {
const parsed = parseEntry(entry)
if (!parsed) {
continue
}
const slug = entry.slice(0, marker).replace(/\+/g, "/")
const version = entry.slice(marker + 1)
const list = versions.get(slug) ?? []
list.push({ dir: full, version, label: entry })
versions.set(slug, list)
const list = versions.get(parsed.name) ?? []
list.push({ dir: full, version: parsed.version, label: entry })
versions.set(parsed.name, list)
}
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 })
const linkPath = join(parent, leaf)
const desired = join(entry.dir, "node_modules", slug)
const exists = await lstat(desired)
.then((info) => info.isDirectory())
.catch(() => false)
if (!exists) {
continue
}
const relativeTarget = relative(parent, desired)
const resolved = relativeTarget.length === 0 ? "." : relativeTarget
await rm(linkPath, { recursive: true, force: true })
@@ -94,3 +98,16 @@ for (const line of rewrites.slice(0, 20)) {
if (rewrites.length > 20) {
console.log(" ...")
}
function parseEntry(label: string) {
const marker = label.startsWith("@") ? label.indexOf("@", 1) : label.indexOf("@")
if (marker <= 0) {
return null
}
const name = label.slice(0, marker).replace(/\+/g, "/")
const version = label.slice(marker + 1)
if (!name || !version) {
return null
}
return { name, version }
}
+39
View File
@@ -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)
+11 -8
View File
@@ -9,7 +9,8 @@
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'"
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'"
},
"workspaces": {
"packages": [
@@ -23,29 +24,33 @@
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@tsconfig/node22": "22.0.2",
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.4.4",
"@solidjs/meta": "0.29.4",
"@pierre/precision-diffs": "0.5.4",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.97",
"hono": "4.7.10",
"hono-openapi": "1.1.1",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251014.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"solid-js": "1.9.9",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
"vite": "7.1.4",
"vite-plugin-solid": "2.11.8"
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10"
}
},
"devDependencies": {
@@ -56,6 +61,7 @@
"turbo": "2.5.6"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*"
},
@@ -76,9 +82,6 @@
"tree-sitter-bash",
"web-tree-sitter"
],
"patchedDependencies": {
"@solidjs/start@1.1.7": "patches/@solidjs%2Fstart@1.1.7.patch"
},
"overrides": {
"@types/bun": "catalog:",
"@types/node": "catalog:"
-1
View File
@@ -3,7 +3,6 @@ dist
.output
.vercel
.netlify
.vinxi
app.config.timestamp_*.js
# Environment
-23
View File
@@ -1,23 +0,0 @@
import { defineConfig } from "@solidjs/start/config"
export default defineConfig({
middleware: "./src/middleware.ts",
vite: {
server: {
allowedHosts: true,
},
build: {
rollupOptions: {
external: ["cloudflare:workers"],
},
minify: false,
},
},
server: {
compatibilityDate: "2024-09-19",
preset: "cloudflare_module",
cloudflare: {
nodeCompat: true,
},
},
})
+12 -9
View File
@@ -1,15 +1,16 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.108",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vinxi dev --host 0.0.0.0",
"dev": "vite dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "1.0.90"
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vite start"
},
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
"@jsx-email/render": "1.1.1",
"@kobalte/core": "catalog:",
@@ -17,17 +18,19 @@
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-mail": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
"vite": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
"wrangler": "4.50.0"
},
"engines": {
"node": ">=22"
@@ -1,4 +1,4 @@
import { useSession } from "vinxi/http"
import { useSession } from "@solidjs/start/http"
export interface AuthSession {
account?: Record<
+4
View File
@@ -1 +1,5 @@
/// <reference types="@solidjs/start/env" />
export declare module "@solidjs/start/server" {
export type APIEvent = { request: Request }
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { defineMiddleware } from "vinxi/http"
import { createMiddleware } from "@solidjs/start/middleware"
export default defineMiddleware({
export default createMiddleware({
onBeforeResponse() {},
})
@@ -19,6 +19,7 @@ export async function GET(input: APIEvent) {
return {
...value,
account: {
...value.account,
[id]: {
id,
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)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import "./index.css"
import { Title, Meta, Link } from "@solidjs/meta"
import { HttpHeader } from "@solidjs/start"
// import { HttpHeader } from "@solidjs/start"
import video from "../asset/lander/opencode-min.mp4"
import videoPoster from "../asset/lander/opencode-poster.png"
import { IconCopy, IconCheck } from "../component/icon"
@@ -42,7 +42,7 @@ export default function Home() {
return (
<main data-page="opencode">
<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" />*/}
<Title>OpenCode | The AI coding agent built for the terminal</Title>
<Link rel="canonical" href={config.baseUrl} />
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
@@ -12,6 +12,7 @@
[data-slot="item"] {
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 { useAuthSession } from "~/context/auth.session"
import { Dropdown } from "~/component/dropdown"
@@ -17,18 +17,15 @@ const logout = action(async () => {
event!.locals.actor = undefined
return val
})
throw redirect("/zen")
})
}, "auth.logout")
export function UserMenu(props: { email: string | null | undefined }) {
return (
<div data-component="user-menu">
<Dropdown trigger={props.email ?? ""} align="right">
<form action={logout} method="post">
<button type="submit" formaction={logout} data-slot="item">
Logout
</button>
</form>
<a href="/auth/logout" data-slot="item">
Logout
</a>
</Dropdown>
</div>
)
@@ -1,7 +1,7 @@
import "./index.css"
import { createAsync, query, redirect } from "@solidjs/router"
import { Title, Meta, Link } from "@solidjs/meta"
import { HttpHeader } from "@solidjs/start"
// import { HttpHeader } from "@solidjs/start"
import zenLogoLight from "../../asset/zen-ornate-light.svg"
import { config } from "~/config"
import zenLogoDark from "../../asset/zen-ornate-dark.svg"
@@ -21,20 +21,21 @@ import { getLastSeenWorkspaceID } from "../workspace/common"
const checkLoggedIn = query(async () => {
"use server"
const workspaceID = await getLastSeenWorkspaceID()
const workspaceID = await getLastSeenWorkspaceID().catch(() => {})
if (workspaceID) throw redirect(`/workspace/${workspaceID}`)
}, "checkLoggedIn.get")
export default function Home() {
createAsync(() => checkLoggedIn())
const loggedin = createAsync(() => checkLoggedIn())
return (
<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" />*/}
<Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title>
<Link rel="canonical" href={`${config.baseUrl}/zen`} />
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
<Meta property="og:image" content="/social-share-zen.png" />
<Meta name="twitter:image" content="/social-share-zen.png" />
<Meta name="opencode:auth" content={loggedin() ? "true" : "false"} />
<div data-component="container">
<Header zen />
@@ -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 { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter } from "./rateLimiter"
import { createDataDumper } from "./dataDumper"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@@ -48,16 +49,19 @@ export async function handler(
try {
const url = input.request.url
const body = await input.request.json()
const ip = input.request.headers.get("x-real-ip") ?? ""
const model = opts.parseModel(url, body)
const isStream = opts.parseIsStream(url, body)
const ip = input.request.headers.get("x-real-ip") ?? ""
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
const requestId = input.request.headers.get("x-opencode-request") ?? ""
logger.metric({
is_tream: isStream,
session: input.request.headers.get("x-opencode-session"),
request: input.request.headers.get("x-opencode-request"),
session: sessionId,
request: requestId,
})
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId)
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
await rateLimiter?.check()
@@ -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
const resHeaders = new Headers()
@@ -126,6 +134,8 @@ export async function handler(
const body = JSON.stringify(responseConverter(json))
logger.metric({ response_length: body.length })
logger.debug("RESPONSE: " + body)
dataDumper?.provideResponse(body)
dataDumper?.flush()
await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
await reload(authInfo)
@@ -155,6 +165,7 @@ export async function handler(
response_length: responseLength,
"timestamp.last_byte": Date.now(),
})
dataDumper?.flush()
await rateLimiter?.track()
const usage = usageParser.retrieve()
if (usage) {
@@ -174,6 +185,7 @@ export async function handler(
}
responseLength += value.length
buffer += decoder.decode(value, { stream: true })
dataDumper?.provideStream(buffer)
const parts = buffer.split(providerInfo.streamSeparator)
buffer = parts.pop() ?? ""
+1 -1
View File
@@ -12,7 +12,7 @@
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vinxi/types/client"],
"types": ["vite/client"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, PluginOption } from "vite"
import { solidStart } from "@solidjs/start/config"
import { nitro } from "nitro/vite"
export default defineConfig({
plugins: [
solidStart() as PluginOption,
nitro({
compatibilityDate: "2024-09-19",
preset: "cloudflare_module",
cloudflare: {
nodeCompat: true,
},
}),
],
server: {
allowedHosts: true,
},
build: {
rollupOptions: {
external: ["cloudflare:workers"],
},
minify: false,
},
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.90",
"version": "1.0.108",
"private": true,
"type": "module",
"dependencies": {
+1
View File
@@ -32,6 +32,7 @@ export namespace ZenData {
model: z.string(),
weight: z.number().optional(),
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
}),
),
})
+10
View File
@@ -74,6 +74,14 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -112,6 +120,8 @@ declare module "sst" {
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.90",
"version": "1.0.108",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+10
View File
@@ -74,6 +74,14 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -112,6 +120,8 @@ declare module "sst" {
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.90",
"version": "1.0.108",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -1,4 +1,5 @@
import { env } from "cloudflare:workers"
export { waitUntil } from "cloudflare:workers"
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 Cloudflare from "cloudflare"
export const waitUntil = async (fn: () => Promise<void>) => {
await fn()
}
export const Resource = new Proxy(
{},
{
get(_target, prop: keyof typeof ResourceBase) {
const value = ResourceBase[prop]
// @ts-ignore
if ("type" in value && value.type === "sst.cloudflare.Kv") {
const client = new Cloudflare({
apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
})
if ("type" in 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],
if (value.type === "sst.cloudflare.Bucket") {
return {
put: async () => {},
}
}
// @ts-ignore
if (value.type === "sst.cloudflare.Kv") {
const client = new Cloudflare({
apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
})
// @ts-ignore
const namespaceId = value.namespaceId
const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
return {
get: (k: string | string[]) => {
const isMulti = Array.isArray(k)
return client.kv.namespaces
.bulkGet(namespaceId, {
keys: Array.isArray(k) ? k : [k],
account_id: accountId,
})
.then((result) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k]))
},
put: (k: string, v: string, opts?: KVNamespacePutOptions) =>
client.kv.namespaces.values.update(namespaceId, k, {
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,
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,
}
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,
}
}),
}
}
}
return value
+10
View File
@@ -74,6 +74,14 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -112,6 +120,8 @@ declare module "sst" {
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.90",
"version": "1.0.108",
"description": "",
"type": "module",
"scripts": {
@@ -14,7 +14,7 @@
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/luxon": "3.7.1",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
@@ -26,6 +26,7 @@
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/event-bus": "1.1.2",
@@ -33,7 +34,7 @@
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.3",
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
@@ -1,6 +1,7 @@
import { useLocal, type LocalFile } from "@/context/local"
import { Tooltip } from "@opencode-ai/ui"
import { Collapsible, FileIcon } from "@/ui"
import { Collapsible } from "@/ui"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
@@ -1,8 +1,6 @@
import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
@@ -11,6 +9,13 @@ import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "
import { useSDK } from "@/context/sdk"
import { useNavigate } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
interface PromptInputProps {
class?: string
@@ -184,8 +189,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const range = selection.getRangeAt(0)
if (atMatch) {
let node: Node | null = range.startContainer
let offset = range.startOffset
// let node: Node | null = range.startContainer
// let offset = range.startOffset
let runningLength = 0
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
@@ -448,7 +453,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{(i) => (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0" />
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
<Show when={i.release_date}>
@@ -1,104 +0,0 @@
import { useSession } from "@/context/session"
import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils"
import { Accordion, Button, Diff, DiffChanges, Icon, IconButton, Tooltip } from "@opencode-ai/ui"
import { For, Match, Show, Switch } from "solid-js"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { createStore } from "solid-js/store"
import { useLayout } from "@/context/layout"
export const SessionReview = (props: { split?: boolean; class?: string; hideExpand?: boolean }) => {
const layout = useLayout()
const session = useSession()
const [store, setStore] = createStore({
open: session.diffs().map((d) => d.file),
})
const handleChange = (open: string[]) => {
setStore("open", open)
}
const handleExpandOrCollapseAll = () => {
if (store.open.length > 0) {
setStore("open", [])
} else {
setStore(
"open",
session.diffs().map((d) => d.file),
)
}
}
return (
<div
classList={{
"flex flex-col gap-3 h-full overflow-y-auto no-scrollbar": true,
[props.class ?? ""]: !!props.class,
}}
>
<div class="sticky top-0 z-20 bg-background-stronger h-8 shrink-0 flex justify-between items-center self-stretch">
<div class="text-14-medium text-text-strong">Session changes</div>
<div class="flex items-center gap-x-4 pr-px">
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
<Switch>
<Match when={store.open.length > 0}>Collapse all</Match>
<Match when={true}>Expand all</Match>
</Switch>
</Button>
<Show when={!props.hideExpand}>
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
layout.review.tab()
session.layout.setActiveTab("review")
}}
/>
</Tooltip>
</Show>
</div>
</div>
<Accordion multiple value={store.open} onChange={handleChange}>
<For each={session.diffs()}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader class="top-11 data-expanded:before:-top-11">
<Accordion.Trigger class="bg-background-stronger">
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon node={{ path: diff.file, type: "file" }} class="shrink-0 size-4" />
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">{getDirectory(diff.file)}&lrm;</span>
</Show>
<span class="text-text-strong shrink-0">{getFilename(diff.file)}</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Diff
diffStyle={props.split ? "split" : "unified"}
before={{
name: diff.file!,
contents: diff.before!,
}}
after={{
name: diff.file!,
contents: diff.after!,
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
)
}
@@ -1,17 +0,0 @@
import { Accordion } from "@opencode-ai/ui"
import { ParentProps } from "solid-js"
export function StickyAccordionHeader(props: ParentProps<{ class?: string }>) {
return (
<Accordion.Header
classList={{
"sticky top-0 data-expanded:z-10": true,
"data-expanded:before:content-[''] data-expanded:before:z-[-10]": true,
"data-expanded:before:absolute data-expanded:before:inset-0 data-expanded:before:bg-background-stronger": true,
[props.class ?? ""]: !!props.class,
}}
>
{props.children}
</Accordion.Header>
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createSimpleContext } from "./helper"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
+2 -2
View File
@@ -14,8 +14,8 @@ import type {
SessionStatus,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@/utils/binary"
import { createSimpleContext } from "./helper"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
type State = {
-25
View File
@@ -1,25 +0,0 @@
import { createContext, Show, useContext, type ParentProps } from "solid-js"
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
name: string
init: ((input: Props) => T) | (() => T)
}) {
const ctx = createContext<T>()
return {
provider: (props: ParentProps<Props>) => {
const init = input.init(props)
return (
// @ts-expect-error
<Show when={init.ready === undefined || init.ready === true}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const value = useContext(ctx)
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
return value
},
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { createStore } from "solid-js/store"
import { createMemo } from "solid-js"
import { createSimpleContext } from "./helper"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync"
+1 -1
View File
@@ -2,7 +2,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
import { uniqueBy } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
import { createSimpleContext } from "./helper"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { base64Encode } from "@/utils"
+1 -1
View File
@@ -1,5 +1,5 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createSimpleContext } from "./helper"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
+5 -5
View File
@@ -1,11 +1,11 @@
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "./sync"
import { makePersisted } from "@solid-primitives/storage"
import { TextSelection } from "./local"
import { pipe, sumBy } from "remeda"
import { AssistantMessage } from "@opencode-ai/sdk"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@/utils"
@@ -60,7 +60,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
})
const status = createMemo(
() =>
sync.data.session_status[params.id] ?? {
sync.data.session_status[params.id ?? ""] ?? {
type: "idle",
},
)
@@ -123,8 +123,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
user: userMessages,
last: lastUserMessage,
active: activeMessage,
setActive(id: string | undefined) {
setStore("messageId", id)
setActive(message: UserMessage | undefined) {
setStore("messageId", message?.id)
},
},
usage: {
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Part } from "@opencode-ai/sdk"
import { produce } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@/utils/binary"
import { createSimpleContext } from "./helper"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
+2 -1
View File
@@ -3,7 +3,8 @@ import "@/index.css"
import { render } from "solid-js/web"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Fonts, MarkedProvider } from "@opencode-ai/ui"
import { Fonts } from "@opencode-ai/ui/fonts"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
@@ -1,22 +1,31 @@
import { createMemo, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk"
import { SyncProvider } from "@/context/sync"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode } from "@/utils"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
export default function Layout(props: ParentProps) {
const params = useParams()
const sync = useGlobalSync()
const directory = createMemo(() => {
const decoded = base64Decode(params.dir)
const decoded = base64Decode(params.dir!)
return sync.data.projects.find((x) => x.worktree === decoded)?.worktree ?? "/"
})
return (
<SDKProvider directory={directory()}>
<SyncProvider>
<LocalProvider>{props.children}</LocalProvider>
{iife(() => {
const sync = useSync()
return (
<DataProvider data={sync.data}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
})}
</SyncProvider>
</SDKProvider>
)
+1 -1
View File
@@ -2,7 +2,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { base64Encode, getFilename } from "@/utils"
import { For } from "solid-js"
import { A } from "@solidjs/router"
import { Button } from "@opencode-ai/ui"
import { Button } from "@opencode-ai/ui/button"
export default function Home() {
const sync = useGlobalSync()
+7 -1
View File
@@ -1,10 +1,16 @@
import { Button, Tooltip, DiffChanges, IconButton, Mark, Icon, Collapsible } from "@opencode-ai/ui"
import { createMemo, For, ParentProps, Show } from "solid-js"
import { DateTime } from "luxon"
import { A, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { base64Encode, getFilename } from "@/utils"
import { Mark } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
export default function Layout(props: ParentProps) {
const params = useParams()
+47 -316
View File
@@ -1,38 +1,21 @@
import {
SelectDialog,
IconButton,
Tabs,
Icon,
Accordion,
Diff,
Collapsible,
DiffChanges,
Message,
Typewriter,
Card,
Code,
Tooltip,
ProgressCircle,
} from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import { MessageProgress } from "@/components/message-progress"
import {
For,
onCleanup,
onMount,
Show,
Match,
Switch,
createSignal,
createEffect,
createMemo,
createResource,
} from "solid-js"
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
import { PromptInput } from "@/components/prompt-input"
import { DateTime } from "luxon"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Code } from "@opencode-ai/ui/code"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import {
DragDropProvider,
DragDropSensors,
@@ -45,14 +28,8 @@ import {
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
import { Markdown } from "@opencode-ai/ui"
import { Spinner } from "@/components/spinner"
import { useSession } from "@/context/session"
import { StickyAccordionHeader } from "@/components/sticky-accordion-header"
import { SessionReview } from "@/components/session-review"
import { useLayout } from "@/context/layout"
import { createSessionSeen } from "@/hooks/create-session-seen"
export default function Page() {
const layout = useLayout()
@@ -65,7 +42,6 @@ export default function Page() {
activeDraggable: undefined as string | undefined,
})
let inputRef!: HTMLDivElement
let messageScrollElement!: HTMLDivElement
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
@@ -280,6 +256,8 @@ export default function Page() {
return typeof draggable.id === "string" ? draggable.id : undefined
}
const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
return (
<div class="relative bg-background-base size-full overflow-x-hidden">
<DragDropProvider
@@ -355,286 +333,25 @@ export default function Page() {
flex: layout.review.state() === "pane",
}}
>
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-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>
<Match when={session.id}>
<div
classList={{
"flex-1 min-h-0 pb-20": true,
"flex items-start justify-start": layout.review.state() === "pane",
}}
>
<div class="flex items-start justify-start h-full min-h-0">
<Show when={session.messages.user().length > 1}>
{(_) => {
const expanded = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
return (
<ul
role="list"
classList={{
"mr-8 shrink-0 flex flex-col items-start": true,
"absolute right-full w-60 mt-3 @7xl:gap-2 @7xl:mt-1": expanded(),
"mt-3": !expanded(),
}}
>
<For each={session.messages.user()}>
{(message) => {
const working = createMemo(
() => message.id === session.messages.last()?.id && session.working(),
)
const handleClick = () => session.messages.setActive(message.id)
return (
<li
classList={{
"group/li flex items-center self-stretch justify-end": true,
"@7xl:justify-start": expanded(),
}}
>
<Tooltip
placement="right"
gutter={8}
value={
<div class="flex items-center gap-2">
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
{message.summary?.title}
</div>
}
>
<button
data-active={session.messages.active()?.id === message.id}
onClick={handleClick}
classList={{
"group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
"data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
"@7xl:hidden": expanded(),
}}
>
<div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
</button>
</Tooltip>
<button
classList={{
"hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
"@7xl:flex": expanded(),
}}
onClick={handleClick}
>
<Switch>
<Match when={working()}>
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
</Match>
<Match when={true}>
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
</Match>
</Switch>
<div
data-active={session.messages.active()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
<Show when={message.summary?.title} fallback="New message">
{message.summary?.title}
</Show>
</div>
</button>
</li>
)
}}
</For>
</ul>
)
}}
<MessageNav
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>
<div ref={messageScrollElement} class="grow size-full min-w-0 overflow-y-auto no-scrollbar">
<For each={session.messages.user()}>
{(message) => {
const isActive = createMemo(() => session.messages.active()?.id === message.id)
const titleSeen = createSessionSeen(`message-title-${message.id}`)
const contentSeen = createSessionSeen(`message-content-${message.id}`)
const [titled, setTitled] = createSignal(titleSeen())
const assistantMessages = createMemo(() => {
if (!session.id) return []
return sync.data.message[session.id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const [detailsExpanded, setDetailsExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id])
const hasToolPart = createMemo(() =>
assistantMessages()
?.flatMap((m) => sync.data.part[m.id])
.some((p) => p?.type === "tool"),
)
const working = createMemo(
() => message.id === session.messages.last()?.id && session.working(),
)
const initialCompleted = !(message.id === session.messages.last()?.id && session.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 = !working()
setTimeout(() => setCompleted(completed), 1200)
})
return (
<Show when={isActive()}>
<div
data-message={message.id}
class="flex flex-col items-start self-stretch gap-8 pb-20"
>
{/* Title */}
<div class="flex items-center gap-2 self-stretch sticky top-0 bg-background-stronger z-20 h-8">
<div class="w-full text-14-medium text-text-strong">
<Show
when={titled()}
fallback={
<Typewriter
as="h1"
text={message.summary?.title}
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
/>
}
>
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
{message.summary?.title}
</h1>
</Show>
</div>
</div>
<Message message={message} parts={parts()} />
{/* Summary */}
<Show when={completed()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">
<Switch>
<Match when={message.summary?.diffs?.length}>Summary</Match>
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={message.summary?.body}>
{(summary) => (
<Markdown
classList={{
"text-14-regular": !!message.summary?.diffs?.length,
"[&>*]:fade-up-text": !message.summary?.diffs?.length && !contentSeen(),
}}
text={summary()}
/>
)}
</Show>
</div>
<Accordion class="w-full" multiple>
<For each={message.summary?.diffs ?? []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader class="top-10 data-expanded:before:-top-10">
<Accordion.Trigger>
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon
node={{ path: diff.file, type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base truncate-start">
{getDirectory(diff.file)}&lrm;
</span>
</Show>
<span class="text-text-strong shrink-0">
{getFilename(diff.file)}
</span>
</div>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="max-h-60 overflow-y-auto no-scrollbar">
<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="text-text-on-critical-base">
{error()?.data?.message as string}
</Card>
</Show>
{/* Response */}
<div class="w-full">
<Switch>
<Match when={!completed()}>
<MessageProgress assistantMessages={assistantMessages} done={!working()} />
</Match>
<Match when={completed() && hasToolPart()}>
<Collapsible
variant="ghost"
open={detailsExpanded()}
onOpenChange={setDetailsExpanded}
>
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
<div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium">
<Switch>
<Match when={detailsExpanded()}>Hide details</Match>
<Match when={!detailsExpanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="w-full flex flex-col items-start self-stretch gap-3">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => sync.data.part[assistantMessage.id])
return <Message message={assistantMessage} parts={parts()} />
}}
</For>
<Show when={error()}>
<Card variant="error" class="text-text-on-critical-base">
{error()?.data?.message as string}
</Card>
</Show>
</div>
</Collapsible.Content>
</Collapsible>
</Match>
</Switch>
</div>
</div>
</Show>
)
}}
</For>
</div>
<SessionTurn
sessionID={session.id!}
messageID={session.messages.active()?.id!}
classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20" }}
/>
</div>
</Match>
<Match when={true}>
@@ -673,7 +390,21 @@ export default function Page() {
"relative grow px-6 py-3 flex-1 min-h-0 border-l border-border-weak-base": true,
}}
>
<SessionReview />
<SessionReview
diffs={session.diffs()}
actions={
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
layout.review.tab()
session.layout.setActiveTab("review")
}}
/>
</Tooltip>
}
/>
</div>
</Show>
</div>
@@ -685,7 +416,7 @@ export default function Page() {
"relative px-6 py-3 flex-1 min-h-0 overflow-hidden": true,
}}
>
<SessionReview split hideExpand class="pb-40" />
<SessionReview diffs={session.diffs()} split class="pb-40" />
</div>
</Tabs.Content>
</Show>
@@ -740,7 +471,7 @@ export default function Page() {
</DragOverlay>
</DragDropProvider>
<Show when={session.layout.tabs.active}>
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-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
ref={(el) => {
inputRef = el
+1 -1
View File
@@ -1,7 +1,7 @@
import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible"
import { Icon, IconProps } from "@opencode-ai/ui/icon"
import { splitProps } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
import { Icon, type IconProps } from "@opencode-ai/ui"
export interface CollapsibleProps extends ComponentProps<typeof KobalteCollapsible> {}
export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {}
-1
View File
@@ -4,4 +4,3 @@ export {
type CollapsibleTriggerProps,
type CollapsibleContentProps,
} from "./collapsible"
export { FileIcon, type FileIconProps } from "./file-icon"
+2 -11
View File
@@ -2,7 +2,6 @@ import { defineConfig } from "vite"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import path from "path"
import { iconsSpritesheet } from "vite-plugin-icons-spritesheet"
export default defineConfig({
resolve: {
@@ -10,18 +9,10 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
plugins: [
tailwindcss(),
solidPlugin(),
iconsSpritesheet({
withTypes: true,
inputDir: "src/assets/file-icons",
outputDir: "src/ui/file-icons",
formatter: "prettier",
}),
],
plugins: [tailwindcss(), solidPlugin()] as any,
server: {
host: "0.0.0.0",
allowedHosts: true,
port: 3000,
},
build: {
+26
View File
@@ -0,0 +1,26 @@
dist
.wrangler
.output
.vercel
.netlify
# Environment
.env
.env*.local
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db
+32
View File
@@ -0,0 +1,32 @@
# SolidStart
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
## Creating a project
```bash
# create a new project in the current directory
npm init solid@latest
# create a new project in my-app
npm init solid@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.108",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev",
"build": "vite build",
"build:cloudflare": "OPENCODE_DEPLOYMENT_TARGET=cloudflare vite build",
"start": "vite start"
},
"dependencies": {
"@opencode-ai/util": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@solidjs/meta": "catalog:",
"hono": "catalog:",
"hono-openapi": "catalog:",
"luxon": "catalog:",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tailwindcss/vite": "catalog:",
"@typescript/native-preview": "catalog:",
"@types/luxon": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
},
"engines": {
"node": ">=22"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

+18
View File
@@ -0,0 +1,18 @@
@import "@opencode-ai/ui/styles/tailwind";
:root {
--background-rgb: 214, 219, 220;
--foreground-rgb: 0, 0, 0;
}
@media (prefers-color-scheme: dark) {
:root {
--background-rgb: 0, 0, 0;
--foreground-rgb: 255, 255, 255;
}
}
body {
/* background: rgb(var(--background-rgb)); */
/* color: rgb(var(--foreground-rgb)); */
}
+28
View File
@@ -0,0 +1,28 @@
import { Router } from "@solidjs/router"
import { FileRoutes } from "@solidjs/start/router"
import { Suspense } from "solid-js"
import { Fonts } from "@opencode-ai/ui/fonts"
import { MetaProvider } from "@solidjs/meta"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import "./app.css"
export default function App() {
return (
<Router
root={(props) => (
<>
<Suspense>
<MarkedProvider>
<MetaProvider>
<Fonts />
{props.children}
</MetaProvider>
</MarkedProvider>
</Suspense>
</>
)}
>
<FileRoutes />
</Router>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk"
import { fn } from "@opencode-ai/util/fn"
import { iife } from "@opencode-ai/util/iife"
import z from "zod"
import { Storage } from "./storage"
export namespace Share {
export const Info = z.object({
id: z.string(),
secret: z.string(),
})
export type Info = z.infer<typeof Info>
export const Data = z.discriminatedUnion("type", [
z.object({
type: z.literal("session"),
data: z.custom<Session>(),
}),
z.object({
type: z.literal("message"),
data: z.custom<Message>(),
}),
z.object({
type: z.literal("part"),
data: z.custom<Part>(),
}),
z.object({
type: z.literal("session_diff"),
data: z.custom<FileDiff[]>(),
}),
z.object({
type: z.literal("session_status"),
data: z.custom<SessionStatus>(),
}),
])
export type Data = z.infer<typeof Data>
export const create = fn(Info.pick({ id: true }), async (body) => {
const info: Info = {
id: body.id,
secret: crypto.randomUUID(),
}
const exists = await get(info.id)
if (exists) throw new Errors.AlreadyExists(info.id)
await Storage.write(["share", info.id], info)
console.log("created share", info.id)
return info
})
async function get(sessionID: string) {
return Storage.read<Info>(["share", sessionID])
}
export const remove = fn(Info.pick({ id: true, secret: true }), async (body) => {
const share = await get(body.id)
if (!share) throw new Errors.NotFound(body.id)
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
await Storage.remove(["share", body.id])
const list = await Storage.list(["share_data", body.id])
for (const item of list) {
await Storage.remove(item)
}
})
export async function data(sessionID: string) {
const list = await Storage.list(["share_data", sessionID])
const promises = []
for (const item of list) {
promises.push(
iife(async () => {
const [, , type] = item
return {
type: type as any,
data: await Storage.read<any>(item),
} as Data
}),
)
}
return await Promise.all(promises)
}
export const sync = fn(
z.object({
share: Info,
data: Data.array(),
}),
async (input) => {
const share = await get(input.share.id)
if (!share) throw new Errors.NotFound(input.share.id)
if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id)
const promises = []
for (const item of input.data) {
promises.push(
iife(async () => {
switch (item.type) {
case "session":
await Storage.write(["share_data", input.share.id, "session"], item.data)
break
case "message":
await Storage.write(["share_data", input.share.id, "message", item.data.id], item.data)
break
case "part":
await Storage.write(
["share_data", input.share.id, "part", item.data.messageID, item.data.id],
item.data,
)
break
case "session_diff":
await Storage.write(["share_data", input.share.id, "session_diff"], item.data)
break
case "session_status":
await Storage.write(["share_data", input.share.id, "session_status"], item.data)
break
}
}),
)
}
await Promise.all(promises)
},
)
export const Errors = {
NotFound: class extends Error {
constructor(public id: string) {
super(`Share not found: ${id}`)
}
},
InvalidSecret: class extends Error {
constructor(public id: string) {
super(`Share secret invalid: ${id}`)
}
},
AlreadyExists: class extends Error {
constructor(public id: string) {
super(`Share already exists: ${id}`)
}
},
}
}
+134
View File
@@ -0,0 +1,134 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
} from "@aws-sdk/client-s3"
import { lazy } from "@opencode-ai/util/lazy"
export namespace Storage {
export interface Adapter {
read(path: string): Promise<string | undefined>
write(path: string, value: string): Promise<void>
remove(path: string): Promise<void>
list(prefix: string): Promise<string[]>
}
function createAdapter(client: S3Client, bucket: string): Adapter {
return {
async read(path: string): Promise<string | undefined> {
try {
console.log("reading", bucket, path)
const command = new GetObjectCommand({
Bucket: bucket,
Key: path,
})
const response = await client.send(command)
if (!response.Body) return undefined
return response.Body.transformToString()
} catch (e: any) {
if (e.name === "NoSuchKey") return undefined
throw e
}
},
async write(path: string, value: string): Promise<void> {
const command = new PutObjectCommand({
Bucket: bucket,
Key: path,
Body: value,
ContentType: "application/json",
})
await client.send(command)
},
async remove(path: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: bucket,
Key: path,
})
await client.send(command)
},
async list(prefix: string): Promise<string[]> {
const command = new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
})
const response = await client.send(command)
return response.Contents?.map((c) => c.Key!) || []
},
}
}
function s3(): Adapter {
const bucket = process.env.OPENCODE_STORAGE_BUCKET!
const client = new S3Client({
region: process.env.OPENCODE_STORAGE_REGION,
credentials: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID
? {
accessKeyId: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!,
secretAccessKey: process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!,
}
: undefined,
})
return createAdapter(client, bucket)
}
function r2() {
const accountId = process.env.OPENCODE_STORAGE_ACCOUNT_ID!
const accessKeyId = process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!
const secretAccessKey = process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!
const bucket = process.env.OPENCODE_STORAGE_BUCKET!
const client = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId,
secretAccessKey,
},
})
return createAdapter(client, bucket)
}
const adapter = lazy(() => {
const type = process.env.OPENCODE_STORAGE_ADAPTER
if (type === "r2") return r2()
if (type === "s3") return s3()
throw new Error("No storage adapter configured")
})
function resolve(key: string[]) {
return key.join("/") + ".json"
}
export async function read<T>(key: string[]) {
const result = await adapter().read(resolve(key))
if (!result) return undefined
return JSON.parse(result) as T
}
export function write<T>(key: string[], value: T) {
return adapter().write(resolve(key), JSON.stringify(value))
}
export function remove(key: string[]) {
return adapter().remove(resolve(key))
}
export async function list(prefix: string[]) {
const p = prefix.join("/") + (prefix.length ? "/" : "")
const result = await adapter().list(p)
return result.map((x) => x.replace(/\.json$/, "").split("/"))
}
export async function update<T>(key: string[], fn: (draft: T) => void) {
const val = await read<T>(key)
if (!val) throw new Error("Not found")
fn(val)
await write(key, val)
return val
}
}
+4
View File
@@ -0,0 +1,4 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client"
mount(() => <StartClient />, document.getElementById("app")!)
+22
View File
@@ -0,0 +1,22 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server"
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<title>OpenCode</title>
{assets}
</head>
<body class="antialiased overscroll-none select-none text-12-regular">
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
))
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="@solidjs/start/env" />
export declare module "@solidjs/start/server" {
export type APIEvent = { request: Request }
}
@@ -0,0 +1,25 @@
import { A } from "@solidjs/router"
export default function NotFound() {
return (
<main class="text-center mx-auto text-gray-700 p-4">
<h1 class="max-6-xs text-6xl text-sky-700 font-thin uppercase my-16">Not Found</h1>
<p class="mt-8">
Visit{" "}
<a href="https://solidjs.com" target="_blank" class="text-sky-600 hover:underline">
solidjs.com
</a>{" "}
to learn how to build Solid apps.
</p>
<p class="my-4">
<A href="/" class="text-sky-600 hover:underline">
Home
</A>
{" - "}
<A href="/about" class="text-sky-600 hover:underline">
About Page
</A>
</p>
</main>
)
}
@@ -0,0 +1,152 @@
import type { APIEvent } from "@solidjs/start/server"
import { Hono } from "hono"
import { describeRoute, openAPIRouteHandler, resolver } from "hono-openapi"
import { validator } from "hono-openapi"
import z from "zod"
import { cors } from "hono/cors"
import { Share } from "~/core/share"
const app = new Hono()
app
.basePath("/api")
.use(cors())
.get(
"/doc",
openAPIRouteHandler(app, {
documentation: {
info: {
title: "Opencode Enterprise API",
version: "1.0.0",
description: "Opencode Enterprise API endpoints",
},
openapi: "3.1.1",
},
}),
)
.post(
"/share",
describeRoute({
description: "Create a share",
operationId: "share.create",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: resolver(
z
.object({
url: z.string(),
secret: z.string(),
})
.meta({ ref: "Share" }),
),
},
},
},
},
}),
validator("json", z.object({ sessionID: z.string() })),
async (c) => {
const body = c.req.valid("json")
const share = await Share.create({ id: body.sessionID })
const protocol = c.req.header("x-forwarded-proto") ?? c.req.header("x-forwarded-protocol") ?? "https"
const host = c.req.header("x-forwarded-host") ?? c.req.header("host")
return c.json({
secret: share.secret,
url: `${protocol}://${host}/share/${share.id}`,
})
},
)
.post(
"/share/:sessionID/sync",
describeRoute({
description: "Sync share data",
operationId: "share.sync",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: resolver(z.object({})),
},
},
},
},
}),
validator("param", z.object({ sessionID: z.string() })),
validator("json", z.object({ secret: z.string(), data: Share.Data.array() })),
async (c) => {
const { sessionID } = c.req.valid("param")
const body = c.req.valid("json")
await Share.sync({
share: { id: sessionID, secret: body.secret },
data: body.data,
})
return c.json({})
},
)
.get(
"/share/:sessionID/data",
describeRoute({
description: "Get share data",
operationId: "share.data",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: resolver(z.array(Share.Data)),
},
},
},
},
}),
validator("param", z.object({ sessionID: z.string() })),
async (c) => {
const { sessionID } = c.req.valid("param")
return c.json(await Share.data(sessionID))
},
)
.delete(
"/share/:sessionID",
describeRoute({
description: "Remove a share",
operationId: "share.remove",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: resolver(z.object({})),
},
},
},
},
}),
validator("param", z.object({ sessionID: z.string() })),
validator("json", z.object({ secret: z.string() })),
async (c) => {
const { sessionID } = c.req.valid("param")
const body = c.req.valid("json")
await Share.remove({ id: sessionID, secret: body.secret })
return c.json({})
},
)
export function GET(event: APIEvent) {
return app.fetch(event.request)
}
export function POST(event: APIEvent) {
return app.fetch(event.request)
}
export function PUT(event: APIEvent) {
return app.fetch(event.request)
}
export async function DELETE(event: APIEvent) {
return app.fetch(event.request)
}
+5
View File
@@ -0,0 +1,5 @@
import { ParentProps } from "solid-js"
export default function Share(props: ParentProps) {
return props.children
}
@@ -0,0 +1,198 @@
import { FileDiff, Message, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider, useData } from "@opencode-ai/ui/context"
import { createAsync, query, RouteDefinition, useParams } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import { Share } from "~/core/share"
import { Logo, Mark } from "@opencode-ai/ui/logo"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary"
import { DateTime } from "luxon"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { createStore } from "solid-js/store"
const getData = query(async (sessionID) => {
const data = await Share.data(sessionID)
const result: {
session: Session[]
session_diff: {
[sessionID: string]: FileDiff[]
}
session_status: {
[sessionID: string]: SessionStatus
}
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
} = {
session: [],
session_diff: {
[sessionID]: [],
},
session_status: {
[sessionID]: {
type: "idle",
},
},
message: {},
part: {},
}
for (const item of data) {
switch (item.type) {
case "session":
result.session.push(item.data)
break
case "session_diff":
result.session_diff[sessionID] = item.data
break
case "session_status":
result.session_status[sessionID] = item.data
break
case "message":
result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
result.message[item.data.sessionID].push(item.data)
break
case "part":
result.part[item.data.messageID] = result.part[item.data.messageID] ?? []
result.part[item.data.messageID].push(item.data)
break
}
}
return result
}, "getShareData")
export const route = {
preload: ({ params }) => getData(params.sessionID),
} satisfies RouteDefinition
export default function () {
const params = useParams()
const data = createAsync(async () => {
if (!params.sessionID) return
return getData(params.sessionID)
})
return (
<Show when={data()}>
{(data) => (
<DataProvider data={data()}>
{iife(() => {
const data = useData()
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const match = createMemo(() => Binary.search(data.session, params.sessionID!, (s) => s.id))
if (!match().found) throw new Error(`Session ${params.sessionID} not found`)
const info = createMemo(() => data.session[match().index])
const messages = createMemo(() =>
params.sessionID ? (data.message[params.sessionID]?.filter((m) => m.role === "user") ?? []) : [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const model = createMemo(() => activeMessage()?.model?.modelID)
const diffs = createMemo(() => data.session_diff[params.sessionID!] ?? [])
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div class="w-full flex-1 min-h-0 flex">
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
"px-21 @4xl:px-6 max-w-2xl": diffs().length > 0,
"px-6 max-w-2xl": diffs().length === 0,
}}
>
<div class="flex flex-col gap-4 shrink-0">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<img
src={`https://models.dev/logos/${provider()}.svg`}
class="size-4 shrink-0 dark:invert"
/>
<div class="text-12-regular text-text-base">{model()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
<div class="flex items-start justify-start h-full min-h-0">
<Show when={messages().length > 1}>
<MessageNav
classList={{ "mt-3 mr-3": true }}
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
size={!diffs().length ? "normal" : "compact"}
/>
</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>
<Show when={diffs().length}>
<div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview diffs={diffs()} class="pb-20" />
</div>
</Show>
</div>
</div>
</div>
)
})}
</DataProvider>
)}
</Show>
)
}
+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 {}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"noEmit": true,
"strict": true,
"types": ["@cloudflare/workers-types", "vite/client"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig, PluginOption } from "vite"
import { solidStart } from "@solidjs/start/config"
import { nitro } from "nitro/vite"
import tailwindcss from "@tailwindcss/vite"
const nitroConfig: any = (() => {
const target = process.env.OPENCODE_DEPLOYMENT_TARGET
if (target === "cloudflare") {
return {
compatibilityDate: "2024-09-19",
preset: "cloudflare_module",
cloudflare: {
nodeCompat: true,
},
}
}
return {}
})()
export default defineConfig({
plugins: [tailwindcss(), solidStart() as PluginOption, nitro(nitroConfig)],
server: {
host: "0.0.0.0",
allowedHosts: true,
},
})
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The AI coding agent built for the terminal"
version = "1.0.90"
version = "1.0.108"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.90/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.108/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.90/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.108/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.90/opencode-linux-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.108/opencode-linux-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.90/opencode-linux-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.108/opencode-linux-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.90/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.108/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.90",
"version": "1.0.108",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+10
View File
@@ -74,6 +74,14 @@ declare module "sst" {
type: "sst.sst.Secret"
value: string
}
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
@@ -112,6 +120,8 @@ declare module "sst" {
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
+10
View File
@@ -0,0 +1,10 @@
FROM alpine
# Disable the runtime transpiler cache by default inside Docker containers.
# On ephemeral containers, the cache is not useful
ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH}
RUN apk add libgcc libstdc++
ADD ./dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode
RUN opencode --version
ENTRYPOINT ["opencode"]
+14 -6
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.90",
"version": "1.0.108",
"name": "opencode",
"type": "module",
"private": true,
@@ -18,8 +18,6 @@
"./*": "./src/*.ts"
},
"devDependencies": {
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/google-vertex": "3.0.16",
"@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -44,7 +42,14 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.57",
"@ai-sdk/anthropic": "2.0.45",
"@ai-sdk/azure": "2.0.73",
"@ai-sdk/google": "2.0.42",
"@ai-sdk/google-vertex": "3.0.74",
"@ai-sdk/mcp": "0.0.8",
"@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -55,8 +60,10 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.47",
"@opentui/solid": "0.1.47",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.2.5",
"@opentui/core": "0.1.49",
"@opentui/solid": "0.1.49",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -70,11 +77,12 @@
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "1.1.1",
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.5",
"partial-json": "0.1.7",
"remeda": "catalog:",
"solid-js": "catalog:",
+1 -1
View File
@@ -112,7 +112,7 @@ for (const item of targets) {
autoloadDotenv: false,
target: name.replace(pkg.name, "bun") as any,
outfile: `dist/${name}/bin/opencode`,
execArgv: [`--user-agent=opencode/${Script.version}`],
execArgv: [`--user-agent=opencode/${Script.version}`, "--"],
windows: {},
},
entrypoints: ["./src/index.ts", parserWorker, workerPath],
+18 -8
View File
@@ -59,12 +59,16 @@ if (!Script.preview) {
if (!Script.preview) {
for (const key of Object.keys(binaries)) {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
if (key.includes("linux")) {
await $`cd dist/${key}/bin && tar -czf ../../${key}.tar.gz *`
} else {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
}
}
// Calculate SHA values
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim())
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim())
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
@@ -88,10 +92,10 @@ if (!Script.preview) {
"conflicts=('opencode')",
"depends=('fzf' 'ripgrep')",
"",
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.zip")`,
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`,
`sha256sums_aarch64=('${arm64Sha}')`,
"",
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.zip")`,
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`,
`sha256sums_x86_64=('${x64Sha}')`,
"",
"package() {",
@@ -216,14 +220,14 @@ if (!Script.preview) {
"",
" on_linux do",
" if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-x64.zip"`,
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-x64.tar.gz"`,
` sha256 "${x64Sha}"`,
" def install",
' bin.install "opencode"',
" end",
" end",
" if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?",
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-arm64.zip"`,
` url "https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-arm64.tar.gz"`,
` sha256 "${arm64Sha}"`,
" def install",
' bin.install "opencode"',
@@ -241,4 +245,10 @@ if (!Script.preview) {
await $`cd ./dist/homebrew-tap && git add opencode.rb`
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push`
const image = "ghcr.io/sst/opencode"
await $`docker build -t ${image}:${Script.version} .`
await $`docker push ${image}:${Script.version}`
await $`docker tag ${image}:${Script.version} ${image}:latest`
await $`docker push ${image}:latest`
}
+24
View File
@@ -4,9 +4,12 @@ import { Log } from "../util/log"
import path from "path"
import { NamedError } from "../util/error"
import { readableStreamToText } from "bun"
import { createRequire } from "module"
import { Lock } from "../util/lock"
export namespace BunProc {
const log = Log.create({ service: "bun" })
const req = createRequire(import.meta.url)
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
log.info("running", {
@@ -58,6 +61,9 @@ export namespace BunProc {
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
const parsed = await pkgjson.json().catch(async () => {
@@ -125,4 +131,22 @@ export namespace BunProc {
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
return mod
}
export async function resolve(pkg: string) {
const local = workspace(pkg)
if (local) return local
const dir = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjson = Bun.file(path.join(dir, "package.json"))
const exists = await pkgjson.exists()
if (exists) return dir
}
function workspace(pkg: string) {
try {
const target = req.resolve(`${pkg}/package.json`)
return path.dirname(target)
} catch {
return
}
}
}
+24
View File
@@ -342,6 +342,21 @@ function App() {
dialog.clear()
},
},
{
title: "Suspend terminal",
value: "terminal.suspend",
keybind: "terminal_suspend",
category: "System",
onSelect: () => {
process.once("SIGCONT", () => {
renderer.resume()
})
renderer.suspend()
// pid=0 means send the signal to all processes in the process group
process.kill(0, "SIGTSTP")
},
},
])
createEffect(() => {
@@ -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 (
<box
width={dimensions().width}
@@ -5,7 +5,7 @@ import { createMemo, createResource, createEffect, onMount, For, Show } from "so
import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import { Locale } from "@/util/locale"
@@ -455,7 +455,7 @@ export function Autocomplete(props: {
{...SplitBorder}
borderColor={theme.border}
>
<box backgroundColor={theme.backgroundElement} height={height()}>
<box backgroundColor={theme.backgroundMenu} height={height()}>
<For
each={options()}
fallback={
@@ -471,11 +471,11 @@ export function Autocomplete(props: {
backgroundColor={index() === store.selected ? theme.primary : undefined}
flexDirection="row"
>
<text fg={index() === store.selected ? theme.background : theme.text} flexShrink={0}>
<text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option.display}
</text>
<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}
</text>
</Show>
@@ -1,5 +1,6 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { EmptyBorder } from "@tui/component/border"
@@ -20,7 +21,7 @@ import type { FilePart } from "@opencode-ai/sdk"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { Shimmer } from "../../ui/shimmer"
import { createColors, createFrames } from "../../ui/spinner.ts"
export type PromptProps = {
sessionID?: string
@@ -545,6 +546,22 @@ export function Prompt(props: PromptProps) {
return local.agent.color(local.agent.current().name)
})
const spinnerDef = createMemo(() => {
const color = local.agent.color(local.agent.current().name)
return {
frames: createFrames({
color,
style: "blocks",
inactiveFactor: 0.25,
}),
color: createColors({
color,
style: "blocks",
inactiveFactor: 0.25,
}),
}
})
createEffect(() => {
renderer.setCursorColor(highlight())
})
@@ -791,10 +808,17 @@ export function Prompt(props: PromptProps) {
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={{
...EmptyBorder,
horizontal: "▀",
}}
customBorderChars={
theme.background.a != 0
? {
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/>
</box>
<box flexDirection="row" justifyContent="space-between">
@@ -806,7 +830,7 @@ export function Prompt(props: PromptProps) {
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<Loader />
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
@@ -876,38 +900,3 @@ export function Prompt(props: PromptProps) {
</>
)
}
function Loader() {
const FRAMES = [
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▰▱▱▱▱▱▱",
"▰▰▱▱▱▱▱",
"▰▰▰▱▱▱▱",
"▱▰▰▰▱▱▱",
"▱▱▰▰▰▱▱",
"▱▱▱▰▰▰▱",
"▱▱▱▱▰▰▰",
"▱▱▱▱▱▰▰",
"▱▱▱▱▱▱▰",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
"▱▱▱▱▱▱▱",
]
const [frame, setFrame] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
setFrame((frame() + 1) % FRAMES.length)
}, 100)
onCleanup(() => {
clearInterval(timer)
})
})
const { theme } = useTheme()
return <text fg={theme.diffAdded}>{FRAMES[frame()]}</text>
}
@@ -94,6 +94,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = agents().find((x) => x.name === name)
if (agent?.color) return RGBA.fromHex(agent.color)
const index = agents().findIndex((x) => x.name === name)
if (index === -1) return colors()[0]
return colors()[index % colors().length]
},
}
@@ -17,7 +17,7 @@ import type {
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { Binary } from "@/util/binary"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
@@ -33,7 +33,7 @@ import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
type Theme = {
type ThemeColors = {
primary: RGBA
secondary: RGBA
accent: RGBA
@@ -43,9 +43,11 @@ type Theme = {
info: RGBA
text: RGBA
textMuted: RGBA
selectedListItemText: RGBA
background: RGBA
backgroundPanel: RGBA
backgroundElement: RGBA
backgroundMenu: RGBA
border: RGBA
borderActive: RGBA
borderSubtle: RGBA
@@ -86,6 +88,27 @@ type Theme = {
syntaxPunctuation: RGBA
}
type Theme = ThemeColors & {
_hasSelectedListItemText: boolean
}
export function selectedForeground(theme: Theme): RGBA {
// If theme explicitly defines selectedListItemText, use it
if (theme._hasSelectedListItemText) {
return theme.selectedListItemText
}
// For transparent backgrounds, calculate contrast based on primary color
if (theme.background.a === 0) {
const { r, g, b } = theme.primary
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
}
// Fall back to background color
return theme.background
}
type HexColor = `#${string}`
type RefName = string
type Variant = {
@@ -96,7 +119,10 @@ type ColorValue = HexColor | RefName | Variant | RGBA
type ThemeJson = {
$schema?: string
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> = {
@@ -137,19 +163,44 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
if (defs[c]) {
return resolveColor(defs[c])
} else if (theme.theme[c as keyof Theme]) {
return resolveColor(theme.theme[c as keyof Theme])
} else if (theme.theme[c as keyof ThemeColors] !== undefined) {
return resolveColor(theme.theme[c as keyof ThemeColors]!)
} else {
throw new Error(`Color reference "${c}" not found in defs or theme`)
}
}
return resolveColor(c[mode])
}
return Object.fromEntries(
Object.entries(theme.theme).map(([key, value]) => {
return [key, resolveColor(value)]
}),
) as Theme
const resolved = Object.fromEntries(
Object.entries(theme.theme)
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu")
.map(([key, value]) => {
return [key, resolveColor(value)]
}),
) as Partial<ThemeColors>
// Handle selectedListItemText separately since it's optional
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
if (hasSelectedListItemText) {
resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!)
} else {
// Backward compatibility: if selectedListItemText is not defined, use background color
// This preserves the current behavior for all existing themes
resolved.selectedListItemText = resolved.background
}
// Handle backgroundMenu - optional with fallback to backgroundElement
if (theme.theme.backgroundMenu !== undefined) {
resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu)
} else {
resolved.backgroundMenu = resolved.backgroundElement
}
return {
...resolved,
_hasSelectedListItemText: hasSelectedListItemText,
} as Theme
}
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
@@ -159,7 +210,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const kv = useKV()
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: props.mode,
mode: kv.get("theme_mode", props.mode),
active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
@@ -211,6 +262,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
},
setMode(mode: "dark" | "light") {
setStore("mode", mode)
kv.set("theme_mode", mode)
},
set(theme: string) {
setStore("active", theme)
@@ -288,11 +340,13 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
// Text colors
text: fg,
textMuted,
selectedListItemText: bg,
// Background colors
background: bg,
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],
// Border colors
borderSubtle: grays[6],
@@ -6,8 +6,6 @@ import {
For,
Match,
on,
onCleanup,
onMount,
Show,
Switch,
useContext,
@@ -45,7 +43,6 @@ import type { TaskTool } from "@/tool/task"
import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
import { Shimmer } from "@tui/ui/shimmer"
import { useKeybind } from "@tui/context/keybind"
import { Header } from "./header"
import { parsePatch } from "diff"
@@ -64,8 +61,6 @@ import { Clipboard } from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import { Global } from "@/global"
import fs from "fs/promises"
import stripAnsi from "strip-ansi"
addDefaultParsers(parsers.parsers)
@@ -84,6 +79,7 @@ const context = createContext<{
width: number
conceal: () => boolean
showThinking: () => boolean
showTimestamps: () => boolean
}>()
function use() {
@@ -106,10 +102,15 @@ export function Session() {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
})
const lastAssistant = createMemo(() => {
return messages().findLast((x) => x.role === "assistant")
})
const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
const [conceal, setConceal] = createSignal(true)
const [showThinking, setShowThinking] = createSignal(true)
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide()))
@@ -176,6 +177,7 @@ export function Session() {
const first = permissions()[0]
if (first) {
const response = iife(() => {
if (evt.ctrl || evt.meta) return
if (evt.name === "return") return "once"
if (evt.name === "a") return "always"
if (evt.name === "d") return "reject"
@@ -404,6 +406,19 @@ export function Session() {
dialog.clear()
},
},
{
title: "Toggle timestamps",
value: "session.toggle.timestamps",
category: "Session",
onSelect: (dialog) => {
setShowTimestamps((prev) => {
const next = !prev
kv.set("timestamps", next ? "show" : "hide")
return next
})
dialog.clear()
},
},
{
title: "Toggle thinking blocks",
value: "session.toggle.thinking",
@@ -513,7 +528,6 @@ export function Session() {
return
}
console.log(text)
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
@@ -714,6 +728,7 @@ export function Session() {
},
conceal,
showThinking,
showTimestamps,
}}
>
<box flexDirection="row" paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={2}>
@@ -846,7 +861,7 @@ export function Session() {
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage
last={pending() === message.id}
last={lastAssistant()?.id === message.id}
message={message as AssistantMessage}
parts={sync.data.part[message.id] ?? []}
/>
@@ -893,6 +908,7 @@ function UserMessage(props: {
index: number
pending?: string
}) {
const ctx = use()
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const sync = useSync()
@@ -951,7 +967,13 @@ function UserMessage(props: {
{sync.data.config.username ?? "You"}{" "}
<Show
when={queued()}
fallback={<span style={{ fg: theme.textMuted }}>{Locale.time(props.message.time.created)}</span>}
fallback={
<span style={{ fg: theme.textMuted }}>
{ctx.showTimestamps()
? Locale.todayTimeOrDateTime(props.message.time.created)
: Locale.time(props.message.time.created)}
</span>
}
>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
</Show>
@@ -976,12 +998,20 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
const local = useLocal()
const { theme } = useTheme()
const sync = useSync()
const status = createMemo(
() =>
sync.data.session_status[props.message.sessionID] ?? {
type: "idle",
},
)
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
const final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
})
const duration = createMemo(() => {
if (!final()) return 0
if (!props.message.time.completed) return 0
const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID)
if (!user || !user.time) return 0
return props.message.time.completed - user.time.created
})
return (
<>
<For each={props.parts}>
@@ -1014,57 +1044,15 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
</box>
</Show>
<Switch>
<Match when={props.last && status().type !== "idle" && false}>
<box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}>
<text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
<Shimmer text={props.message.modelID} color={theme.text} />
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini 3 way too hot right now"
if (r.message.length > 50) return r.message.slice(0, 50) + "..."
return r.message
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
return (
<Show when={retry()}>
<text fg={theme.error}>
{message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""}
attempt #{retry()!.attempt}]
</text>
</Show>
)
})()}
</box>
</Match>
<Match
when={
(props.message.time.completed &&
props.parts.some((item) => item.type === "step-finish" && item.reason !== "tool-calls")) ||
props.last
}
>
<Match when={props.last || final()}>
<box paddingLeft={3}>
<text marginTop={1}>
<span style={{ fg: local.agent.color(props.message.mode) }}>{Locale.titlecase(props.message.mode)}</span>{" "}
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
<span style={{ fg: local.agent.color(props.message.mode) }}></span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>{" "}
<span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> {Locale.duration(duration())}</span>
</Show>
</text>
</box>
</Match>
@@ -1110,7 +1098,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { syntax } = useTheme()
const { theme, syntax } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
@@ -1121,6 +1109,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
syntaxStyle={syntax()}
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.text}
/>
</box>
</Show>
@@ -1535,7 +1524,6 @@ ToolRegistry.register<typeof EditTool>({
const ft = createMemo(() => filetype(props.input.filePath))
createEffect(() => console.log(props.metadata.diagnostics))
const diagnostics = createMemo(() => {
const arr = props.metadata.diagnostics?.[props.input.filePath ?? ""] ?? []
return arr.filter((x) => x.severity === 1).slice(0, 3)
@@ -18,6 +18,9 @@ export function Sidebar(props: { sessionID: string }) {
const [todoExpanded, setTodoExpanded] = createSignal(true)
const [lspExpanded, setLspExpanded] = createSignal(true)
// Sort MCP servers alphabetically for consistent display order
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
const cost = createMemo(() => {
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
@@ -58,22 +61,22 @@ export function Sidebar(props: { sessionID: string }) {
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<Show when={mcpEntries().length > 0}>
<box>
<box
flexDirection="row"
gap={1}
onMouseDown={() => Object.keys(sync.data.mcp).length > 2 && setMcpExpanded(!mcpExpanded())}
onMouseDown={() => mcpEntries().length > 2 && setMcpExpanded(!mcpExpanded())}
>
<Show when={Object.keys(sync.data.mcp).length > 2}>
<Show when={mcpEntries().length > 2}>
<text fg={theme.text}>{mcpExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>MCP</b>
</text>
</box>
<Show when={Object.keys(sync.data.mcp).length <= 2 || mcpExpanded()}>
<For each={Object.entries(sync.data.mcp)}>
<Show when={mcpEntries().length <= 2 || mcpExpanded()}>
<For each={mcpEntries()}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
@@ -188,6 +191,7 @@ export function Sidebar(props: { sessionID: string }) {
const splits = item.file.split(path.sep).filter(Boolean)
const last = splits.at(-1)!
const rest = splits.slice(0, -1).join(path.sep)
if (!rest) return last
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
})
return (
+14 -9
View File
@@ -58,16 +58,14 @@ export const TuiThreadCommand = cmd({
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
const defaultWorker = new URL("./worker.ts", import.meta.url)
// Nix build creates a bundled worker next to the binary; prefer it when present.
const localWorker = new URL("./worker.ts", import.meta.url)
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
const execDir = path.dirname(process.execPath)
const bundledWorker = path.join(execDir, "opencode-worker.js")
const hasBundledWorker = await Bun.file(bundledWorker).exists()
const workerPath = (() => {
const workerPath = await iife(async () => {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
if (hasBundledWorker) return bundledWorker
return defaultWorker
})()
if (await Bun.file(distWorker).exists()) return distWorker
return localWorker
})
try {
process.chdir(cwd)
} catch (e) {
@@ -99,7 +97,8 @@ export const TuiThreadCommand = cmd({
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})
await tui({
const tuiPromise = tui({
url: server.url,
args: {
continue: args.continue,
@@ -112,5 +111,11 @@ export const TuiThreadCommand = cmd({
await client.call("shutdown", undefined)
},
})
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000)
await tuiPromise
},
})
@@ -38,7 +38,7 @@ export function DialogAlert(props: DialogAlertProps) {
dialog.clear()
}}
>
<text fg={theme.background}>ok</text>
<text fg={theme.selectedListItemText}>ok</text>
</box>
</box>
</box>

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