Compare commits

..

51 Commits

Author SHA1 Message Date
Aiden Cline
dbd58974a8 add unit tests 2026-01-21 12:48:06 -06:00
Aiden Cline
7e4d2150fe fix 2026-01-20 17:56:35 -06:00
Aiden Cline
fe58321f36 wip 2026-01-20 17:50:34 -06:00
Aiden Cline
ff77016c8b wip 2026-01-20 14:59:01 -06:00
Aiden Cline
84c4fe971a fix: azure issue where azure sdk was being used instead of anthropic one for anthropic models 2026-01-18 22:00:01 -06:00
Aiden Cline
0d8e706fac test: fix transfomr test 2026-01-18 14:44:39 -06:00
Aiden Cline
d841e70d26 fix: bad variants for grok models 2026-01-18 14:21:14 -06:00
Github Action
19cf9344e1 Update node_modules hashes 2026-01-18 19:24:21 +00:00
Aiden Cline
c29d44fcef docs: note untracked files in review 2026-01-18 13:22:58 -06:00
zerone0x
38c641a2fc fix(tool): treat .fbs files as text instead of images (#9276)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-18 13:17:49 -06:00
Vladimir Glafirov
501ef2d989 fix: update gitlab-ai-provider to 1.3.2 (#9279) 2026-01-18 13:11:34 -06:00
Spoon
bfd2f91d5b feat(hook): command execute before hook (#9267) 2026-01-18 13:11:22 -06:00
Caleb Norton
dac099a489 feat(nix): overhaul nix flake and packages (#9032) 2026-01-18 11:14:13 -06:00
GitHub Action
5009f10406 chore: generate 2026-01-18 16:46:02 +00:00
Lior
095a64291d fix(acp): preserve file attachment metadata during session replay (#6342)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-18 10:45:25 -06:00
Chawye Hsu
f7fef99ddd refactor(installation): update scoop installation method (#9243)
Signed-off-by: Chawye Hsu <su+git@chawyehsu.com>
2026-01-18 09:58:34 -06:00
Aiden Cline
2dcca4755d fix: import issue in patch module 2026-01-18 09:47:18 -06:00
OpeOginni
ad2e03284b refactor(desktop): improve layout and styling of session search button (#9251) 2026-01-18 08:10:38 -06:00
Kit Langton
6c0991d162 fix(app): remove redundant toast for thinking effort changes (#9181) 2026-01-18 08:00:49 -06:00
GitHub Action
5c9cc9c748 ignore: update download stats 2026-01-18 2026-01-18 12:05:11 +00:00
Mani Sundararajan
06bc4dcb06 feat(desktop): implement session unshare button (#8660) 2026-01-18 05:12:07 -06:00
Mani Sundararajan
0ccf9bd9ac feat(cli): uninstall opencode installed via windows package managers (#8571) 2026-01-18 02:40:01 -06:00
Noam Bressler
ee4ea65311 fix: restore persisted model/agent when loading ACP session (#7809)
Co-authored-by: noam-v <noam@bespo.ai>
2026-01-18 01:29:57 -06:00
Noam Bressler
bef1f66281 fix(acp): use single global event subscription and route by sessionID (#5628)
Co-authored-by: noamzbr <noamzbr@users.noreply.github.com>
Co-authored-by: noam-v <noam@bespo.ai>
2026-01-18 01:29:42 -06:00
GitHub Action
d13c0ea915 chore: generate 2026-01-18 06:42:13 +00:00
Bowen Dwelle
3591372c45 feat(tool): increase question header and label limits (#9201) 2026-01-18 00:41:36 -06:00
GitHub Action
90f848fbc6 chore: generate 2026-01-18 06:35:48 +00:00
Aiden Cline
b7ad6bd839 feat: apply_patch tool for openai models (#9127) 2026-01-18 00:35:09 -06:00
Patrick Schiel
10433cb45b fix(windows): fix jdtls download on Windows (#9195) 2026-01-18 00:30:45 -06:00
GitHub Action
073f9d99b5 chore: generate 2026-01-18 03:55:03 +00:00
Nathan Flurry
bfb8c531c2 feat: bind vim-style line-by-line scrolling (#8980)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-17 21:54:26 -06:00
Aiden Cline
052f887a9a core: prevent env variables in config from being replaced with actual values
When opencode.json was missing a $schema, the config loader would add it
and write the file back - but with env variables like {env:API_KEY} replaced
with their actual secret values. This made it impossible to safely commit
opencode.json to version control.

Now the original config text is preserved when adding $schema, keeping
variable placeholders intact.
2026-01-17 20:59:50 -06:00
Kit Langton
759e68616e refactor(tui): unify command registry and derive slash commands (#9115) 2026-01-17 20:39:19 -06:00
opencode-agent[bot]
93e43d8e5e Hide variants hint when list empty (#9179)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-17 20:32:57 -06:00
David Hill
53c77e29df fix: remove max-width of session name tooltip 2026-01-18 00:59:41 +00:00
David Hill
260739a227 Revert "fix: increase max-width of session name tooltip"
This reverts commit c3ab76c8ad.
2026-01-18 00:57:21 +00:00
David Hill
c3ab76c8ad fix: increase max-width of session name tooltip 2026-01-18 00:51:35 +00:00
David Hill
389d97ece9 fix: adjust project path tooltip placement
Move the desktop project path tooltip above the header and tune spacing/offset; add content style hooks to Tooltip for max-width and horizontal shift.
2026-01-18 00:48:49 +00:00
David Hill
e36b3433fc fix: remove max width on sidebar new buttons 2026-01-18 00:48:06 +00:00
David Hill
ded9bd26bb fix: adjust session list tooltip trigger and delay 2026-01-18 00:07:21 +00:00
David Hill
c890853992 fix: keep project avatar hover styles while popover open 2026-01-17 23:40:06 +00:00
David Hill
2a4e8bc01c fix: adjust recent sessions popover padding 2026-01-17 23:21:34 +00:00
David Hill
c19d031144 fix: reduce prompt dock bottom spacing 2026-01-17 22:54:30 +00:00
David Hill
0cc9a22a42 fix: show project name in avatar hover 2026-01-17 22:51:49 +00:00
David Hill
b4075cd856 fix: remove loading text after splash 2026-01-17 21:54:51 +00:00
David Hill
53227bfc2a fix: command pallete file list item spacing 2026-01-17 21:46:23 +00:00
David Hill
d3baaf7408 fix: shrink project notification dot and mask 2026-01-17 21:46:23 +00:00
David Hill
0384e6b0e1 fix: update desktop initializing splash logo 2026-01-17 21:46:23 +00:00
David Hill
c3d33562c7 fix: align project avatar notification dot 2026-01-17 21:46:23 +00:00
Aiden Cline
f3513bacff tui: fix model state persistence when model store is not ready 2026-01-17 14:41:42 -06:00
opencode-agent[bot]
3aff88c23d docs: add use_github_token to example (#9120)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-17 13:36:54 -06:00
63 changed files with 2260 additions and 1385 deletions

View File

@@ -19,84 +19,7 @@ on:
- ".github/workflows/update-nix-hashes.yml"
jobs:
update-flake:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
TITLE: flake.lock
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Configure git
run: |
git config --global user.email "action@github.com"
git config --global user.name "Github Action"
- name: Update ${{ env.TITLE }}
run: |
set -euo pipefail
echo "Updating $TITLE..."
nix flake update
echo "$TITLE updated successfully"
- name: Commit ${{ env.TITLE }} changes
env:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
set -euo pipefail
echo "Checking for changes in tracked files..."
summarize() {
local status="$1"
{
echo "### Nix $TITLE"
echo ""
echo "- ref: ${GITHUB_REF_NAME}"
echo "- status: ${status}"
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
}
FILES=(flake.lock flake.nix)
STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then
echo "No changes detected."
summarize "no changes"
exit 0
fi
echo "Changes detected:"
echo "$STATUS"
echo "Staging files..."
git add "${FILES[@]}"
echo "Committing changes..."
git commit -m "Update $TITLE"
echo "Changes committed"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
echo "Pulling latest from branch: $BRANCH"
git pull --rebase --autostash origin "$BRANCH"
echo "Pushing changes to branch: $BRANCH"
git push origin HEAD:"$BRANCH"
echo "Changes pushed successfully"
summarize "committed $(git rev-parse --short HEAD)"
compute-node-modules-hash:
needs: update-flake
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
strategy:
fail-fast: false

View File

@@ -9,6 +9,11 @@
"opencode": {
"options": {},
},
"azure": {
"options": {
"resourceName": "alice-mi7mfgew-eastus2",
},
},
},
"mcp": {
"context7": {

View File

@@ -26,7 +26,7 @@ curl -fsSL https://opencode.ai/install | bash
# Package managers
npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less)
@@ -52,6 +52,8 @@ OpenCode is also available as a desktop application. Download directly from the
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Installation Directory

View File

@@ -26,7 +26,7 @@ curl -fsSL https://opencode.ai/install | bash
# 软件包管理器
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux推荐始终保持最新
brew install opencode # macOS 和 Linux官方 brew formula更新频率较低
@@ -52,6 +52,8 @@ OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](htt
```bash
# macOS (Homebrew Cask)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### 安装目录

View File

@@ -26,7 +26,7 @@ curl -fsSL https://opencode.ai/install | bash
# 套件管理員
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 與 Linux推薦始終保持最新
brew install opencode # macOS 與 Linux官方 brew formula更新頻率較低
@@ -52,6 +52,8 @@ OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (rele
```bash
# macOS (Homebrew Cask)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### 安裝目錄

View File

@@ -203,3 +203,4 @@
| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) |
| 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) |
| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) |
| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) |

View File

@@ -281,7 +281,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.1",
"@gitlab/gitlab-ai-provider": "3.1.2",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -917,7 +917,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-p0NZhZJSavWDX9r/Px/mOK2YIC803GZa8iRzcg3f1C6S0qfea/HBTe4/NWvT2+2kWIwhCePGuI4FN2UFiUWXUg=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1768456270,
"narHash": "sha256-NgaL2CCiUR6nsqUIY4yxkzz07iQUlUCany44CFv+OxY=",
"lastModified": 1768302833,
"narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f4606b01b39e09065df37905a2133905246db9ed",
"rev": "61db79b0c6b838d9894923920b612048e1201926",
"type": "github"
},
"original": {

110
flake.nix
View File

@@ -6,11 +6,7 @@
};
outputs =
{
self,
nixpkgs,
...
}:
{ self, nixpkgs, ... }:
let
systems = [
"aarch64-linux"
@@ -18,99 +14,35 @@
"aarch64-darwin"
"x86_64-darwin"
];
inherit (nixpkgs) lib;
forEachSystem = lib.genAttrs systems;
pkgsFor = system: nixpkgs.legacyPackages.${system};
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
bunTarget = {
"aarch64-linux" = "bun-linux-arm64";
"x86_64-linux" = "bun-linux-x64";
"aarch64-darwin" = "bun-darwin-arm64";
"x86_64-darwin" = "bun-darwin-x64";
};
# Parse "bun-{os}-{cpu}" to {os, cpu}
parseBunTarget =
target:
let
parts = lib.splitString "-" target;
in
{
os = builtins.elemAt parts 1;
cpu = builtins.elemAt parts 2;
};
hashesFile = "${./nix}/hashes.json";
hashesData =
if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { };
# Lookup hash: supports per-system ({system: hash}) or legacy single hash
nodeModulesHashFor =
system:
if builtins.isAttrs hashesData.nodeModules then
hashesData.nodeModules.${system}
else
hashesData.nodeModules;
modelsDev = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
pkgs."models-dev"
);
forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
rev = self.shortRev or self.dirtyShortRev or "dirty";
in
{
devShells = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = with pkgs; [
bun
nodejs_20
pkg-config
openssl
git
];
};
}
);
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [
bun
nodejs_20
pkg-config
openssl
git
];
};
});
packages = forEachSystem (
system:
pkgs:
let
pkgs = pkgsFor system;
bunPlatform = parseBunTarget bunTarget.${system};
mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
hash = nodeModulesHashFor system;
bunCpu = bunPlatform.cpu;
bunOs = bunPlatform.os;
opencode = pkgs.callPackage ./nix/opencode.nix {
inherit rev;
};
mkOpencode = pkgs.callPackage ./nix/opencode.nix { };
mkDesktop = pkgs.callPackage ./nix/desktop.nix { };
opencodePkg = mkOpencode {
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
target = bunTarget.${system};
modelsDev = "${modelsDev.${system}}/dist/_api.json";
inherit mkNodeModules;
};
desktopPkg = mkDesktop {
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
mkNodeModules = mkNodeModules;
opencode = opencodePkg;
desktop = pkgs.callPackage ./nix/desktop.nix {
inherit opencode;
};
in
{
default = self.packages.${system}.opencode;
opencode = opencodePkg;
desktop = desktopPkg;
default = opencode;
inherit opencode desktop;
}
);
};

View File

@@ -91,8 +91,10 @@ This will walk you through installing the GitHub app, creating the workflow, and
uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: anthropic/claude-sonnet-4-20250514
use_github_token: true
```
3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys.

View File

@@ -1,40 +0,0 @@
#!/usr/bin/env bun
import solidPlugin from "./node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
import fs from "fs"
const dir = process.cwd()
const parser = fs.realpathSync(path.join(dir, "node_modules/@opentui/core/parser.worker.js"))
const worker = "./src/cli/cmd/tui/worker.ts"
const version = process.env.OPENCODE_VERSION ?? "local"
const channel = process.env.OPENCODE_CHANNEL ?? "local"
fs.rmSync(path.join(dir, "dist"), { recursive: true, force: true })
const result = await Bun.build({
entrypoints: ["./src/index.ts", worker, parser],
outdir: "./dist",
target: "bun",
sourcemap: "none",
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
external: ["@opentui/core"],
define: {
OPENCODE_VERSION: `'${version}'`,
OPENCODE_CHANNEL: `'${channel}'`,
// Leave undefined so runtime picks bundled/dist worker or fallback in code.
OPENCODE_WORKER_PATH: "undefined",
OTUI_TREE_SITTER_WORKER_PATH: 'new URL("./cli/cmd/tui/parser.worker.js", import.meta.url).href',
},
})
if (!result.success) {
console.error("bundle failed")
for (const log of result.logs) console.error(log)
process.exit(1)
}
const parserOut = path.join(dir, "dist/src/cli/cmd/tui/parser.worker.js")
fs.mkdirSync(path.dirname(parserOut), { recursive: true })
await Bun.write(parserOut, Bun.file(parser))

View File

@@ -2,166 +2,99 @@
lib,
stdenv,
rustPlatform,
bun,
pkg-config,
dbus ? null,
openssl,
glib ? null,
gtk3 ? null,
libsoup_3 ? null,
webkitgtk_4_1 ? null,
librsvg ? null,
libappindicator-gtk3 ? null,
cargo-tauri,
bun,
nodejs,
cargo,
rustc,
makeBinaryWrapper,
copyDesktopItems,
makeDesktopItem,
nodejs,
jq,
wrapGAppsHook4,
makeWrapper,
dbus,
glib,
gtk4,
libsoup_3,
librsvg,
libappindicator,
glib-networking,
openssl,
webkitgtk_4_1,
gst_all_1,
opencode,
}:
args:
let
scripts = args.scripts;
mkModules =
attrs:
args.mkNodeModules (
attrs
// {
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
}
);
in
rustPlatform.buildRustPackage rec {
rustPlatform.buildRustPackage (finalAttrs: {
pname = "opencode-desktop";
version = args.version;
inherit (opencode)
version
src
node_modules
patches
;
src = args.src;
# We need to set the root for cargo, but we also need access to the whole repo.
postUnpack = ''
# Update sourceRoot to point to the tauri app
sourceRoot+=/packages/desktop/src-tauri
'';
cargoLock = {
lockFile = ../packages/desktop/src-tauri/Cargo.lock;
allowBuiltinFetchGit = true;
};
node_modules = mkModules {
version = version;
src = src;
};
cargoRoot = "packages/desktop/src-tauri";
cargoLock.lockFile = ../packages/desktop/src-tauri/Cargo.lock;
buildAndTestSubdir = finalAttrs.cargoRoot;
nativeBuildInputs = [
pkg-config
cargo-tauri.hook
bun
makeBinaryWrapper
copyDesktopItems
nodejs # for patchShebangs node_modules
cargo
rustc
nodejs
jq
];
# based on packages/desktop/src-tauri/release/appstream.metainfo.xml
desktopItems = lib.optionals stdenv.isLinux [
(makeDesktopItem {
name = "ai.opencode.opencode";
desktopName = "OpenCode";
comment = "Open source AI coding agent";
exec = "opencode-desktop";
icon = "opencode";
terminal = false;
type = "Application";
categories = [ "Development" "IDE" ];
startupWMClass = "opencode";
})
];
buildInputs = [
openssl
makeWrapper
]
++ lib.optionals stdenv.isLinux [
++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
buildInputs = lib.optionals stdenv.isLinux [
dbus
glib
gtk3
gtk4
libsoup_3
webkitgtk_4_1
librsvg
libappindicator-gtk3
libappindicator
glib-networking
openssl
webkitgtk_4_1
gst_all_1.gstreamer
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-good
];
strictDeps = true;
preBuild = ''
# Restore node_modules
pushd ../../..
# Copy node_modules from the fixed-output derivation
# We use cp -r --no-preserve=mode to ensure we can write to them if needed,
# though we usually just read.
cp -r ${node_modules}/node_modules .
cp -r ${node_modules}/packages .
# Ensure node_modules is writable so patchShebangs can update script headers
chmod -R u+w node_modules
# Ensure workspace packages are writable for tsgo incremental outputs (.tsbuildinfo)
chmod -R u+w packages
# Patch shebangs so scripts can run
cp -a ${finalAttrs.node_modules}/{node_modules,packages} .
chmod -R u+w node_modules packages
patchShebangs node_modules
patchShebangs packages/desktop/node_modules
# Copy sidecar
mkdir -p packages/desktop/src-tauri/sidecars
targetTriple=${stdenv.hostPlatform.rust.rustcTarget}
cp ${args.opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-$targetTriple
# Merge prod config into tauri.conf.json
if ! jq -s '.[0] * .[1]' \
packages/desktop/src-tauri/tauri.conf.json \
packages/desktop/src-tauri/tauri.prod.conf.json \
> packages/desktop/src-tauri/tauri.conf.json.tmp; then
echo "Error: failed to merge tauri.conf.json with tauri.prod.conf.json" >&2
exit 1
fi
mv packages/desktop/src-tauri/tauri.conf.json.tmp packages/desktop/src-tauri/tauri.conf.json
# Build the frontend
cd packages/desktop
# The 'build' script runs 'bun run typecheck && vite build'.
bun run build
popd
cp ${opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-${stdenv.hostPlatform.rust.rustcTarget}
'';
# Tauri bundles the assets during the rust build phase (which happens after preBuild).
# It looks for them in the location specified in tauri.conf.json.
# see publish-tauri job in .github/workflows/publish.yml
tauriBuildFlags = [
"--config"
"tauri.prod.conf.json"
"--no-sign" # no code signing or auto updates
];
postInstall = lib.optionalString stdenv.isLinux ''
# Install icon
mkdir -p $out/share/icons/hicolor/128x128/apps
cp ../../../packages/desktop/src-tauri/icons/prod/128x128.png $out/share/icons/hicolor/128x128/apps/opencode.png
# Wrap the binary to ensure it finds the libraries
wrapProgram $out/bin/opencode-desktop \
--prefix LD_LIBRARY_PATH : ${
lib.makeLibraryPath [
gtk3
webkitgtk_4_1
librsvg
glib
libsoup_3
]
}
# FIXME: workaround for concerns about case insensitive filesystems
# should be removed once binary is renamed or decided otherwise
# darwin output is a .app bundle so no conflict
postFixup = lib.optionalString stdenv.hostPlatform.isLinux ''
mv $out/bin/OpenCode $out/bin/opencode-desktop
sed -i 's|^Exec=OpenCode$|Exec=opencode-desktop|' $out/share/applications/OpenCode.desktop
'';
meta = with lib; {
meta = {
description = "OpenCode Desktop App";
homepage = "https://opencode.ai";
license = licenses.mit;
maintainers = with maintainers; [ ];
license = lib.licenses.mit;
mainProgram = "opencode-desktop";
platforms = platforms.linux ++ platforms.darwin;
inherit (opencode.meta) platforms;
};
}
})

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=",
"aarch64-linux": "sha256-3/BSRsl5pI0Iz3qAFZxIkOehFLZ2Ox9UsbdDHYzqlVg=",
"aarch64-darwin": "sha256-86d/G1q6xiHSSlm+/irXoKLb/yLQbV348uuSrBV70+Q=",
"x86_64-darwin": "sha256-WYaP44PWRGtoG1DIuUJUH4DvuaCuFhlJZ9fPzGsiIfE="
"x86_64-linux": "sha256-D1VXuKJagfq3mxh8Xs8naHoYNJUJzAM9JLJqpHcItDk=",
"aarch64-linux": "sha256-9wXcg50Sv56Wb2x5NWe15olNGE/uMiDkmGRmqPoeW1U=",
"aarch64-darwin": "sha256-i5eTTjsNAARwcw69sd6wuse2BKTUi/Vfgo4M28l+RoY=",
"x86_64-darwin": "sha256-oFtQnIzgTS2zcjkhBTnXxYqr20KXdA2I+b908piLs+c="
}
}

View File

@@ -1,62 +0,0 @@
{
hash,
lib,
stdenvNoCC,
bun,
cacert,
curl,
bunCpu,
bunOs,
}:
args:
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
inherit (args) version src;
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
"GIT_PROXY_COMMAND"
"SOCKS_SERVER"
];
nativeBuildInputs = [
bun
cacert
curl
];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="${bunCpu}" \
--os="${bunOs}" \
--frozen-lockfile \
--ignore-scripts \
--no-progress \
--linker=isolated
bun --bun ${args.canonicalizeScript}
bun --bun ${args.normalizeBinsScript}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
while IFS= read -r dir; do
rel="''${dir#./}"
dest="$out/$rel"
mkdir -p "$(dirname "$dest")"
cp -R "$dir" "$dest"
done < <(find . -type d -name node_modules -prune | sort)
runHook postInstall
'';
dontFixup = true;
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = hash;
}

View File

@@ -2,60 +2,115 @@
lib,
stdenvNoCC,
bun,
ripgrep,
sysctl,
makeBinaryWrapper,
models-dev,
ripgrep,
installShellFiles,
versionCheckHook,
writableTmpDirAsHomeHook,
rev ? "dirty",
}:
args:
let
inherit (args) scripts;
mkModules =
attrs:
args.mkNodeModules (
attrs
// {
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
}
);
packageJson = lib.pipe ../packages/opencode/package.json [
builtins.readFile
builtins.fromJSON
];
in
stdenvNoCC.mkDerivation (finalAttrs: {
pname = "opencode";
inherit (args) version src;
version = "${packageJson.version}-${rev}";
node_modules = mkModules {
src = lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
lib.fileset.unions [
../packages
../bun.lock
../package.json
../patches
../install
]
);
};
node_modules = stdenvNoCC.mkDerivation {
pname = "${finalAttrs.pname}-node_modules";
inherit (finalAttrs) version src;
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
"GIT_PROXY_COMMAND"
"SOCKS_SERVER"
];
nativeBuildInputs = [
bun
];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="${if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64"}" \
--os="${if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin"}" \
--frozen-lockfile \
--ignore-scripts \
--no-progress \
--linker=isolated
bun --bun ${./scripts/canonicalize-node-modules.ts}
bun --bun ${./scripts/normalize-bun-binaries.ts}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
find . -type d -name node_modules -exec cp -R --parents {} $out \;
runHook postInstall
'';
dontFixup = true;
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash =
(lib.pipe ./hashes.json [
builtins.readFile
builtins.fromJSON
]).nodeModules.${stdenvNoCC.hostPlatform.system};
};
nativeBuildInputs = [
bun
installShellFiles
makeBinaryWrapper
models-dev
writableTmpDirAsHomeHook
];
env.MODELS_DEV_API_JSON = args.modelsDev;
env.OPENCODE_VERSION = args.version;
env.OPENCODE_CHANNEL = "stable";
dontConfigure = true;
configurePhase = ''
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
runHook postConfigure
'';
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
env.OPENCODE_VERSION = finalAttrs.version;
env.OPENCODE_CHANNEL = "local";
buildPhase = ''
runHook preBuild
cp -r ${finalAttrs.node_modules}/node_modules .
cp -r ${finalAttrs.node_modules}/packages .
(
cd packages/opencode
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
)
cd ./packages/opencode
bun --bun ./script/build.ts --single --skip-install
bun --bun ./script/schema.ts schema.json
runHook postBuild
'';
@@ -63,76 +118,52 @@ stdenvNoCC.mkDerivation (finalAttrs: {
installPhase = ''
runHook preInstall
cd packages/opencode
if [ ! -d dist ]; then
echo "ERROR: dist directory missing after bundle step"
exit 1
fi
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
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 [ ripgrep ]} \
--argv0 opencode
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath (
[
ripgrep
]
# bun runs sysctl to detect if dunning on rosetta2
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
)
}
runHook postInstall
'';
postInstall = ''
for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do
if [ -d "$pkg" ]; then
pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/')
ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \
$out/lib/opencode/node_modules/@opentui/$pkgName
fi
done
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions
installShellCompletion --cmd opencode \
--bash <($out/bin/opencode completion) \
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
'';
dontFixup = true;
nativeInstallCheckInputs = [
versionCheckHook
writableTmpDirAsHomeHook
];
doInstallCheck = true;
versionCheckKeepEnvironment = [ "HOME" ];
versionCheckProgramArg = "--version";
passthru = {
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
};
meta = {
description = "AI coding agent built for the terminal";
longDescription = ''
OpenCode is a terminal-based agent that can build anything.
It combines a TypeScript/JavaScript core with a Go-based TUI
to provide an interactive AI coding experience.
'';
homepage = "https://github.com/anomalyco/opencode";
description = "The open source coding agent";
homepage = "https://opencode.ai/";
license = lib.licenses.mit;
mainProgram = "opencode";
platforms = [
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
mainProgram = "opencode";
};
})

View File

@@ -1,120 +0,0 @@
import solidPlugin from "./packages/opencode/node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
import fs from "fs"
const version = "@VERSION@"
const pkg = path.join(process.cwd(), "packages/opencode")
const parser = fs.realpathSync(path.join(pkg, "./node_modules/@opentui/core/parser.worker.js"))
const worker = "./src/cli/cmd/tui/worker.ts"
const target = process.env["BUN_COMPILE_TARGET"]
if (!target) {
throw new Error("BUN_COMPILE_TARGET not set")
}
process.chdir(pkg)
const manifestName = "opencode-assets.manifest"
const manifestPath = path.join(pkg, manifestName)
const readTrackedAssets = () => {
if (!fs.existsSync(manifestPath)) return []
return fs
.readFileSync(manifestPath, "utf8")
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
}
const removeTrackedAssets = () => {
for (const file of readTrackedAssets()) {
const filePath = path.join(pkg, file)
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true })
}
}
}
const assets = new Set<string>()
const addAsset = async (p: string) => {
const file = path.basename(p)
const dest = path.join(pkg, file)
await Bun.write(dest, Bun.file(p))
assets.add(file)
}
removeTrackedAssets()
const result = await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
sourcemap: "external",
entrypoints: ["./src/index.ts", parser, worker],
define: {
OPENCODE_VERSION: `'@VERSION@'`,
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(pkg, parser).replace(/\\/g, "/"),
OPENCODE_CHANNEL: "'latest'",
},
compile: {
target,
outfile: "opencode",
autoloadBunfig: false,
autoloadDotenv: false,
//@ts-ignore (bun types aren't up to date)
autoloadTsconfig: true,
autoloadPackageJson: true,
execArgv: ["--user-agent=opencode/" + version, "--use-system-ca", "--"],
windows: {},
},
})
if (!result.success) {
console.error("Build failed!")
for (const log of result.logs) {
console.error(log)
}
throw new Error("Compilation failed")
}
const assetOutputs = result.outputs?.filter((x) => x.kind === "asset") ?? []
for (const x of assetOutputs) {
await addAsset(x.path)
}
const bundle = await Bun.build({
entrypoints: [worker],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
target: "bun",
outdir: "./.opencode-worker",
sourcemap: "none",
})
if (!bundle.success) {
console.error("Worker build failed!")
for (const log of bundle.logs) {
console.error(log)
}
throw new Error("Worker compilation failed")
}
const workerAssets = bundle.outputs?.filter((x) => x.kind === "asset") ?? []
for (const x of workerAssets) {
await addAsset(x.path)
}
const output = bundle.outputs.find((x) => x.kind === "entry-point")
if (!output) {
throw new Error("Worker build produced no entry-point output")
}
const dest = path.join(pkg, "opencode-worker.js")
await Bun.write(dest, Bun.file(output.path))
fs.rmSync(path.dirname(output.path), { recursive: true, force: true })
const list = Array.from(assets)
await Bun.write(manifestPath, list.length > 0 ? list.join("\n") + "\n" : "")
console.log("Build successful!")

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
/**
* Rewrite tree-sitter wasm references inside a JS file to absolute paths.
* argv: [node, script, file, mainWasm, ...wasmPaths]
*/
const [, , file, mainWasm, ...wasmPaths] = process.argv
if (!file || !mainWasm) {
console.error("usage: patch-wasm <file> <mainWasm> [wasmPaths...]")
process.exit(1)
}
const content = fs.readFileSync(file, "utf8")
const byName = new Map<string, string>()
for (const wasm of wasmPaths) {
const name = path.basename(wasm)
byName.set(name, wasm)
}
let next = content
for (const [name, wasmPath] of byName) {
next = next.replaceAll(name, wasmPath)
}
next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
const nixStorePrefix = process.env.NIX_STORE || "/nix/store"
next = next.replace(/(\.\/)+/g, "./")
next = next.replace(
new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"),
"/$2",
)
next = next.replace(new RegExp(`(["'])\\/{2,}(\\/${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
next = next.replace(new RegExp(`(["'])\\/\\/(${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
if (next !== content) fs.writeFileSync(file, next)

View File

@@ -29,7 +29,7 @@ import { Suspense } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full flex items-center justify-center text-text-weak">Loading...</div>
const Loading = () => <div class="size-full" />
declare global {
interface Window {

View File

@@ -149,7 +149,7 @@ export function DialogSelectFile() {
<Show
when={item.type === "command"}
fallback={
<div class="w-full flex items-center justify-between rounded-md">
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">

View File

@@ -1,15 +1,17 @@
import { createMemo, createResource, Show } from "solid-js"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
// import { useServer } from "@/context/server"
// import { useDialog } from "@opencode-ai/ui/context/dialog"
import { usePlatform } from "@/context/platform"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { base64Decode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
@@ -26,6 +28,7 @@ export function SessionHeader() {
// const server = useServer()
// const dialog = useDialog()
const sync = useSync()
const platform = usePlatform()
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const project = createMemo(() => {
@@ -45,6 +48,78 @@ export function SessionHeader() {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey()))
const [state, setState] = createStore({
share: false,
unshare: false,
copied: false,
timer: undefined as number | undefined,
})
const shareUrl = createMemo(() => currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
})
onCleanup(() => {
if (state.timer) window.clearTimeout(state.timer)
})
function shareSession() {
const session = currentSession()
if (!session || state.share) return
setState("share", true)
globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
.finally(() => {
setState("share", false)
})
}
function unshareSession() {
const session = currentSession()
if (!session || state.unshare) return
setState("unshare", true)
globalSDK.client.session
.unshare({ sessionID: session.id, directory: projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
.finally(() => {
setState("unshare", false)
})
}
function copyLink() {
const url = shareUrl()
if (!url) return
navigator.clipboard
.writeText(url)
.then(() => {
if (state.timer) window.clearTimeout(state.timer)
setState("copied", true)
const timer = window.setTimeout(() => {
setState("copied", false)
setState("timer", undefined)
}, 3000)
setState("timer", timer)
})
.catch((error) => {
console.error("Failed to copy share link", error)
})
}
function viewShare() {
const url = shareUrl()
if (!url) return
platform.openLink(url)
}
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
@@ -58,14 +133,14 @@ export function SessionHeader() {
class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
onClick={() => command.trigger("file.open")}
>
<div class="flex items-center gap-2">
<Icon name="magnifying-glass" size="normal" class="icon-base" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate h-3.5 flex items-center overflow-visible">
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-visible">
<Icon name="magnifying-glass" size="normal" class="icon-base shrink-0" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate h-4.5 flex items-center">
Search {name()}
</span>
</div>
<Show when={hotkey()}>{(keybind) => <Keybind>{keybind()}</Keybind>}</Show>
<Show when={hotkey()}>{(keybind) => <Keybind class="shrink-0">{keybind()}</Keybind>}</Show>
</button>
</Portal>
)}
@@ -159,40 +234,77 @@ export function SessionHeader() {
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
<div class="flex items-center">
<Popover
title="Publish on web"
description={
shareUrl()
? "This session is public on the web. It is accessible to anyone with the link."
: "Share session publicly on the web. It will be accessible to anyone with the link."
}
trigger={
<Tooltip class="shrink-0" value="Share session">
<Button variant="secondary" classList={{ "rounded-r-none": shareUrl() !== undefined }}>
Share
</Button>
</Tooltip>
}
>
<div class="flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={shareSession}
disabled={state.share}
>
{state.share ? "Publishing..." : "Publish"}
</Button>
</div>
}
return shareURL
},
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
>
<div class="flex flex-col gap-2 w-72">
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={state.unshare}
>
{state.unshare ? "Unpublishing..." : "Unpublish"}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={state.unshare}
>
View
</Button>
</div>
</div>
</Show>
)
})}
</Popover>
</div>
</Popover>
<Show when={shareUrl()}>
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top" gutter={8}>
<IconButton
icon={state.copied ? "check" : "copy"}
variant="secondary"
class="rounded-l-none border-l border-border-weak-base"
onClick={copyLink}
disabled={state.unshare}
/>
</Tooltip>
</Show>
</div>
</Show>
</div>
</Portal>

View File

@@ -1018,7 +1018,7 @@ export default function Layout(props: ParentProps) {
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)"
const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)"
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
@@ -1039,7 +1039,7 @@ export default function Layout(props: ParentProps) {
<Show when={notifications().length > 0 && props.notify}>
<div
classList={{
"absolute -top-px -right-px size-2 rounded-full z-10": true,
"absolute top-px right-px size-1.5 rounded-full z-10": true,
"bg-icon-critical-base": hasError(),
"bg-text-interactive-base": !hasError(),
}}
@@ -1089,33 +1089,39 @@ export default function Layout(props: ParentProps) {
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={16} openDelay={1000}>
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<Tooltip
placement="top-start"
value={props.session.title}
gutter={0}
openDelay={3000}
class="grow-1 min-w-0"
>
<InlineEditor
id={`session:${props.session.id}`}
value={() => props.session.title}
@@ -1124,16 +1130,16 @@ export default function Layout(props: ParentProps) {
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
stopPropagation
/>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div>
</A>
</Tooltip>
</Tooltip>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div>
</A>
<div
class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
>
@@ -1338,6 +1344,8 @@ export default function Layout(props: ParentProps) {
const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)())
const [open, setOpen] = createSignal(false)
const label = (directory: string) => {
const [data] = globalSync.child(directory)
const kind = directory === props.project.worktree ? "local" : "sandbox"
@@ -1370,7 +1378,8 @@ export default function Layout(props: ParentProps) {
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!selected(),
!selected() && !open(),
"bg-surface-base-hover border border-border-weak-base": !selected() && open(),
}}
onClick={() => navigateToProject(props.project.worktree)}
>
@@ -1381,9 +1390,17 @@ export default function Layout(props: ParentProps) {
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={6} trigger={trigger}>
<HoverCard
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={6}
trigger={trigger}
onOpenChange={setOpen}
>
<div class="-m-3 flex flex-col w-72">
<div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-4 pt-2 pb-1 text-14-medium text-text-strong truncate">{displayName(props.project)}</div>
<div class="px-4 pb-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-2 pb-2 flex flex-col gap-2">
<Show
when={workspaceEnabled()}
@@ -1610,7 +1627,16 @@ export default function Layout(props: ParentProps) {
stopPropagation
/>
<Tooltip placement="right" value={project()?.worktree} class="shrink-0">
<Tooltip
placement={sidebarProps.mobile ? "bottom" : "top"}
gutter={2}
value={project()?.worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate">
{project()?.worktree.replace(homedir(), "~")}
</span>
@@ -1652,7 +1678,7 @@ export default function Layout(props: ParentProps) {
<Button
size="large"
icon="plus-small"
class="w-full max-w-[256px]"
class="w-full"
onClick={() => {
navigate(`/${base64Encode(p.worktree)}/session`)
layout.mobileSidebar.hide()
@@ -1669,7 +1695,7 @@ export default function Layout(props: ParentProps) {
>
<>
<div class="py-4 px-3">
<Button size="large" icon="plus-small" class="w-full max-w-[256px]" onClick={createWorkspace}>
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
New workspace
</Button>
</div>

View File

@@ -533,10 +533,6 @@ export default function Page() {
keybind: "shift+mod+t",
onSelect: () => {
local.model.variant.cycle()
showToast({
title: "Thinking effort changed",
description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"),
})
},
},
{
@@ -654,6 +650,72 @@ export default function Page() {
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
...(sync.data.config.share !== "disabled"
? [
{
id: "session.share",
title: "Share session",
description: "Share this session and copy the URL to clipboard",
category: "Session",
slash: "share",
disabled: !params.id || !!info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.share({ sessionID: params.id })
.then((res) => {
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
showToast({
title: "Failed to copy URL to clipboard",
variant: "error",
}),
)
})
.then(() =>
showToast({
title: "Session shared",
description: "Share URL copied to clipboard!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to share session",
description: "An error occurred while sharing the session",
variant: "error",
}),
)
},
},
{
id: "session.unshare",
title: "Unshare session",
description: "Stop sharing this session",
category: "Session",
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: "Session unshared",
description: "Session unshared successfully!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to unshare session",
description: "An error occurred while unsharing the session",
variant: "error",
}),
)
},
},
]
: []),
])
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1091,7 +1153,7 @@ export default function Page() {
file.load(path)
}}
classes={{
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
root: "pb-[calc(var(--prompt-height,8rem)+24px)]",
header: "px-4",
container: "px-4",
}}
@@ -1237,7 +1299,7 @@ export default function Page() {
{/* Prompt input */}
<div
ref={(el) => (promptDock = el)}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
>
<div
classList={{

View File

@@ -12,7 +12,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo"
import { Splash } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
@@ -357,8 +357,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Logo class="w-xl opacity-12 animate-pulse" />
<div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>

View File

@@ -70,7 +70,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.1",
"@gitlab/gitlab-ai-provider": "3.1.2",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",

View File

@@ -90,6 +90,11 @@ const targets = singleFlag
return baselineFlag
}
// also skip abi-specific builds for the same reason
if (item.abi !== undefined) {
return false
}
return true
})
: allTargets

View File

@@ -20,7 +20,7 @@ import {
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { ACPSessionManager } from "./session"
import type { ACPConfig, ACPSessionState } from "./types"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
import { Agent as AgentModule } from "../agent/agent"
import { Installation } from "@/installation"
@@ -29,7 +29,7 @@ import { Config } from "@/config/config"
import { Todo } from "@/session/todo"
import { z } from "zod"
import { LoadAPIKeyError } from "ai"
import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
export namespace ACP {
@@ -47,304 +47,354 @@ export namespace ACP {
private connection: AgentSideConnection
private config: ACPConfig
private sdk: OpencodeClient
private sessionManager
private sessionManager: ACPSessionManager
private eventAbort = new AbortController()
private eventStarted = false
private permissionQueues = new Map<string, Promise<void>>()
private permissionOptions: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
constructor(connection: AgentSideConnection, config: ACPConfig) {
this.connection = connection
this.config = config
this.sdk = config.sdk
this.sessionManager = new ACPSessionManager(this.sdk)
this.startEventSubscription()
}
private setupEventSubscriptions(session: ACPSessionState) {
const sessionId = session.id
const directory = session.cwd
private startEventSubscription() {
if (this.eventStarted) return
this.eventStarted = true
this.runEventSubscription().catch((error) => {
if (this.eventAbort.signal.aborted) return
log.error("event subscription failed", { error })
})
}
const options: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
this.config.sdk.event.subscribe({ directory }).then(async (events) => {
private async runEventSubscription() {
while (true) {
if (this.eventAbort.signal.aborted) return
const events = await this.sdk.global.event({
signal: this.eventAbort.signal,
})
for await (const event of events.stream) {
switch (event.type) {
case "permission.asked":
try {
const permission = event.properties
const res = await this.connection
.requestPermission({
sessionId,
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options,
if (this.eventAbort.signal.aborted) return
const payload = (event as any)?.payload
if (!payload) continue
await this.handleEvent(payload as Event).catch((error) => {
log.error("failed to handle event", { error, type: payload.type })
})
}
}
}
private async handleEvent(event: Event) {
switch (event.type) {
case "permission.asked": {
const permission = event.properties
const session = this.sessionManager.tryGet(permission.sessionID)
if (!session) return
const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
const next = prev
.then(async () => {
const directory = session.cwd
const res = await this.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options: this.permissionOptions,
})
.catch(async (error) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
.catch(async (error) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.config.sdk.permission.reply({
requestID: permission.id,
reply: "reject",
directory,
})
return
})
if (!res) return
if (res.outcome.outcome !== "selected") {
await this.config.sdk.permission.reply({
await this.sdk.permission.reply({
requestID: permission.id,
reply: "reject",
directory,
})
return
}
if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
const metadata = permission.metadata || {}
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
return undefined
})
const content = await Bun.file(filepath).text()
const newContent = getNewContent(content, diff)
if (newContent) {
this.connection.writeTextFile({
sessionId: sessionId,
path: filepath,
content: newContent,
})
}
}
await this.config.sdk.permission.reply({
if (!res) return
if (res.outcome.outcome !== "selected") {
await this.sdk.permission.reply({
requestID: permission.id,
reply: res.outcome.optionId as "once" | "always" | "reject",
reply: "reject",
directory,
})
} catch (err) {
log.error("unexpected error when handling permission", { error: err })
} finally {
break
return
}
case "message.part.updated":
log.info("message part updated", { event: event.properties })
try {
const props = event.properties
const { part } = props
if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
const metadata = permission.metadata || {}
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
const message = await this.config.sdk.session
.message(
{
sessionID: part.sessionID,
messageID: part.messageID,
directory,
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch((err) => {
log.error("unexpected error when fetching message", { error: err })
return undefined
const content = await Bun.file(filepath).text()
const newContent = getNewContent(content, diff)
if (newContent) {
this.connection.writeTextFile({
sessionId: session.id,
path: filepath,
content: newContent,
})
}
}
if (!message || message.info.role !== "assistant") return
await this.sdk.permission.reply({
requestID: permission.id,
reply: res.outcome.optionId as "once" | "always" | "reject",
directory,
})
})
.catch((error) => {
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.finally(() => {
if (this.permissionQueues.get(permission.sessionID) === next) {
this.permissionQueues.delete(permission.sessionID)
}
})
this.permissionQueues.set(permission.sessionID, next)
return
}
if (part.type === "tool") {
switch (part.state.status) {
case "pending":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: part.callID,
title: part.tool,
kind: toToolKind(part.tool),
status: "pending",
locations: [],
rawInput: {},
},
})
.catch((err) => {
log.error("failed to send tool pending to ACP", { error: err })
})
break
case "running":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
kind: toToolKind(part.tool),
title: part.tool,
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
})
.catch((err) => {
log.error("failed to send tool in_progress to ACP", { error: err })
})
break
case "completed":
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
case "message.part.updated": {
log.info("message part updated", { event: event.properties })
const props = event.properties
const part = props.part
const session = this.sessionManager.tryGet(part.sessionID)
if (!session) return
const sessionId = session.id
const directory = session.cwd
const message = await this.sdk.session
.message(
{
sessionID: part.sessionID,
messageID: part.messageID,
directory,
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch((error) => {
log.error("unexpected error when fetching message", { error })
return undefined
})
if (!message || message.info.role !== "assistant") return
if (part.type === "tool") {
switch (part.state.status) {
case "pending":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: part.callID,
title: part.tool,
kind: toToolKind(part.tool),
status: "pending",
locations: [],
rawInput: {},
},
})
.catch((error) => {
log.error("failed to send tool pending to ACP", { error })
})
return
case "running":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "in_progress",
kind: toToolKind(part.tool),
title: part.tool,
locations: toLocations(part.tool, part.state.input),
rawInput: part.state.input,
},
})
.catch((error) => {
log.error("failed to send tool in_progress to ACP", { error })
})
return
case "completed": {
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => {
const status: PlanEntry["status"] =
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
return {
priority: "medium",
status,
content: todo.content,
}
}),
},
})
.catch((error) => {
log.error("failed to send session update for todo", { error })
})
} else {
log.error("failed to parse todo output", { error: parsedTodos.error })
}
}
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "completed",
kind,
content,
title: part.state.title,
rawInput: part.state.input,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
},
},
})
.catch((error) => {
log.error("failed to send tool completed to ACP", { error })
})
return
}
case "error":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "failed",
kind: toToolKind(part.tool),
title: part.tool,
rawInput: part.state.input,
content: [
{
type: "content",
content: {
type: "text",
text: part.state.output,
text: part.state.error,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => {
const status: PlanEntry["status"] =
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
return {
priority: "medium",
status,
content: todo.content,
}
}),
},
})
.catch((err) => {
log.error("failed to send session update for todo", { error: err })
})
} else {
log.error("failed to parse todo output", { error: parsedTodos.error })
}
}
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "completed",
kind,
content,
title: part.state.title,
rawInput: part.state.input,
rawOutput: {
output: part.state.output,
metadata: part.state.metadata,
},
},
})
.catch((err) => {
log.error("failed to send tool completed to ACP", { error: err })
})
break
case "error":
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "failed",
kind: toToolKind(part.tool),
title: part.tool,
rawInput: part.state.input,
content: [
{
type: "content",
content: {
type: "text",
text: part.state.error,
},
},
],
rawOutput: {
error: part.state.error,
},
},
})
.catch((err) => {
log.error("failed to send tool error to ACP", { error: err })
})
break
}
} else if (part.type === "text") {
const delta = props.delta
if (delta && part.synthetic !== true) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((err) => {
log.error("failed to send text to ACP", { error: err })
})
}
} else if (part.type === "reasoning") {
const delta = props.delta
if (delta) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_thought_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((err) => {
log.error("failed to send reasoning to ACP", { error: err })
})
}
}
} finally {
break
}
],
rawOutput: {
error: part.state.error,
},
},
})
.catch((error) => {
log.error("failed to send tool error to ACP", { error })
})
return
}
}
if (part.type === "text") {
const delta = props.delta
if (delta && part.ignored !== true) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((error) => {
log.error("failed to send text to ACP", { error })
})
}
return
}
if (part.type === "reasoning") {
const delta = props.delta
if (delta) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_thought_chunk",
content: {
type: "text",
text: delta,
},
},
})
.catch((error) => {
log.error("failed to send reasoning to ACP", { error })
})
}
}
return
}
})
}
}
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
@@ -409,8 +459,6 @@ export namespace ACP {
sessionId,
})
this.setupEventSubscriptions(state)
return {
sessionId,
models: load.models,
@@ -436,18 +484,16 @@ export namespace ACP {
const model = await defaultModel(this.config, directory)
// Store ACP session state
const state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
const mode = await this.loadSessionMode({
const result = await this.loadSessionMode({
cwd: directory,
mcpServers: params.mcpServers,
sessionId,
})
this.setupEventSubscriptions(state)
// Replay session history
const messages = await this.sdk.session
.messages(
@@ -463,12 +509,20 @@ export namespace ACP {
return undefined
})
const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
if (lastUser?.role === "user") {
result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
if (result.modes.availableModes.some((m) => m.id === lastUser.agent)) {
result.modes.currentModeId = lastUser.agent
}
}
for (const msg of messages ?? []) {
log.debug("replay message", msg)
await this.processMessage(msg)
}
return mode
return result
} catch (e) {
const error = MessageV2.fromError(e, {
providerID: this.config.defaultModel?.providerID ?? "unknown",
@@ -633,7 +687,7 @@ export namespace ACP {
break
}
} else if (part.type === "text") {
if (part.text) {
if (part.text && !part.ignored) {
await this.connection
.sessionUpdate({
sessionId,
@@ -649,6 +703,83 @@ export namespace ACP {
log.error("failed to send text to ACP", { error: err })
})
}
} else if (part.type === "file") {
// Replay file attachments as appropriate ACP content blocks.
// OpenCode stores files internally as { type: "file", url, filename, mime }.
// We convert these back to ACP blocks based on the URL scheme and MIME type:
// - file:// URLs → resource_link
// - data: URLs with image/* → image block
// - data: URLs with text/* or application/json → resource with text
// - data: URLs with other types → resource with blob
const url = part.url
const filename = part.filename ?? "file"
const mime = part.mime || "application/octet-stream"
const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk"
if (url.startsWith("file://")) {
// Local file reference - send as resource_link
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: messageChunk,
content: { type: "resource_link", uri: url, name: filename, mimeType: mime },
},
})
.catch((err) => {
log.error("failed to send resource_link to ACP", { error: err })
})
} else if (url.startsWith("data:")) {
// Embedded content - parse data URL and send as appropriate block type
const base64Match = url.match(/^data:([^;]+);base64,(.*)$/)
const dataMime = base64Match?.[1]
const base64Data = base64Match?.[2] ?? ""
const effectiveMime = dataMime || mime
if (effectiveMime.startsWith("image/")) {
// Image - send as image block
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: messageChunk,
content: {
type: "image",
mimeType: effectiveMime,
data: base64Data,
uri: `file://${filename}`,
},
},
})
.catch((err) => {
log.error("failed to send image to ACP", { error: err })
})
} else {
// Non-image: text types get decoded, binary types stay as blob
const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
const resource = isText
? {
uri: `file://${filename}`,
mimeType: effectiveMime,
text: Buffer.from(base64Data, "base64").toString("utf-8"),
}
: { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data }
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: messageChunk,
content: { type: "resource", resource },
},
})
.catch((err) => {
log.error("failed to send resource to ACP", { error: err })
})
}
}
// URLs that don't match file:// or data: are skipped (unsupported)
} else if (part.type === "reasoning") {
if (part.text) {
await this.connection
@@ -847,39 +978,57 @@ export namespace ACP {
text: part.text,
})
break
case "image":
case "image": {
const parsed = parseUri(part.uri ?? "")
const filename = parsed.type === "file" ? parsed.filename : "image"
if (part.data) {
parts.push({
type: "file",
url: `data:${part.mimeType};base64,${part.data}`,
filename: "image",
filename,
mime: part.mimeType,
})
} else if (part.uri && part.uri.startsWith("http:")) {
parts.push({
type: "file",
url: part.uri,
filename: "image",
filename,
mime: part.mimeType,
})
}
break
}
case "resource_link":
const parsed = parseUri(part.uri)
// Use the name from resource_link if available
if (part.name && parsed.type === "file") {
parsed.filename = part.name
}
parts.push(parsed)
break
case "resource":
case "resource": {
const resource = part.resource
if ("text" in resource) {
if ("text" in resource && resource.text) {
parts.push({
type: "text",
text: resource.text,
})
} else if ("blob" in resource && resource.blob && resource.mimeType) {
// Binary resource (PDFs, etc.): store as file part with data URL
const parsed = parseUri(resource.uri ?? "")
const filename = parsed.type === "file" ? parsed.filename : "file"
parts.push({
type: "file",
url: `data:${resource.mimeType};base64,${resource.blob}`,
filename,
mime: resource.mimeType,
})
}
break
}
default:
break

View File

@@ -13,6 +13,10 @@ export class ACPSessionManager {
this.sdk = sdk
}
tryGet(sessionId: string): ACPSessionState | undefined {
return this.sessions.get(sessionId)
}
async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise<ACPSessionState> {
const session = await this.sdk.session
.create(

View File

@@ -288,6 +288,10 @@ function App() {
keybind: "session_list",
category: "Session",
suggested: sync.data.session.length > 0,
slash: {
name: "sessions",
aliases: ["resume", "continue"],
},
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
@@ -298,6 +302,10 @@ function App() {
value: "session.new",
keybind: "session_new",
category: "Session",
slash: {
name: "new",
aliases: ["clear"],
},
onSelect: () => {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
@@ -315,26 +323,29 @@ function App() {
keybind: "model_list",
suggested: true,
category: "Agent",
slash: {
name: "models",
},
onSelect: () => {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Model cycle",
disabled: true,
value: "model.cycle_recent",
keybind: "model_cycle_recent",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycle(1)
},
},
{
title: "Model cycle reverse",
disabled: true,
value: "model.cycle_recent_reverse",
keybind: "model_cycle_recent_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycle(-1)
},
@@ -344,6 +355,7 @@ function App() {
value: "model.cycle_favorite",
keybind: "model_cycle_favorite",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycleFavorite(1)
},
@@ -353,6 +365,7 @@ function App() {
value: "model.cycle_favorite_reverse",
keybind: "model_cycle_favorite_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycleFavorite(-1)
},
@@ -362,6 +375,9 @@ function App() {
value: "agent.list",
keybind: "agent_list",
category: "Agent",
slash: {
name: "agents",
},
onSelect: () => {
dialog.replace(() => <DialogAgent />)
},
@@ -370,6 +386,9 @@ function App() {
title: "Toggle MCPs",
value: "mcp.list",
category: "Agent",
slash: {
name: "mcps",
},
onSelect: () => {
dialog.replace(() => <DialogMcp />)
},
@@ -379,7 +398,7 @@ function App() {
value: "agent.cycle",
keybind: "agent_cycle",
category: "Agent",
disabled: true,
hidden: true,
onSelect: () => {
local.agent.move(1)
},
@@ -389,6 +408,7 @@ function App() {
value: "variant.cycle",
keybind: "variant_cycle",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.variant.cycle()
},
@@ -398,7 +418,7 @@ function App() {
value: "agent.cycle.reverse",
keybind: "agent_cycle_reverse",
category: "Agent",
disabled: true,
hidden: true,
onSelect: () => {
local.agent.move(-1)
},
@@ -407,6 +427,9 @@ function App() {
title: "Connect provider",
value: "provider.connect",
suggested: !connected(),
slash: {
name: "connect",
},
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
@@ -416,6 +439,9 @@ function App() {
title: "View status",
keybind: "status_view",
value: "opencode.status",
slash: {
name: "status",
},
onSelect: () => {
dialog.replace(() => <DialogStatus />)
},
@@ -425,6 +451,9 @@ function App() {
title: "Switch theme",
value: "theme.switch",
keybind: "theme_list",
slash: {
name: "themes",
},
onSelect: () => {
dialog.replace(() => <DialogThemeList />)
},
@@ -442,6 +471,9 @@ function App() {
{
title: "Help",
value: "help.show",
slash: {
name: "help",
},
onSelect: () => {
dialog.replace(() => <DialogHelp />)
},
@@ -468,6 +500,10 @@ function App() {
{
title: "Exit the app",
value: "app.exit",
slash: {
name: "exit",
aliases: ["quit", "q"],
},
onSelect: () => exit(),
category: "System",
},
@@ -508,6 +544,7 @@ function App() {
value: "terminal.suspend",
keybind: "terminal_suspend",
category: "System",
hidden: true,
onSelect: () => {
process.once("SIGCONT", () => {
renderer.resume()

View File

@@ -16,9 +16,17 @@ import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
export type CommandOption = DialogSelectOption & {
export type Slash = {
name: string
aliases?: string[]
}
export type CommandOption = DialogSelectOption<string> & {
keybind?: keyof KeybindsConfig
suggested?: boolean
slash?: Slash
hidden?: boolean
enabled?: boolean
}
function init() {
@@ -26,27 +34,35 @@ function init() {
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const keybind = useKeybind()
const options = createMemo(() => {
const entries = createMemo(() => {
const all = registrations().flatMap((x) => x())
const suggested = all.filter((x) => x.suggested)
return [
...suggested.map((x) => ({
...x,
category: "Suggested",
value: "suggested." + x.value,
})),
...all,
].map((x) => ({
return all.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))
})
const isEnabled = (option: CommandOption) => option.enabled !== false
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
)
const suspended = () => suspendCount() > 0
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
for (const option of options()) {
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
@@ -56,20 +72,33 @@ function init() {
})
const result = {
trigger(name: string, source?: "prompt") {
for (const option of options()) {
trigger(name: string) {
for (const option of entries()) {
if (option.value === name) {
option.onSelect?.(dialog, source)
if (!isEnabled(option)) return
option.onSelect?.(dialog)
return
}
}
},
slashes() {
return visibleOptions().flatMap((option) => {
const slash = option.slash
if (!slash) return []
return {
display: "/" + slash.name,
description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value),
}
})
},
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
suspended,
show() {
dialog.replace(() => <DialogCommand options={options()} />)
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
},
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
@@ -78,9 +107,6 @@ function init() {
setRegistrations((arr) => arr.filter((x) => x !== results))
})
},
get options() {
return options()
},
}
return result
}
@@ -104,7 +130,7 @@ export function CommandProvider(props: ParentProps) {
if (evt.defaultPrevented) return
if (keybind.match("command_list", evt)) {
evt.preventDefault()
dialog.replace(() => <DialogCommand options={value.options} />)
value.show()
return
}
})
@@ -112,13 +138,11 @@ export function CommandProvider(props: ParentProps) {
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
function DialogCommand(props: { options: CommandOption[] }) {
function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
let ref: DialogSelectRef<string>
return (
<DialogSelect
ref={(r) => (ref = r)}
title="Commands"
options={props.options.filter((x) => !ref?.filter || !x.value.startsWith("suggested."))}
/>
)
const list = () => {
if (ref?.filter) return props.options
return [...props.suggestedOptions, ...props.options]
}
return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
}

View File

@@ -332,16 +332,15 @@ export function Autocomplete(props: {
)
})
const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined))
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = []
const s = session()
for (const command of sync.data.command) {
const results: AutocompleteOption[] = [...command.slashes()]
for (const serverCommand of sync.data.command) {
results.push({
display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
description: command.description,
display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""),
description: serverCommand.description,
onSelect: () => {
const newText = "/" + command.name + " "
const newText = "/" + serverCommand.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
@@ -349,138 +348,9 @@ export function Autocomplete(props: {
},
})
}
if (s) {
results.push(
{
display: "/undo",
description: "undo the last message",
onSelect: () => {
command.trigger("session.undo")
},
},
{
display: "/redo",
description: "redo the last message",
onSelect: () => command.trigger("session.redo"),
},
{
display: "/compact",
aliases: ["/summarize"],
description: "compact the session",
onSelect: () => command.trigger("session.compact"),
},
{
display: "/unshare",
disabled: !s.share,
description: "unshare a session",
onSelect: () => command.trigger("session.unshare"),
},
{
display: "/rename",
description: "rename session",
onSelect: () => command.trigger("session.rename"),
},
{
display: "/copy",
description: "copy session transcript to clipboard",
onSelect: () => command.trigger("session.copy"),
},
{
display: "/export",
description: "export session transcript to file",
onSelect: () => command.trigger("session.export"),
},
{
display: "/timeline",
description: "jump to message",
onSelect: () => command.trigger("session.timeline"),
},
{
display: "/fork",
description: "fork from message",
onSelect: () => command.trigger("session.fork"),
},
{
display: "/thinking",
description: "toggle thinking visibility",
onSelect: () => command.trigger("session.toggle.thinking"),
},
)
if (sync.data.config.share !== "disabled") {
results.push({
display: "/share",
disabled: !!s.share?.url,
description: "share a session",
onSelect: () => command.trigger("session.share"),
})
}
}
results.push(
{
display: "/new",
aliases: ["/clear"],
description: "create a new session",
onSelect: () => command.trigger("session.new"),
},
{
display: "/models",
description: "list models",
onSelect: () => command.trigger("model.list"),
},
{
display: "/agents",
description: "list agents",
onSelect: () => command.trigger("agent.list"),
},
{
display: "/session",
aliases: ["/resume", "/continue"],
description: "list sessions",
onSelect: () => command.trigger("session.list"),
},
{
display: "/status",
description: "show status",
onSelect: () => command.trigger("opencode.status"),
},
{
display: "/mcp",
description: "toggle MCPs",
onSelect: () => command.trigger("mcp.list"),
},
{
display: "/theme",
description: "toggle theme",
onSelect: () => command.trigger("theme.switch"),
},
{
display: "/editor",
description: "open editor",
onSelect: () => command.trigger("prompt.editor", "prompt"),
},
{
display: "/connect",
description: "connect to a provider",
onSelect: () => command.trigger("provider.connect"),
},
{
display: "/help",
description: "show help",
onSelect: () => command.trigger("help.show"),
},
{
display: "/commands",
description: "show all commands",
onSelect: () => command.show(),
},
{
display: "/exit",
aliases: ["/quit", "/q"],
description: "exit the app",
onSelect: () => command.trigger("app.exit"),
},
)
results.sort((a, b) => a.display.localeCompare(b.display))
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
if (!max) return results
return results.map((item) => ({
@@ -494,9 +364,8 @@ export function Autocomplete(props: {
const agentsValue = agents()
const commandsValue = commands()
const mixed: AutocompleteOption[] = (
const mixed: AutocompleteOption[] =
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
).filter((x) => x.disabled !== true)
const currentFilter = filter()

View File

@@ -157,7 +157,7 @@ export function Prompt(props: PromptProps) {
title: "Clear prompt",
value: "prompt.clear",
category: "Prompt",
disabled: true,
hidden: true,
onSelect: (dialog) => {
input.extmarks.clear()
input.clear()
@@ -167,9 +167,9 @@ export function Prompt(props: PromptProps) {
{
title: "Submit prompt",
value: "prompt.submit",
disabled: true,
keybind: "input_submit",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
if (!input.focused) return
submit()
@@ -179,9 +179,9 @@ export function Prompt(props: PromptProps) {
{
title: "Paste",
value: "prompt.paste",
disabled: true,
keybind: "input_paste",
category: "Prompt",
hidden: true,
onSelect: async () => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
@@ -197,8 +197,9 @@ export function Prompt(props: PromptProps) {
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
disabled: status().type === "idle",
category: "Session",
hidden: true,
enabled: status().type !== "idle",
onSelect: (dialog) => {
if (autocomplete.visible) return
if (!input.focused) return
@@ -229,7 +230,10 @@ export function Prompt(props: PromptProps) {
category: "Session",
keybind: "editor_open",
value: "prompt.editor",
onSelect: async (dialog, trigger) => {
slash: {
name: "editor",
},
onSelect: async (dialog) => {
dialog.clear()
// replace summarized text parts with the actual text
@@ -242,7 +246,7 @@ export function Prompt(props: PromptProps) {
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
const value = trigger === "prompt" ? "" : text
const value = text
const content = await Editor.open({ value, renderer })
if (!content) return
@@ -432,7 +436,7 @@ export function Prompt(props: PromptProps) {
title: "Stash prompt",
value: "prompt.stash",
category: "Prompt",
disabled: !store.prompt.input,
enabled: !!store.prompt.input,
onSelect: (dialog) => {
if (!store.prompt.input) return
stash.push({
@@ -450,7 +454,7 @@ export function Prompt(props: PromptProps) {
title: "Stash pop",
value: "prompt.stash.pop",
category: "Prompt",
disabled: stash.list().length === 0,
enabled: stash.list().length > 0,
onSelect: (dialog) => {
const entry = stash.pop()
if (entry) {
@@ -466,7 +470,7 @@ export function Prompt(props: PromptProps) {
title: "Stash list",
value: "prompt.stash.list",
category: "Prompt",
disabled: stash.list().length === 0,
enabled: stash.list().length > 0,
onSelect: (dialog) => {
dialog.replace(() => (
<DialogStash
@@ -1065,9 +1069,11 @@ export function Prompt(props: PromptProps) {
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<text fg={theme.text}>
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
</text>
<Show when={local.model.variant.list().length > 0}>
<text fg={theme.text}>
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
</text>
</Show>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
</text>

View File

@@ -113,8 +113,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const file = Bun.file(path.join(Global.Path.state, "model.json"))
const state = {
pending: false,
}
function save() {
if (!modelStore.ready) {
state.pending = true
return
}
state.pending = false
Bun.write(
file,
JSON.stringify({
@@ -135,6 +143,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
.catch(() => {})
.finally(() => {
setModelStore("ready", true)
if (state.pending) save()
})
const args = useArgs()

View File

@@ -16,6 +16,8 @@ export const TuiEvent = {
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",

View File

@@ -295,37 +295,39 @@ export function Session() {
const command = useCommandDialog()
command.register(() => [
...(sync.data.config.share !== "disabled"
? [
{
title: "Share session",
value: "session.share",
suggested: route.type === "session",
keybind: "session_share" as const,
disabled: !!session()?.share?.url,
category: "Session",
onSelect: async (dialog: any) => {
await sdk.client.session
.share({
sessionID: route.sessionID,
})
.then((res) =>
Clipboard.copy(res.data!.share!.url).catch(() =>
toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
),
)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
dialog.clear()
},
},
]
: []),
{
title: "Share session",
value: "session.share",
suggested: route.type === "session",
keybind: "session_share",
category: "Session",
enabled: sync.data.config.share !== "disabled" && !session()?.share?.url,
slash: {
name: "share",
},
onSelect: async (dialog) => {
await sdk.client.session
.share({
sessionID: route.sessionID,
})
.then((res) =>
Clipboard.copy(res.data!.share!.url).catch(() =>
toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
),
)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
dialog.clear()
},
},
{
title: "Rename session",
value: "session.rename",
keybind: "session_rename",
category: "Session",
slash: {
name: "rename",
},
onSelect: (dialog) => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
},
@@ -335,6 +337,9 @@ export function Session() {
value: "session.timeline",
keybind: "session_timeline",
category: "Session",
slash: {
name: "timeline",
},
onSelect: (dialog) => {
dialog.replace(() => (
<DialogTimeline
@@ -355,6 +360,9 @@ export function Session() {
value: "session.fork",
keybind: "session_fork",
category: "Session",
slash: {
name: "fork",
},
onSelect: (dialog) => {
dialog.replace(() => (
<DialogForkFromTimeline
@@ -374,6 +382,10 @@ export function Session() {
value: "session.compact",
keybind: "session_compact",
category: "Session",
slash: {
name: "compact",
aliases: ["summarize"],
},
onSelect: (dialog) => {
const selectedModel = local.model.current()
if (!selectedModel) {
@@ -396,8 +408,11 @@ export function Session() {
title: "Unshare session",
value: "session.unshare",
keybind: "session_unshare",
disabled: !session()?.share?.url,
category: "Session",
enabled: !!session()?.share?.url,
slash: {
name: "unshare",
},
onSelect: async (dialog) => {
await sdk.client.session
.unshare({
@@ -413,6 +428,9 @@ export function Session() {
value: "session.undo",
keybind: "messages_undo",
category: "Session",
slash: {
name: "undo",
},
onSelect: async (dialog) => {
const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
@@ -447,8 +465,11 @@ export function Session() {
title: "Redo",
value: "session.redo",
keybind: "messages_redo",
disabled: !session()?.revert?.messageID,
category: "Session",
enabled: !!session()?.revert?.messageID,
slash: {
name: "redo",
},
onSelect: (dialog) => {
dialog.clear()
const messageID = session()?.revert?.messageID
@@ -495,6 +516,10 @@ export function Session() {
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
value: "session.toggle.timestamps",
category: "Session",
slash: {
name: "timestamps",
aliases: ["toggle-timestamps"],
},
onSelect: (dialog) => {
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
dialog.clear()
@@ -504,6 +529,10 @@ export function Session() {
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
category: "Session",
slash: {
name: "thinking",
aliases: ["toggle-thinking"],
},
onSelect: (dialog) => {
setShowThinking((prev) => !prev)
dialog.clear()
@@ -513,6 +542,9 @@ export function Session() {
title: "Toggle diff wrapping",
value: "session.toggle.diffwrap",
category: "Session",
slash: {
name: "diffwrap",
},
onSelect: (dialog) => {
setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
dialog.clear()
@@ -552,7 +584,7 @@ export function Session() {
value: "session.page.up",
keybind: "messages_page_up",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
@@ -563,18 +595,40 @@ export function Session() {
value: "session.page.down",
keybind: "messages_page_down",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
},
{
title: "Line up",
value: "session.line.up",
keybind: "messages_line_up",
category: "Session",
disabled: true,
onSelect: (dialog) => {
scroll.scrollBy(-1)
dialog.clear()
},
},
{
title: "Line down",
value: "session.line.down",
keybind: "messages_line_down",
category: "Session",
disabled: true,
onSelect: (dialog) => {
scroll.scrollBy(1)
dialog.clear()
},
},
{
title: "Half page up",
value: "session.half.page.up",
keybind: "messages_half_page_up",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
@@ -585,7 +639,7 @@ export function Session() {
value: "session.half.page.down",
keybind: "messages_half_page_down",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollBy(scroll.height / 4)
dialog.clear()
@@ -596,7 +650,7 @@ export function Session() {
value: "session.first",
keybind: "messages_first",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollTo(0)
dialog.clear()
@@ -607,7 +661,7 @@ export function Session() {
value: "session.last",
keybind: "messages_last",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
@@ -618,6 +672,7 @@ export function Session() {
value: "session.messages_last_user",
keybind: "messages_last_user",
category: "Session",
hidden: true,
onSelect: () => {
const messages = sync.data.message[route.sessionID]
if (!messages || !messages.length) return
@@ -649,7 +704,7 @@ export function Session() {
value: "session.message.next",
keybind: "messages_next",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => scrollToMessage("next", dialog),
},
{
@@ -657,7 +712,7 @@ export function Session() {
value: "session.message.previous",
keybind: "messages_previous",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => scrollToMessage("prev", dialog),
},
{
@@ -706,8 +761,10 @@ export function Session() {
{
title: "Copy session transcript",
value: "session.copy",
keybind: "session_copy",
category: "Session",
slash: {
name: "copy",
},
onSelect: async (dialog) => {
try {
const sessionData = session()
@@ -735,6 +792,9 @@ export function Session() {
value: "session.export",
keybind: "session_export",
category: "Session",
slash: {
name: "export",
},
onSelect: async (dialog) => {
try {
const sessionData = session()
@@ -793,7 +853,7 @@ export function Session() {
value: "session.child.next",
keybind: "session_child_cycle",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
moveChild(1)
dialog.clear()
@@ -804,7 +864,7 @@ export function Session() {
value: "session.child.previous",
keybind: "session_child_cycle_reverse",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
moveChild(-1)
dialog.clear()
@@ -815,7 +875,7 @@ export function Session() {
value: "session.parent",
keybind: "session_parent",
category: "Session",
disabled: true,
hidden: true,
onSelect: (dialog) => {
const parentID = session()?.parentID
if (parentID) {

View File

@@ -38,7 +38,7 @@ export interface DialogSelectOption<T = any> {
disabled?: boolean
bg?: RGBA
gutter?: JSX.Element
onSelect?: (ctx: DialogContext, trigger?: "prompt") => void
onSelect?: (ctx: DialogContext) => void
}
export type DialogSelectRef<T> = {

View File

@@ -133,6 +133,8 @@ async function showRemovalSummary(targets: RemovalTargets, method: Installation.
bun: "bun remove -g opencode-ai",
yarn: "yarn global remove opencode-ai",
brew: "brew uninstall opencode",
choco: "choco uninstall opencode",
scoop: "scoop uninstall opencode",
}
prompts.log.info(` ✓ Package: ${cmds[method] || method}`)
}
@@ -182,16 +184,27 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
bun: ["bun", "remove", "-g", "opencode-ai"],
yarn: ["yarn", "global", "remove", "opencode-ai"],
brew: ["brew", "uninstall", "opencode"],
choco: ["choco", "uninstall", "opencode"],
scoop: ["scoop", "uninstall", "opencode"],
}
const cmd = cmds[method]
if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`)
const result = await $`${cmd}`.quiet().nothrow()
const result =
method === "choco"
? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow()
: await $`${cmd}`.quiet().nothrow()
if (result.exitCode !== 0) {
spinner.stop(`Package manager uninstall failed`, 1)
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
errors.push(`Package manager: exit code ${result.exitCode}`)
spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1)
if (
method === "choco" &&
result.stdout.toString("utf8").includes("not running from an elevated command shell")
) {
prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
} else {
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
}
} else {
spinner.stop("Package removed")
}

View File

@@ -13,6 +13,7 @@ Based on the input provided, determine which type of review to perform:
1. **No arguments (default)**: Review all uncommitted changes
- Run: `git diff` for unstaged changes
- Run: `git diff --cached` for staged changes
- Run: `git status --short` to identify untracked (net new) files
2. **Commit hash** (40-char SHA or short hash): Review that specific commit
- Run: `git show $ARGUMENTS`
@@ -33,6 +34,7 @@ Use best judgement when processing input.
**Diffs alone are not enough.** After getting the diff, read the entire file(s) being modified to understand the full context. Code that looks wrong in isolation may be correct given surrounding logic—and vice versa.
- Use the diff to identify which files changed
- Use `git status --short` to identify untracked files, then read their full contents
- Read the full file to understand existing patterns, control flow, and error handling
- Check for existing style guide or conventions files (CONVENTIONS.md, AGENTS.md, .editorconfig, etc.)

View File

@@ -651,8 +651,14 @@ export namespace Config {
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"),
messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.default("pagedown,ctrl+alt+f")
.describe("Scroll messages down by one page"),
messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
@@ -1115,6 +1121,7 @@ export namespace Config {
}
async function load(text: string, configFilepath: string) {
const original = text
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
@@ -1184,7 +1191,9 @@ export namespace Config {
if (parsed.success) {
if (!parsed.data.$schema) {
parsed.data.$schema = "https://opencode.ai/config.json"
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)).catch(() => {})
// Write the $schema to the original text to preserve variables like {env:VAR}
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Bun.write(configFilepath, updated).catch(() => {})
}
const data = parsed.data
if (data.plugin) {

View File

@@ -158,7 +158,7 @@ export namespace Installation {
cmd = $`echo Y | choco upgrade opencode --version=${target}`
break
case "scoop":
cmd = $`scoop install extras/opencode@${target}`
cmd = $`scoop install opencode@${target}`
break
default:
throw new Error(`Unknown method: ${method}`)
@@ -226,7 +226,7 @@ export namespace Installation {
}
if (detectedMethod === "scoop") {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Extras/master/bucket/opencode.json", {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
headers: { Accept: "application/json" },
})
.then((res) => {

View File

@@ -1157,10 +1157,24 @@ export namespace LSPServer {
await fs.mkdir(distPath, { recursive: true })
const releaseURL =
"https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz"
const archivePath = path.join(distPath, "release.tar.gz")
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
await $`tar -xzf ${archivePath}`.cwd(distPath).quiet().nothrow()
await fs.rm(archivePath, { force: true })
const archiveName = "release.tar.gz"
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow()
if (curlResult.exitCode !== 0) {
log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() })
return
}
log.info("Extracting JDTLS archive")
const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow()
if (tarResult.exitCode !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() })
return
}
await fs.rm(path.join(distPath, archiveName), { force: true })
log.info("JDTLS download and extraction completed")
}
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
.cwd(launcherDir)

View File

@@ -1,6 +1,7 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util/log"
export namespace Patch {
@@ -311,7 +312,7 @@ export namespace Patch {
// Read original file content
let originalContent: string
try {
originalContent = require("fs").readFileSync(filePath, "utf-8")
originalContent = readFileSync(filePath, "utf-8")
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`)
}

View File

@@ -60,7 +60,12 @@ export namespace ModelsDev {
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
provider: z.object({ npm: z.string() }).optional(),
provider: z
.object({
npm: z.string().optional(),
api: z.string().optional(),
})
.optional(),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
})
export type Model = z.infer<typeof Model>

View File

@@ -66,7 +66,7 @@ export namespace Provider {
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
type CustomModelLoader = (sdk: any, model: Model, options?: Record<string, any>) => Promise<any>
type CustomLoader = (provider: Info) => Promise<{
autoload: boolean
getModel?: CustomModelLoader
@@ -110,8 +110,8 @@ export namespace Provider {
openai: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
return sdk.responses(model.api.id)
},
options: {},
}
@@ -119,11 +119,11 @@ export namespace Provider {
"github-copilot": async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (modelID.includes("codex")) {
return sdk.responses(modelID)
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
if (model.api.id.includes("codex")) {
return sdk.responses(model.api.id)
}
return sdk.chat(modelID)
return sdk.chat(model.api.id)
},
options: {},
}
@@ -131,42 +131,44 @@ export namespace Provider {
"github-copilot-enterprise": async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (modelID.includes("codex")) {
return sdk.responses(modelID)
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
if (model.api.id.includes("codex")) {
return sdk.responses(model.api.id)
}
return sdk.chat(modelID)
return sdk.chat(model.api.id)
},
options: {},
}
},
// TODO: handle the openai and anthropic deployments
azure: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (options?.["useCompletionUrls"]) {
return sdk.chat(modelID)
} else {
return sdk.responses(modelID)
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
if (model && model.api.npm !== "@ai-sdk/azure") {
return sdk.languageModel(model.api.id)
}
if (options?.["useCompletionUrls"]) {
return sdk.chat(model.api.id)
}
return sdk.responses(model.api.id)
},
options: {},
}
},
"azure-cognitive-services": async () => {
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (options?.["useCompletionUrls"]) {
return sdk.chat(modelID)
} else {
return sdk.responses(modelID)
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
if (model && model.api.npm !== "@ai-sdk/azure") {
return sdk.languageModel(model.api.id)
}
if (options?.["useCompletionUrls"]) {
return sdk.chat(model.api.id)
}
return sdk.responses(model.api.id)
},
options: {
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
},
options: {},
}
},
"amazon-bedrock": async () => {
@@ -225,7 +227,8 @@ export namespace Provider {
return {
autoload: true,
options: providerOptions,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
let modelID = model.api.id
// Skip region prefixing if model already has a cross-region inference profile prefix
if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
return sdk.languageModel(modelID)
@@ -343,8 +346,8 @@ export namespace Provider {
project,
location,
},
async getModel(sdk: any, modelID: string) {
const id = String(modelID).trim()
async getModel(sdk: any, model: Model) {
const id = String(model.api.id).trim()
return sdk.languageModel(id)
},
}
@@ -360,8 +363,8 @@ export namespace Provider {
project,
location,
},
async getModel(sdk: any, modelID) {
const id = String(modelID).trim()
async getModel(sdk: any, model: Model) {
const id = String(model.api.id).trim()
return sdk.languageModel(id)
},
}
@@ -383,8 +386,8 @@ export namespace Provider {
return {
autoload: !!envServiceKey,
options: envServiceKey ? { deploymentId, resourceGroup } : {},
async getModel(sdk: any, modelID: string) {
return sdk(modelID)
async getModel(sdk: any, model: Model) {
return sdk(model.api.id)
},
}
},
@@ -423,8 +426,8 @@ export namespace Provider {
...(providerConfig?.options?.featureFlags || {}),
},
},
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
return sdk.agenticChat(modelID, {
async getModel(sdk: ReturnType<typeof createGitLab>, model: Model) {
return sdk.agenticChat(model.api.id, {
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
@@ -451,8 +454,8 @@ export namespace Provider {
return {
autoload: true,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.languageModel(modelID)
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
return sdk.languageModel(model.api.id)
},
options: {
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
@@ -594,7 +597,7 @@ export namespace Provider {
family: model.family,
api: {
id: model.id,
url: provider.api!,
url: model.provider?.api ?? provider.api!,
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
},
status: model.status ?? "active",
@@ -957,6 +960,21 @@ export namespace Provider {
return state().then((state) => state.providers)
}
export function resolveModelBaseURL(model: Model, options: Record<string, any>): string {
const template = model.api?.url ?? ""
if (!template) return ""
const matches = [...template.matchAll(/{{([^}]+)}}/g)]
if (matches.length === 0) return template
return matches.reduce((url, match) => {
const keys = match[1].split("|").map((item) => item.trim())
const resolved = keys
.map((key) => Env.get(key) ?? options[key])
.find((value) => value !== undefined && value !== null && value !== "")
if (resolved === undefined || resolved === null || resolved === "") return url
return url.replaceAll(match[0], String(resolved))
}, template)
}
async function getSDK(model: Model) {
try {
using _ = log.time("getSDK", {
@@ -970,7 +988,8 @@ export namespace Provider {
options["includeUsage"] = true
}
if (!options["baseURL"]) options["baseURL"] = model.api.url
const resolvedBaseURL = resolveModelBaseURL(model, options)
if (!options["baseURL"] && resolvedBaseURL) options["baseURL"] = resolvedBaseURL
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
if (model.headers)
options["headers"] = {
@@ -1093,9 +1112,8 @@ export namespace Provider {
const sdk = await getSDK(model)
try {
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
: sdk.languageModel(model.api.id)
const loader = s.modelLoaders[model.providerID]
const language = loader ? await loader(sdk, model, provider.options) : sdk.languageModel(model.api.id)
s.models.set(key, language)
return language
} catch (e) {

View File

@@ -325,9 +325,24 @@ export namespace ProviderTransform {
const id = model.id.toLowerCase()
if (id.includes("deepseek") || id.includes("minimax") || id.includes("glm") || id.includes("mistral")) return {}
// see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks
if (id.includes("grok") && id.includes("grok-3-mini")) {
if (model.api.npm === "@openrouter/ai-sdk-provider") {
return {
low: { reasoning: { effort: "low" } },
high: { reasoning: { effort: "high" } },
}
}
return {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}
}
if (id.includes("grok")) return {}
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("grok-4")) return {}
if (!model.id.includes("gpt") && !model.id.includes("gemini-3")) return {}
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
// TODO: YOU CANNOT SET max_tokens if this is set!!!

View File

@@ -10,7 +10,7 @@ export namespace Question {
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
label: z.string().max(30).describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({
@@ -21,7 +21,7 @@ export namespace Question {
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().max(12).describe("Very short label (max 12 chars)"),
header: z.string().max(30).describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),

View File

@@ -275,6 +275,8 @@ export const TuiRoutes = lazy(() =>
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",

View File

@@ -1702,6 +1702,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
: await lastModel(input.sessionID)
: taskModel
await Plugin.trigger(
"command.execute.before",
{
command: input.command,
sessionID: input.sessionID,
arguments: input.arguments,
},
{ parts },
)
const result = (await prompt({
sessionID: input.sessionID,
messageID: input.messageID,

View File

@@ -59,7 +59,9 @@ export const ReadTool = Tool.define("read", {
throw new Error(`File not found: ${filepath}`)
}
const isImage = file.type.startsWith("image/") && file.type !== "image/svg+xml"
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
const isImage =
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
const isPdf = file.type === "application/pdf"
if (isImage || isPdf) {
const mime = file.type

View File

@@ -0,0 +1,436 @@
import { describe, expect, test } from "bun:test"
import { ACP } from "../../src/acp/agent"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { Event } from "@opencode-ai/sdk/v2"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type RequestPermissionParams = Parameters<AgentSideConnection["requestPermission"]>[0]
type RequestPermissionResult = Awaited<ReturnType<AgentSideConnection["requestPermission"]>>
type GlobalEventEnvelope = {
directory?: string
payload?: Event
}
type EventController = {
push: (event: GlobalEventEnvelope) => void
close: () => void
}
function createEventStream() {
const queue: GlobalEventEnvelope[] = []
const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
const state = { closed: false }
const push = (event: GlobalEventEnvelope) => {
const waiter = waiters.shift()
if (waiter) {
waiter(event)
return
}
queue.push(event)
}
const close = () => {
state.closed = true
for (const waiter of waiters.splice(0)) {
waiter(undefined)
}
}
const stream = async function* (signal?: AbortSignal) {
while (true) {
if (signal?.aborted) return
const next = queue.shift()
if (next) {
yield next
continue
}
if (state.closed) return
const value = await new Promise<GlobalEventEnvelope | undefined>((resolve) => {
waiters.push(resolve)
if (!signal) return
signal.addEventListener("abort", () => resolve(undefined), { once: true })
})
if (!value) return
yield value
}
}
return { controller: { push, close } satisfies EventController, stream }
}
function createFakeAgent() {
const updates = new Map<string, string[]>()
const chunks = new Map<string, string>()
const record = (sessionId: string, type: string) => {
const list = updates.get(sessionId) ?? []
list.push(type)
updates.set(sessionId, list)
}
const connection = {
async sessionUpdate(params: SessionUpdateParams) {
const update = params.update
const type = update?.sessionUpdate ?? "unknown"
record(params.sessionId, type)
if (update?.sessionUpdate === "agent_message_chunk") {
const content = update.content
if (content?.type !== "text") return
if (typeof content.text !== "string") return
chunks.set(params.sessionId, (chunks.get(params.sessionId) ?? "") + content.text)
}
},
async requestPermission(_params: RequestPermissionParams): Promise<RequestPermissionResult> {
return { outcome: { outcome: "selected", optionId: "once" } } as RequestPermissionResult
},
} as unknown as AgentSideConnection
const { controller, stream } = createEventStream()
const calls = {
eventSubscribe: 0,
sessionCreate: 0,
}
const sdk = {
global: {
event: async (opts?: { signal?: AbortSignal }) => {
calls.eventSubscribe++
return { stream: stream(opts?.signal) }
},
},
session: {
create: async (_params?: any) => {
calls.sessionCreate++
return {
data: {
id: `ses_${calls.sessionCreate}`,
time: { created: new Date().toISOString() },
},
}
},
get: async (_params?: any) => {
return {
data: {
id: "ses_1",
time: { created: new Date().toISOString() },
},
}
},
messages: async () => {
return { data: [] }
},
message: async () => {
return {
data: {
info: {
role: "assistant",
},
},
}
},
},
permission: {
respond: async () => {
return { data: true }
},
},
config: {
providers: async () => {
return {
data: {
providers: [
{
id: "opencode",
name: "opencode",
models: {
"big-pickle": { id: "big-pickle", name: "big-pickle" },
},
},
],
},
}
},
},
app: {
agents: async () => {
return {
data: [
{
name: "build",
description: "build",
mode: "agent",
},
],
}
},
},
command: {
list: async () => {
return { data: [] }
},
},
mcp: {
add: async () => {
return { data: true }
},
},
} as any
const agent = new ACP.Agent(connection, {
sdk,
defaultModel: { providerID: "opencode", modelID: "big-pickle" },
} as any)
const stop = () => {
controller.close()
;(agent as any).eventAbort.abort()
}
return { agent, controller, calls, updates, chunks, stop, sdk, connection }
}
describe("acp.agent event subscription", () => {
test("routes message.part.updated by the event sessionID (no cross-session pollution)", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { agent, controller, updates, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
controller.push({
directory: cwd,
payload: {
type: "message.part.updated",
properties: {
part: {
sessionID: sessionB,
messageID: "msg_1",
type: "text",
synthetic: false,
},
delta: "hello",
},
},
} as any)
await new Promise((r) => setTimeout(r, 10))
expect((updates.get(sessionA) ?? []).includes("agent_message_chunk")).toBe(false)
expect((updates.get(sessionB) ?? []).includes("agent_message_chunk")).toBe(true)
stop()
},
})
})
test("keeps concurrent sessions isolated when message.part.updated events are interleaved", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { agent, controller, chunks, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const tokenA = ["ALPHA_", "111", "_X"]
const tokenB = ["BETA_", "222", "_Y"]
const push = (sessionId: string, messageID: string, delta: string) => {
controller.push({
directory: cwd,
payload: {
type: "message.part.updated",
properties: {
part: {
sessionID: sessionId,
messageID,
type: "text",
synthetic: false,
},
delta,
},
},
} as any)
}
push(sessionA, "msg_a", tokenA[0])
push(sessionB, "msg_b", tokenB[0])
push(sessionA, "msg_a", tokenA[1])
push(sessionB, "msg_b", tokenB[1])
push(sessionA, "msg_a", tokenA[2])
push(sessionB, "msg_b", tokenB[2])
await new Promise((r) => setTimeout(r, 20))
const a = chunks.get(sessionA) ?? ""
const b = chunks.get(sessionB) ?? ""
expect(a).toContain(tokenA.join(""))
expect(b).toContain(tokenB.join(""))
for (const part of tokenB) expect(a).not.toContain(part)
for (const part of tokenA) expect(b).not.toContain(part)
stop()
},
})
})
test("does not create additional event subscriptions on repeated loadSession()", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { agent, calls, stop } = createFakeAgent()
const cwd = "/tmp/opencode-acp-test"
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any)
expect(calls.eventSubscribe).toBe(1)
stop()
},
})
})
test("permission.asked events are handled and replied", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const permissionReplies: string[] = []
const { agent, controller, stop, sdk } = createFakeAgent()
sdk.permission.reply = async (params: any) => {
permissionReplies.push(params.requestID)
return { data: true }
}
const cwd = "/tmp/opencode-acp-test"
const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
controller.push({
directory: cwd,
payload: {
type: "permission.asked",
properties: {
id: "perm_1",
sessionID: sessionA,
permission: "bash",
patterns: ["*"],
metadata: {},
always: [],
},
},
} as any)
await new Promise((r) => setTimeout(r, 20))
expect(permissionReplies).toContain("perm_1")
stop()
},
})
})
test("permission prompt on session A does not block message updates for session B", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const permissionReplies: string[] = []
let resolvePermissionA: (() => void) | undefined
const permissionABlocking = new Promise<void>((r) => {
resolvePermissionA = r
})
const { agent, controller, chunks, stop, sdk, connection } = createFakeAgent()
// Make permission request for session A block until we release it
const originalRequestPermission = connection.requestPermission.bind(connection)
let permissionCalls = 0
connection.requestPermission = async (params: RequestPermissionParams) => {
permissionCalls++
if (params.sessionId.endsWith("1")) {
await permissionABlocking
}
return originalRequestPermission(params)
}
sdk.permission.reply = async (params: any) => {
permissionReplies.push(params.requestID)
return { data: true }
}
const cwd = "/tmp/opencode-acp-test"
const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
// Push permission.asked for session A (will block)
controller.push({
directory: cwd,
payload: {
type: "permission.asked",
properties: {
id: "perm_a",
sessionID: sessionA,
permission: "bash",
patterns: ["*"],
metadata: {},
always: [],
},
},
} as any)
// Give time for permission handling to start
await new Promise((r) => setTimeout(r, 10))
// Push message for session B while A's permission is pending
controller.push({
directory: cwd,
payload: {
type: "message.part.updated",
properties: {
part: {
sessionID: sessionB,
messageID: "msg_b",
type: "text",
synthetic: false,
},
delta: "session_b_message",
},
},
} as any)
// Wait for session B's message to be processed
await new Promise((r) => setTimeout(r, 20))
// Session B should have received message even though A's permission is still pending
expect(chunks.get(sessionB) ?? "").toContain("session_b_message")
expect(permissionReplies).not.toContain("perm_a")
// Release session A's permission
resolvePermissionA!()
await new Promise((r) => setTimeout(r, 20))
// Now session A's permission should be replied
expect(permissionReplies).toContain("perm_a")
stop()
},
})
})
})

View File

@@ -127,6 +127,44 @@ test("handles environment variable substitution", async () => {
}
})
test("preserves env variables when adding $schema to config", async () => {
const originalEnv = process.env["PRESERVE_VAR"]
process.env["PRESERVE_VAR"] = "secret_value"
try {
await using tmp = await tmpdir({
init: async (dir) => {
// Config without $schema - should trigger auto-add
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
theme: "{env:PRESERVE_VAR}",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("secret_value")
// Read the file to verify the env variable was preserved
const content = await Bun.file(path.join(tmp.path, "opencode.json")).text()
expect(content).toContain("{env:PRESERVE_VAR}")
expect(content).not.toContain("secret_value")
expect(content).toContain("$schema")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["PRESERVE_VAR"] = originalEnv
} else {
delete process.env["PRESERVE_VAR"]
}
}
})
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -381,6 +381,160 @@ test("parseModel handles model IDs with slashes", () => {
expect(result.modelID).toBe("anthropic/claude-3-opus")
})
test("resolveModelBaseURL returns empty when url missing", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = { api: { url: "" } } as Provider.Model
expect(Provider.resolveModelBaseURL(model, {})).toBe("")
},
})
})
test("resolveModelBaseURL returns template when no placeholders", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = { api: { url: "https://api.example.com/v1" } } as Provider.Model
expect(Provider.resolveModelBaseURL(model, {})).toBe("https://api.example.com/v1")
},
})
})
test("resolveModelBaseURL replaces placeholders from env", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("CUSTOM_HOST", "env.example.com")
},
fn: async () => {
const model = { api: { url: "https://{{CUSTOM_HOST}}/v1" } } as Provider.Model
expect(Provider.resolveModelBaseURL(model, {})).toBe("https://env.example.com/v1")
Env.remove("CUSTOM_HOST")
},
})
})
test("resolveModelBaseURL falls back to options when env missing", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = { api: { url: "https://{{HOST}}/v1" } } as Provider.Model
expect(Provider.resolveModelBaseURL(model, { HOST: "options.example.com" })).toBe(
"https://options.example.com/v1",
)
},
})
})
test("resolveModelBaseURL chooses first non-empty key", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = { api: { url: "https://{{PRIMARY|SECONDARY}}/v1" } } as Provider.Model
const options = {
PRIMARY: "",
SECONDARY: "fallback.example.com",
}
expect(Provider.resolveModelBaseURL(model, options)).toBe("https://fallback.example.com/v1")
},
})
})
test("resolveModelBaseURL keeps unresolved placeholders", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = { api: { url: "https://{{MISSING}}/v1" } } as Provider.Model
expect(Provider.resolveModelBaseURL(model, {})).toBe("https://{{MISSING}}/v1")
},
})
})
test("resolveModelBaseURL replaces multiple placeholders", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = { api: { url: "https://{{HOST}}/{{VERSION}}" } } as Provider.Model
const options = {
HOST: "multi.example.com",
VERSION: "v1",
}
expect(Provider.resolveModelBaseURL(model, options)).toBe("https://multi.example.com/v1")
},
})
})
test("defaultModel returns first available model when no config set", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -1140,7 +1140,7 @@ describe("ProviderTransform.variants", () => {
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
})
test("grok-4 returns OPENAI_EFFORTS with reasoning", () => {
test("grok-4 returns empty object", () => {
const model = createMockModel({
id: "openrouter/grok-4",
providerID: "openrouter",
@@ -1151,7 +1151,23 @@ describe("ProviderTransform.variants", () => {
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
expect(result).toEqual({})
})
test("grok-3-mini returns low and high with reasoning", () => {
const model = createMockModel({
id: "openrouter/grok-3-mini",
providerID: "openrouter",
api: {
id: "grok-3-mini",
url: "https://openrouter.ai",
npm: "@openrouter/ai-sdk-provider",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "high"])
expect(result.low).toEqual({ reasoning: { effort: "low" } })
expect(result.high).toEqual({ reasoning: { effort: "high" } })
})
})
@@ -1210,7 +1226,7 @@ describe("ProviderTransform.variants", () => {
})
describe("@ai-sdk/xai", () => {
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
test("grok-3 returns empty object", () => {
const model = createMockModel({
id: "xai/grok-3",
providerID: "xai",
@@ -1221,7 +1237,21 @@ describe("ProviderTransform.variants", () => {
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
expect(result).toEqual({})
})
test("grok-3-mini returns low and high with reasoningEffort", () => {
const model = createMockModel({
id: "xai/grok-3-mini",
providerID: "xai",
api: {
id: "grok-3-mini",
url: "https://api.x.ai",
npm: "@ai-sdk/xai",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "high"])
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})

View File

@@ -0,0 +1,105 @@
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
import { z } from "zod"
import { QuestionTool } from "../../src/tool/question"
import * as QuestionModule from "../../src/question"
const ctx = {
sessionID: "test-session",
messageID: "test-message",
callID: "test-call",
agent: "test-agent",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}
describe("tool.question", () => {
let askSpy: any
beforeEach(() => {
askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => {
return []
})
})
afterEach(() => {
askSpy.mockRestore()
})
test("should successfully execute with valid question parameters", async () => {
const tool = await QuestionTool.init()
const questions = [
{
question: "What is your favorite color?",
header: "Color",
options: [
{ label: "Red", description: "The color of passion" },
{ label: "Blue", description: "The color of sky" },
],
multiple: false,
},
]
askSpy.mockResolvedValueOnce([["Red"]])
const result = await tool.execute({ questions }, ctx)
expect(askSpy).toHaveBeenCalledTimes(1)
expect(result.title).toBe("Asked 1 question")
})
test("should now pass with a header longer than 12 but less than 30 chars", async () => {
const tool = await QuestionTool.init()
const questions = [
{
question: "What is your favorite animal?",
header: "This Header is Over 12",
options: [{ label: "Dog", description: "Man's best friend" }],
},
]
askSpy.mockResolvedValueOnce([["Dog"]])
const result = await tool.execute({ questions }, ctx)
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
})
test("should throw an Error for header exceeding 30 characters", async () => {
const tool = await QuestionTool.init()
const questions = [
{
question: "What is your favorite animal?",
header: "This Header is Definitely More Than Thirty Characters Long",
options: [{ label: "Dog", description: "Man's best friend" }],
},
]
try {
await tool.execute({ questions }, ctx)
// If it reaches here, the test should fail
expect(true).toBe(false)
} catch (e: any) {
expect(e).toBeInstanceOf(Error)
expect(e.cause).toBeInstanceOf(z.ZodError)
}
})
test("should throw an Error for label exceeding 30 characters", async () => {
const tool = await QuestionTool.init()
const questions = [
{
question: "A question with a very long label",
header: "Long Label",
options: [
{ label: "This is a very, very, very long label that will exceed the limit", description: "A description" },
],
},
]
try {
await tool.execute({ questions }, ctx)
// If it reaches here, the test should fail
expect(true).toBe(false)
} catch (e: any) {
expect(e).toBeInstanceOf(Error)
expect(e.cause).toBeInstanceOf(z.ZodError)
}
})
})

View File

@@ -300,4 +300,33 @@ describe("tool.read truncation", () => {
},
})
})
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// FlatBuffers schema content
const fbsContent = `namespace MyGame;
table Monster {
pos:Vec3;
name:string;
inventory:[ubyte];
}
root_type Monster;`
await Bun.write(path.join(dir, "schema.fbs"), fbsContent)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "schema.fbs") }, ctx)
// Should be read as text, not as image
expect(result.attachments).toBeUndefined()
expect(result.output).toContain("namespace MyGame")
expect(result.output).toContain("table Monster")
},
})
})
})

View File

@@ -173,6 +173,10 @@ export interface Hooks {
output: { temperature: number; topP: number; topK: number; options: Record<string, any> },
) => Promise<void>
"permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
"command.execute.before"?: (
input: { command: string; sessionID: string; arguments: string },
output: { parts: Part[] },
) => Promise<void>
"tool.execute.before"?: (
input: { tool: string; sessionID: string; callID: string },
output: { args: any },

View File

@@ -842,6 +842,14 @@ export type KeybindsConfig = {
* Scroll messages down by one page
*/
messages_page_down?: string
/**
* Scroll messages up by one line
*/
messages_line_up?: string
/**
* Scroll messages down by one line
*/
messages_line_down?: string
/**
* Scroll messages up by half page
*/

View File

@@ -545,7 +545,7 @@ export type QuestionInfo = {
*/
question: string
/**
* Very short label (max 12 chars)
* Very short label (max 30 chars)
*/
header: string
/**
@@ -633,6 +633,14 @@ export type EventTodoUpdated = {
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
@@ -651,6 +659,8 @@ export type EventTuiCommandExecute = {
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
@@ -789,14 +799,6 @@ export type EventSessionError = {
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type EventVcsBranchUpdated = {
type: "vcs.branch.updated"
properties: {
@@ -878,6 +880,7 @@ export type Event =
| EventQuestionRejected
| EventSessionCompacted
| EventTodoUpdated
| EventFileWatcherUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
@@ -890,7 +893,6 @@ export type Event =
| EventSessionDeleted
| EventSessionDiff
| EventSessionError
| EventFileWatcherUpdated
| EventVcsBranchUpdated
| EventPtyCreated
| EventPtyUpdated
@@ -1019,6 +1021,14 @@ export type KeybindsConfig = {
* Scroll messages down by one page
*/
messages_page_down?: string
/**
* Scroll messages up by one line
*/
messages_line_up?: string
/**
* Scroll messages down by one line
*/
messages_line_down?: string
/**
* Scroll messages up by half page
*/

View File

@@ -7163,7 +7163,8 @@
"properties": {
"label": {
"description": "Display text (1-5 words, concise)",
"type": "string"
"type": "string",
"maxLength": 30
},
"description": {
"description": "Explanation of choice",
@@ -7180,9 +7181,9 @@
"type": "string"
},
"header": {
"description": "Very short label (max 12 chars)",
"description": "Very short label (max 30 chars)",
"type": "string",
"maxLength": 12
"maxLength": 30
},
"options": {
"description": "Available choices",
@@ -7370,6 +7371,41 @@
},
"required": ["type", "properties"]
},
"Event.file.watcher.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "file.watcher.updated"
},
"properties": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"event": {
"anyOf": [
{
"type": "string",
"const": "add"
},
{
"type": "string",
"const": "change"
},
{
"type": "string",
"const": "unlink"
}
]
}
},
"required": ["file", "event"]
}
},
"required": ["type", "properties"]
},
"Event.tui.prompt.append": {
"type": "object",
"properties": {
@@ -7411,6 +7447,8 @@
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
@@ -7796,41 +7834,6 @@
},
"required": ["type", "properties"]
},
"Event.file.watcher.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "file.watcher.updated"
},
"properties": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"event": {
"anyOf": [
{
"type": "string",
"const": "add"
},
{
"type": "string",
"const": "change"
},
{
"type": "string",
"const": "unlink"
}
]
}
},
"required": ["file", "event"]
}
},
"required": ["type", "properties"]
},
"Event.vcs.branch.updated": {
"type": "object",
"properties": {
@@ -8052,6 +8055,9 @@
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
@@ -8088,9 +8094,6 @@
{
"$ref": "#/components/schemas/Event.session.error"
},
{
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
@@ -8282,12 +8285,22 @@
},
"messages_page_up": {
"description": "Scroll messages up by one page",
"default": "pageup",
"default": "pageup,ctrl+alt+b",
"type": "string"
},
"messages_page_down": {
"description": "Scroll messages down by one page",
"default": "pagedown",
"default": "pagedown,ctrl+alt+f",
"type": "string"
},
"messages_line_up": {
"description": "Scroll messages up by one line",
"default": "ctrl+alt+y",
"type": "string"
},
"messages_line_down": {
"description": "Scroll messages down by one line",
"default": "ctrl+alt+e",
"type": "string"
},
"messages_half_page_up": {

View File

@@ -13,6 +13,21 @@ export const Mark = (props: { class?: string }) => {
)
}
export const Splash = (props: { class?: string }) => {
return (
<svg
data-component="logo-splash"
classList={{ [props.class ?? ""]: !!props.class }}
viewBox="0 0 80 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M60 80H20V40H60V80Z" fill="var(--icon-base)" />
<path d="M60 20H20V80H60V20ZM80 100H0V0H80V100Z" fill="var(--icon-strong-base)" />
</svg>
)
}
export const Logo = (props: { class?: string }) => {
return (
<svg

View File

@@ -5,6 +5,8 @@ import type { ComponentProps } from "solid-js"
export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> {
value: JSX.Element
class?: string
contentClass?: string
contentStyle?: JSX.CSSProperties
inactive?: boolean
}
@@ -30,7 +32,7 @@ export function TooltipKeybind(props: TooltipKeybindProps) {
export function Tooltip(props: TooltipProps) {
const [open, setOpen] = createSignal(false)
const [local, others] = splitProps(props, ["children", "class", "inactive"])
const [local, others] = splitProps(props, ["children", "class", "contentClass", "contentStyle", "inactive"])
const c = children(() => local.children)
@@ -58,7 +60,12 @@ export function Tooltip(props: TooltipProps) {
{c()}
</KobalteTooltip.Trigger>
<KobalteTooltip.Portal>
<KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
<KobalteTooltip.Content
data-component="tooltip"
data-placement={props.placement}
class={local.contentClass}
style={local.contentStyle}
>
{others.value}
{/* <KobalteTooltip.Arrow data-slot="tooltip-arrow" /> */}
</KobalteTooltip.Content>

View File

@@ -180,8 +180,10 @@ jobs:
- uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: anthropic/claude-sonnet-4-20250514
use_github_token: true
prompt: |
Review this pull request:
- Check for code quality issues

View File

@@ -98,8 +98,7 @@ You can also install it with the following commands:
- **Using Scoop**
```bash
scoop bucket add extras
scoop install extras/opencode
scoop install opencode
```
- **Using NPM**

View File

@@ -31,8 +31,10 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"session_child_cycle": "<leader>right",
"session_child_cycle_reverse": "<leader>left",
"session_parent": "<leader>up",
"messages_page_up": "pageup",
"messages_page_down": "pagedown",
"messages_page_up": "pageup,ctrl+alt+b",
"messages_page_down": "pagedown,ctrl+alt+f",
"messages_line_up": "ctrl+alt+y",
"messages_line_down": "ctrl+alt+e",
"messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d",
"messages_first": "ctrl+g,home",