Compare commits

...
34 Commits
Author SHA1 Message Date
opencode 4f49967518 release: v1.1.3
Update Nix Hashes / update (push) Has been cancelled
2026-01-05 19:26:40 +00:00
Adam ec637aa21e fix(app): store image attachments 2026-01-05 13:21:33 -06:00
Adam 2ff9a757b6 fix(app): bottom padding 2026-01-05 13:21:32 -06:00
Adam cb8533ef5b fix(app): terminal long-running processes 2026-01-05 13:21:32 -06:00
Adam cdbb009ab0 fix(app): terminal flakiness 2026-01-05 13:21:31 -06:00
Adam 001b486356 fix(app): performance improvements through event batching 2026-01-05 13:21:30 -06:00
Adam d315026abc fix(app): prompt input shouldn't cover content 2026-01-05 13:21:30 -06:00
Adam d1191675c6 fix(app): worktree selection should navigate to worktree 2026-01-05 13:21:29 -06:00
GitHub Action 362a657b4f chore: generate 2026-01-05 19:10:42 +00:00
Dax Raad 0276885181 core: preserve permission config key order to maintain user-defined permission precedence 2026-01-05 14:09:37 -05:00
Guorui Yu 5a38a6f248 tui: autocomplete: expand directory on Tab, select on Enter (#6975)
Signed-off-by: yuguorui <yuguorui@pku.edu.cn>
2026-01-05 13:08:17 -06:00
GitHub Action aef01003e7 chore: generate 2026-01-05 19:07:34 +00:00
Melih MucukandMelih Mucuk a38e1701ee fix: pass image parts to custom commands (#6525)
Co-authored-by: Melih Mucuk <melih@monkeysteam.com>
2026-01-05 13:06:57 -06:00
ikeda-tomoya-swx bf9ee32d4a fix(provider): add support for jp. prefix in Amazon Bedrock cross-region inference profiles (#6916) 2026-01-05 13:03:09 -06:00
Josh Thomas 0917991361 docs: update GHA examples to use actions/checkout@v6 (#6969)
publish-github-action / publish (push) Has been cancelled
Update Nix Hashes / update (push) Has been cancelled
2026-01-05 13:00:22 -06:00
Ravi KumarandAiden Cline c6a241e331 ci: prevent duplicate PR check from flagging current PR as duplicate (#6924)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-05 12:59:42 -06:00
Rohan Mukherjee 4b7301e8ca fix: lucent-orng bg transparency for slash commands (#6938) 2026-01-05 12:56:49 -06:00
Daniel Vélez 1bf20f0a2b docs: add description for MCP command (#6944) 2026-01-05 12:56:00 -06:00
Grégoire Morpain e3b4d4ad49 feat(bedrock): config options and authentication precedence (#6377) 2026-01-05 12:51:43 -06:00
MogamiTsuchikawa 6b207b09d6 fix(app): avoid unintended submits during IME composition (#6952) 2026-01-05 12:38:38 -06:00
Albin Groen 9771325026 feat(app): highlight collapsed active project in sidebar (#6958) 2026-01-05 12:37:46 -06:00
Albin Groen bbd1c071c4 fix(app): fix flicker and navigation when collapsing/expanding projects (#6658) 2026-01-05 11:24:49 -06:00
Daniel PolitoandBrendan Allan 8e9a0c4ad0 Desktop: Install CLI (#6526)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-01-06 01:07:46 +08:00
Frank ced093e646 sync 2026-01-05 11:59:17 -05:00
Frank 283bdce358 sync 2026-01-05 11:13:59 -05:00
Dax Raad 91d5ce8bf3 tui: add system theme resolution and event handling 2026-01-05 10:38:35 -05:00
Frank 7f870cc9d4 wip: zen 2026-01-05 10:16:47 -05:00
David HillandDax 2cb3b0484b fix: cleaner interrupted experience (#6785)
Co-authored-by: Dax <mail@thdxr.com>
2026-01-05 09:53:19 -05:00
Frank 11b0df6b86 wip: zen 2026-01-05 06:16:04 -05:00
Frank e15af828fa zen: optimize query 2026-01-05 05:58:39 -05:00
Albin Groen 265cbaea7c fix(app): fix image dragging in project edit dialog (#6700) 2026-01-05 04:54:11 -06:00
GitHub Action d39ebbc947 chore: generate 2026-01-05 07:41:02 +00:00
Aiden Cline 06acd70670 tweak: transform 2026-01-05 01:40:15 -06:00
Aiden Cline c285304acf fix: for anthropic compat ensure empty msgs and empty reasoning is filtered out 2026-01-05 01:40:15 -06:00
72 changed files with 1786 additions and 671 deletions
+2
View File
@@ -44,6 +44,8 @@ jobs:
{ {
echo "Check for duplicate PRs related to this new PR:" echo "Check for duplicate PRs related to this new PR:"
echo "" echo ""
echo "CURRENT_PR_NUMBER: $PR_NUMBER"
echo ""
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)" echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
echo "" echo ""
echo "Description:" echo "Description:"
+1 -1
View File
@@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs: jobs:
stats: stats:
if: github.repository == 'sst/opencode' if: github.repository == 'anomalyco/opencode'
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: permissions:
contents: write contents: write
+2
View File
@@ -12,6 +12,8 @@ You are a duplicate PR detection agent. When a PR is opened, your job is to sear
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature. Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature.
IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself.
Search using keywords from the PR title and description. Try multiple searches with different relevant terms. Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
If you find potential duplicates: If you find potential duplicates:
+5
View File
@@ -45,6 +45,11 @@ export default tool({
} }
const prs = result.items as PR[] const prs = result.items as PR[]
if (prs.length === 0) {
return `No other PRs found matching "${args.query}"`
}
const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n") const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}` return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
+3
View File
@@ -189,3 +189,6 @@
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) | | 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) | | 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) | | 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) |
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
+15 -15
View File
@@ -22,7 +22,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -173,7 +173,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
@@ -201,7 +201,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -230,7 +230,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -246,7 +246,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.1.2", "version": "1.1.3",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -349,7 +349,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -369,7 +369,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.2", "version": "1.1.3",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.88.1", "@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -380,7 +380,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -393,7 +393,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -431,7 +431,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -442,7 +442,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -82,7 +82,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
id-token: write id-token: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 1 fetch-depth: 1
+143 -113
View File
@@ -16,16 +16,19 @@ Usage: install.sh [options]
Options: Options:
-h, --help Display this help message -h, --help Display this help message
-v, --version <version> Install a specific version (e.g., 1.0.180) -v, --version <version> Install a specific version (e.g., 1.0.180)
-b, --binary <path> Install from a local binary instead of downloading
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
Examples: Examples:
curl -fsSL https://opencode.ai/install | bash curl -fsSL https://opencode.ai/install | bash
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180 curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
./install --binary /path/to/opencode
EOF EOF
} }
requested_version=${VERSION:-} requested_version=${VERSION:-}
no_modify_path=false no_modify_path=false
binary_path=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do
exit 1 exit 1
fi fi
;; ;;
-b|--binary)
if [[ -n "${2:-}" ]]; then
binary_path="$2"
shift 2
else
echo -e "${RED}Error: --binary requires a path argument${NC}"
exit 1
fi
;;
--no-modify-path) --no-modify-path)
no_modify_path=true no_modify_path=true
shift shift
@@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
raw_os=$(uname -s)
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
case "$raw_os" in
Darwin*) os="darwin" ;;
Linux*) os="linux" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
esac
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
fi
if [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
if [ "$rosetta_flag" = "1" ]; then
arch="arm64"
fi
fi
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
exit 1
;;
esac
archive_ext=".zip"
if [ "$os" = "linux" ]; then
archive_ext=".tar.gz"
fi
is_musl=false
if [ "$os" = "linux" ]; then
if [ -f /etc/alpine-release ]; then
is_musl=true
fi
if command -v ldd >/dev/null 2>&1; then
if ldd --version 2>&1 | grep -qi musl; then
is_musl=true
fi
fi
fi
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
if [ "$os" = "darwin" ]; then
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
if [ "$avx2" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"
if [ "$needs_baseline" = "true" ]; then
target="$target-baseline"
fi
if [ "$is_musl" = "true" ]; then
target="$target-musl"
fi
filename="$APP-$target$archive_ext"
if [ "$os" = "linux" ]; then
if ! command -v tar >/dev/null 2>&1; then
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
exit 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
INSTALL_DIR=$HOME/.opencode/bin INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then # If --binary is provided, skip all download/detection logic
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename" if [ -n "$binary_path" ]; then
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') if [ ! -f "$binary_path" ]; then
echo -e "${RED}Error: Binary not found at ${binary_path}${NC}"
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
exit 1 exit 1
fi fi
specific_version="local"
else else
# Strip leading 'v' if present raw_os=$(uname -s)
requested_version="${requested_version#v}" os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename" case "$raw_os" in
specific_version=$requested_version Darwin*) os="darwin" ;;
Linux*) os="linux" ;;
# Verify the release exists before downloading MINGW*|MSYS*|CYGWIN*) os="windows" ;;
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}") esac
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}" arch=$(uname -m)
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}" if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
fi
if [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
if [ "$rosetta_flag" = "1" ]; then
arch="arm64"
fi
fi
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
exit 1 exit 1
;;
esac
archive_ext=".zip"
if [ "$os" = "linux" ]; then
archive_ext=".tar.gz"
fi
is_musl=false
if [ "$os" = "linux" ]; then
if [ -f /etc/alpine-release ]; then
is_musl=true
fi
if command -v ldd >/dev/null 2>&1; then
if ldd --version 2>&1 | grep -qi musl; then
is_musl=true
fi
fi
fi
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
if [ "$os" = "darwin" ]; then
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
if [ "$avx2" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"
if [ "$needs_baseline" = "true" ]; then
target="$target-baseline"
fi
if [ "$is_musl" = "true" ]; then
target="$target-musl"
fi
filename="$APP-$target$archive_ext"
if [ "$os" = "linux" ]; then
if ! command -v tar >/dev/null 2>&1; then
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
exit 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
if [ -z "$requested_version" ]; then
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
exit 1
fi
else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
# Verify the release exists before downloading
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
exit 1
fi
fi fi
fi fi
@@ -267,11 +288,11 @@ download_with_progress() {
{ {
local length=0 local length=0
local bytes=0 local bytes=0
while IFS=" " read -r -a line; do while IFS=" " read -r -a line; do
[ "${#line[@]}" -lt 2 ] && continue [ "${#line[@]}" -lt 2 ] && continue
local tag="${line[0]} ${line[1]}" local tag="${line[0]} ${line[1]}"
if [ "$tag" = "0000: content-length:" ]; then if [ "$tag" = "0000: content-length:" ]; then
length="${line[2]}" length="${line[2]}"
length=$(echo "$length" | tr -d '\r') length=$(echo "$length" | tr -d '\r')
@@ -296,7 +317,7 @@ download_and_install() {
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version" print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$" local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
mkdir -p "$tmp_dir" mkdir -p "$tmp_dir"
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails # Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
curl -# -L -o "$tmp_dir/$filename" "$url" curl -# -L -o "$tmp_dir/$filename" "$url"
@@ -307,14 +328,24 @@ download_and_install() {
else else
unzip -q "$tmp_dir/$filename" -d "$tmp_dir" unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
fi fi
mv "$tmp_dir/opencode" "$INSTALL_DIR" mv "$tmp_dir/opencode" "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode" chmod 755 "${INSTALL_DIR}/opencode"
rm -rf "$tmp_dir" rm -rf "$tmp_dir"
} }
check_version install_from_binary() {
download_and_install print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path"
cp "$binary_path" "${INSTALL_DIR}/opencode"
chmod 755 "${INSTALL_DIR}/opencode"
}
if [ -n "$binary_path" ]; then
install_from_binary
else
check_version
download_and_install
fi
add_to_path() { add_to_path() {
@@ -416,4 +447,3 @@ echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs" echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
echo -e "" echo -e ""
echo -e "" echo -e ""
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.2", "version": "1.1.3",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+47
View File
@@ -242,6 +242,53 @@ describe("SerializeAddon", () => {
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42") expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
}) })
test("serialized output should restore after Terminal.reset()", async () => {
const { term, addon } = createTerminal()
const content = [
"\x1b[1;32m\x1b[0m \x1b[34mcd\x1b[0m /some/path",
"\x1b[1;32m\x1b[0m \x1b[34mls\x1b[0m -la",
"total 42",
].join("\r\n")
await writeAndWait(term, content)
const serialized = addon.serialize()
const { term: term2 } = createTerminal()
terminals.push(term2)
term2.reset()
await writeAndWait(term2, serialized)
expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
})
test("alternate buffer should round-trip without garbage", async () => {
const { term, addon } = createTerminal(20, 5)
await writeAndWait(term, "normal\r\n")
await writeAndWait(term, "\x1b[?1049h\x1b[HALT")
expect(term.buffer.active.type).toBe("alternate")
const serialized = addon.serialize()
const { term: term2 } = createTerminal(20, 5)
terminals.push(term2)
await writeAndWait(term2, serialized)
expect(term2.buffer.active.type).toBe("alternate")
const line = term2.buffer.active.getLine(0)
expect(line?.translateToString(true)).toBe("ALT")
// Ensure a cell beyond content isn't garbage
const cellCode = line?.getCell(10)?.getCode()
expect(cellCode === 0 || cellCode === 32).toBe(true)
})
test("serialized output written to new terminal should match original colors", async () => { test("serialized output written to new terminal should match original colors", async () => {
const { term, addon } = createTerminal(40, 5) const { term, addon } = createTerminal(40, 5)
+19 -23
View File
@@ -157,23 +157,6 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
abstract class BaseSerializeHandler { abstract class BaseSerializeHandler {
constructor(protected readonly _buffer: IBuffer) {} constructor(protected readonly _buffer: IBuffer) {}
private _isRealContent(codepoint: number): boolean {
if (codepoint === 0) return false
if (codepoint >= 0xf000) return false
return true
}
private _findLastContentColumn(line: IBufferLine): number {
let lastContent = -1
for (let col = 0; col < line.length; col++) {
const cell = line.getCell(col)
if (cell && this._isRealContent(cell.getCode())) {
lastContent = col
}
}
return lastContent + 1
}
public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string { public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
let oldCell = this._buffer.getNullCell() let oldCell = this._buffer.getNullCell()
@@ -182,14 +165,14 @@ abstract class BaseSerializeHandler {
const startColumn = range.start.x const startColumn = range.start.x
const endColumn = range.end.x const endColumn = range.end.x
this._beforeSerialize(endRow - startRow, startRow, endRow) this._beforeSerialize(endRow - startRow + 1, startRow, endRow)
for (let row = startRow; row <= endRow; row++) { for (let row = startRow; row <= endRow; row++) {
const line = this._buffer.getLine(row) const line = this._buffer.getLine(row)
if (line) { if (line) {
const startLineColumn = row === range.start.y ? startColumn : 0 const startLineColumn = row === range.start.y ? startColumn : 0
const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line) const endLineColumn = Math.min(endColumn, line.length)
const endLineColumn = Math.min(maxColumn, line.length)
for (let col = startLineColumn; col < endLineColumn; col++) { for (let col = startLineColumn; col < endLineColumn; col++) {
const c = line.getCell(col) const c = line.getCell(col)
if (!c) { if (!c) {
@@ -243,6 +226,13 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _beforeSerialize(rows: number, start: number, _end: number): void { protected _beforeSerialize(rows: number, start: number, _end: number): void {
this._allRows = new Array<string>(rows) this._allRows = new Array<string>(rows)
this._allRowSeparators = new Array<string>(rows)
this._rowIndex = 0
this._currentRow = ""
this._nullCellCount = 0
this._cursorStyle = this._buffer.getNullCell()
this._lastContentCursorRow = start this._lastContentCursorRow = start
this._lastCursorRow = start this._lastCursorRow = start
this._firstRow = start this._firstRow = start
@@ -251,6 +241,11 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _rowEnd(row: number, isLastRow: boolean): void { protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = "" let rowSeparator = ""
if (this._nullCellCount > 0) {
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
if (!isLastRow) { if (!isLastRow) {
const nextLine = this._buffer.getLine(row + 1) const nextLine = this._buffer.getLine(row + 1)
@@ -388,7 +383,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
} }
const codepoint = cell.getCode() const codepoint = cell.getCode()
const isGarbage = codepoint >= 0xf000 const isInvalidCodepoint = codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)
const isGarbage = isInvalidCodepoint || (codepoint >= 0xf000 && cell.getWidth() === 1)
const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
const sgrSeq = this._diffStyle(cell, this._cursorStyle) const sgrSeq = this._diffStyle(cell, this._cursorStyle)
@@ -397,7 +393,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (styleChanged) { if (styleChanged) {
if (this._nullCellCount > 0) { if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C` this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0 this._nullCellCount = 0
} }
@@ -417,7 +413,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._nullCellCount += cell.getWidth() this._nullCellCount += cell.getWidth()
} else { } else {
if (this._nullCellCount > 0) { if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C` this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0 this._nullCellCount = 0
} }
+56 -29
View File
@@ -1,5 +1,17 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" import {
createEffect,
on,
Component,
Show,
For,
onMount,
onCleanup,
Switch,
Match,
createMemo,
createSignal,
} from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element" import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -153,6 +165,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
) )
const working = createMemo(() => status()?.type !== "idle") const working = createMemo(() => status()?.type !== "idle")
const imageAttachments = createMemo(
() => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
)
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
popover: "at" | "slash" | null popover: "at" | "slash" | null
@@ -160,7 +175,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
savedPrompt: Prompt | null savedPrompt: Prompt | null
placeholder: number placeholder: number
dragging: boolean dragging: boolean
imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell" mode: "normal" | "shell"
applyingHistory: boolean applyingHistory: boolean
}>({ }>({
@@ -169,7 +183,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
savedPrompt: null, savedPrompt: null,
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
dragging: false, dragging: false,
imageAttachments: [],
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
}) })
@@ -246,6 +259,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
const isFocused = createFocusSignal(() => editorRef) const isFocused = createFocusSignal(() => editorRef)
const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const addImageAttachment = async (file: File) => { const addImageAttachment = async (file: File) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
@@ -260,21 +275,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mime: file.type, mime: file.type,
dataUrl, dataUrl,
} }
setStore( const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
produce((draft) => { prompt.set([...prompt.current(), attachment], cursorPosition)
draft.imageAttachments.push(attachment)
}),
)
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
const removeImageAttachment = (id: string) => { const removeImageAttachment = (id: string) => {
setStore( const current = prompt.current()
produce((draft) => { const next = current.filter((part) => part.type !== "image" || part.id !== id)
draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) prompt.set(next, prompt.cursor())
}),
)
} }
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
@@ -302,6 +312,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const handleGlobalDragOver = (event: DragEvent) => { const handleGlobalDragOver = (event: DragEvent) => {
if (dialog.active) return
event.preventDefault() event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files") const hasFiles = event.dataTransfer?.types.includes("Files")
if (hasFiles) { if (hasFiles) {
@@ -310,6 +322,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const handleGlobalDragLeave = (event: DragEvent) => { const handleGlobalDragLeave = (event: DragEvent) => {
if (dialog.active) return
// relatedTarget is null when leaving the document window // relatedTarget is null when leaving the document window
if (!event.relatedTarget) { if (!event.relatedTarget) {
setStore("dragging", false) setStore("dragging", false)
@@ -317,6 +331,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const handleGlobalDrop = async (event: DragEvent) => { const handleGlobalDrop = async (event: DragEvent) => {
if (dialog.active) return
event.preventDefault() event.preventDefault()
setStore("dragging", false) setStore("dragging", false)
@@ -518,8 +534,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
on( on(
() => prompt.current(), () => prompt.current(),
(currentParts) => { (currentParts) => {
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
const domParts = parseFromDOM() const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
const selection = window.getSelection() const selection = window.getSelection()
let cursorPosition: number | null = null let cursorPosition: number | null = null
@@ -527,7 +544,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
cursorPosition = getCursorPosition(editorRef) cursorPosition = getCursorPosition(editorRef)
} }
renderEditor(currentParts) renderEditor(inputParts)
if (cursorPosition !== null) { if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition) setCursorPosition(editorRef, cursorPosition)
@@ -618,11 +635,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleInput = () => { const handleInput = () => {
const rawParts = parseFromDOM() const rawParts = parseFromDOM()
const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef) const cursorPosition = getCursorPosition(editorRef)
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const trimmed = rawText.replace(/\u200B/g, "").trim() const trimmed = rawText.replace(/\u200B/g, "").trim()
const hasNonText = rawParts.some((part) => part.type !== "text") const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = trimmed.length === 0 && !hasNonText const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
if (shouldReset) { if (shouldReset) {
setStore("popover", null) setStore("popover", null)
@@ -661,7 +679,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null) setStore("savedPrompt", null)
} }
prompt.set(rawParts, cursorPosition) prompt.set([...rawParts, ...images], cursorPosition)
queueScroll() queueScroll()
} }
@@ -764,16 +782,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.map((p) => ("content" in p ? p.content : "")) .map((p) => ("content" in p ? p.content : ""))
.join("") .join("")
.trim() .trim()
if (!text) return const hasImages = prompt.some((part) => part.type === "image")
if (!text && !hasImages) return
const entry = clonePromptParts(prompt) const entry = clonePromptParts(prompt)
const currentHistory = mode === "shell" ? shellHistory : history const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const lastEntry = currentHistory.entries[0] const lastEntry = currentHistory.entries[0]
if (lastEntry) { if (lastEntry && isPromptEqual(lastEntry, entry)) return
const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
if (lastText === text) return
}
setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
} }
@@ -862,6 +878,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
if (event.key === "Enter" && isImeComposing(event)) {
return
}
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
if (store.popover === "at") { if (store.popover === "at") {
atOnKeyDown(event) atOnKeyDown(event)
@@ -943,7 +963,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const currentPrompt = prompt.current() const currentPrompt = prompt.current()
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = store.imageAttachments.slice() const images = imageAttachments().slice()
const mode = store.mode const mode = store.mode
if (text.trim().length === 0 && images.length === 0) { if (text.trim().length === 0 && images.length === 0) {
@@ -1037,14 +1057,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const clearInput = () => { const clearInput = () => {
prompt.reset() prompt.reset()
setStore("imageAttachments", [])
setStore("mode", "normal") setStore("mode", "normal")
setStore("popover", null) setStore("popover", null)
} }
const restoreInput = () => { const restoreInput = () => {
prompt.set(currentPrompt, promptLength(currentPrompt)) prompt.set(currentPrompt, promptLength(currentPrompt))
setStore("imageAttachments", images)
setStore("mode", mode) setStore("mode", mode)
setStore("popover", null) setStore("popover", null)
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -1087,6 +1105,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
agent, agent,
model: `${model.providerID}/${model.modelID}`, model: `${model.providerID}/${model.modelID}`,
variant, variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
}) })
.catch((err) => { .catch((err) => {
showToast({ showToast({
@@ -1440,9 +1465,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</For> </For>
</div> </div>
</Show> </Show>
<Show when={store.imageAttachments.length > 0}> <Show when={imageAttachments().length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3"> <div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={store.imageAttachments}> <For each={imageAttachments()}>
{(attachment) => ( {(attachment) => (
<div class="relative group"> <div class="relative group">
<Show <Show
@@ -1483,6 +1508,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
contenteditable="true" contenteditable="true"
onInput={handleInput} onInput={handleInput}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
"select-text": true, "select-text": true,
@@ -1492,7 +1519,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"font-mono!": store.mode === "shell", "font-mono!": store.mode === "shell",
}} }}
/> />
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}> <Show when={!prompt.dirty()}>
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"> <div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{store.mode === "shell" {store.mode === "shell"
? "Enter shell command..." ? "Enter shell command..."
@@ -1625,7 +1652,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<IconButton <IconButton
type="submit" type="submit"
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()} disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="h-6 w-4.5" class="h-6 w-4.5"
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createResource, Show } from "solid-js" import { createMemo, createResource, Show } from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
@@ -20,6 +20,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator" import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator" import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { same } from "@/utils/same"
export function SessionHeader() { export function SessionHeader() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
@@ -36,6 +37,7 @@ export function SessionHeader() {
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
function navigateToProject(directory: string) { function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`) navigate(`/${base64Encode(directory)}`)
@@ -60,7 +62,7 @@ export function SessionHeader() {
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2"> <div class="hidden xl:flex items-center gap-2">
<Select <Select
options={layout.projects.list().map((project) => project.worktree)} options={worktrees()}
current={sync.project?.worktree ?? projectDirectory()} current={sync.project?.worktree ?? projectDirectory()}
label={(x) => getFilename(x)} label={(x) => getFilename(x)}
onSelect={(x) => (x ? navigateToProject(x) : undefined)} onSelect={(x) => (x ? navigateToProject(x) : undefined)}
@@ -23,11 +23,18 @@ export function NewSessionView(props: NewSessionViewProps) {
if (options().includes(selection)) return selection if (options().includes(selection)) return selection
return MAIN_WORKTREE return MAIN_WORKTREE
}) })
const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
const isWorktree = createMemo(() => {
const project = sync.project
if (!project) return false
return sync.data.path.directory !== project.worktree
})
const label = (value: string) => { const label = (value: string) => {
if (value === MAIN_WORKTREE) { if (value === MAIN_WORKTREE) {
if (isWorktree()) return "Main branch"
const branch = sync.data.vcs?.branch const branch = sync.data.vcs?.branch
if (branch) return `Current branch (${branch})` if (branch) return `Main branch (${branch})`
return "Main branch" return "Main branch"
} }
@@ -37,13 +44,16 @@ export function NewSessionView(props: NewSessionViewProps) {
} }
return ( return (
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"> <div
class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"
style={{ "padding-bottom": "calc(var(--prompt-height, 11.25rem) + 64px)" }}
>
<div class="text-20-medium text-text-weaker">New session</div> <div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3"> <div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" /> <Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak"> <div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)} {getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span> <span class="text-text-strong">{getFilename(projectRoot())}</span>
</div> </div>
</div> </div>
<div class="flex justify-center items-center gap-1"> <div class="flex justify-center items-center gap-1">
+59 -45
View File
@@ -36,12 +36,14 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme() const theme = useTheme()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket let ws: WebSocket | undefined
let term: Term let term: Term | undefined
let ghostty: Ghostty let ghostty: Ghostty
let serializeAddon: SerializeAddon let serializeAddon: SerializeAddon
let fitAddon: FitAddon let fitAddon: FitAddon
let handleResize: () => void let handleResize: () => void
let reconnect: number | undefined
let disposed = false
const getTerminalColors = (): TerminalColors => { const getTerminalColors = (): TerminalColors => {
const mode = theme.mode() const mode = theme.mode()
@@ -76,22 +78,24 @@ export const Terminal = (props: TerminalProps) => {
if (!term || !term.hasSelection()) return false if (!term || !term.hasSelection()) return false
const selection = term.getSelection() const selection = term.getSelection()
if (!selection) return false if (!selection) return false
if (document.body) {
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
document.body.removeChild(textarea)
if (copied) return true
}
const clipboard = navigator.clipboard const clipboard = navigator.clipboard
if (clipboard?.writeText) { if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {}) clipboard.writeText(selection).catch(() => {})
return true return true
} }
if (!document.body) return false return false
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
document.body.removeChild(textarea)
return copied
} }
const handlePointerDown = () => { const handlePointerDown = () => {
const activeElement = document.activeElement const activeElement = document.activeElement
@@ -104,8 +108,12 @@ export const Terminal = (props: TerminalProps) => {
onMount(async () => { onMount(async () => {
ghostty = await Ghostty.load() ghostty = await Ghostty.load()
ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) const socket = new WebSocket(
term = new Term({ sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
)
ws = socket
const t = new Term({
cursorBlink: true, cursorBlink: true,
fontSize: 14, fontSize: 14,
fontFamily: "IBM Plex Mono, monospace", fontFamily: "IBM Plex Mono, monospace",
@@ -114,7 +122,9 @@ export const Terminal = (props: TerminalProps) => {
scrollback: 10_000, scrollback: 10_000,
ghostty, ghostty,
}) })
term.attachCustomKeyEventHandler((event) => { term = t
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase() const key = event.key.toLowerCase()
if (key === "c") { if (key === "c") {
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
@@ -134,30 +144,31 @@ export const Terminal = (props: TerminalProps) => {
fitAddon = new FitAddon() fitAddon = new FitAddon()
serializeAddon = new SerializeAddon() serializeAddon = new SerializeAddon()
term.loadAddon(serializeAddon) t.loadAddon(serializeAddon)
term.loadAddon(fitAddon) t.loadAddon(fitAddon)
term.open(container) t.open(container)
container.addEventListener("pointerdown", handlePointerDown) container.addEventListener("pointerdown", handlePointerDown)
focusTerminal() focusTerminal()
if (local.pty.buffer) { if (local.pty.buffer) {
if (local.pty.rows && local.pty.cols) { if (local.pty.rows && local.pty.cols) {
term.resize(local.pty.cols, local.pty.rows) t.resize(local.pty.cols, local.pty.rows)
} }
term.reset() t.reset()
term.write(local.pty.buffer) t.write(local.pty.buffer, () => {
if (local.pty.scrollY) { if (local.pty.scrollY) {
term.scrollToLine(local.pty.scrollY) t.scrollToLine(local.pty.scrollY)
} }
fitAddon.fit() fitAddon.fit()
})
} }
fitAddon.observeResize() fitAddon.observeResize()
handleResize = () => fitAddon.fit() handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
term.onResize(async (size) => { t.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty await sdk.client.pty
.update({ .update({
ptyID: local.pty.id, ptyID: local.pty.id,
@@ -169,39 +180,39 @@ export const Terminal = (props: TerminalProps) => {
.catch(() => {}) .catch(() => {})
} }
}) })
term.onData((data) => { t.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
ws.send(data) socket.send(data)
} }
}) })
term.onKey((key) => { t.onKey((key) => {
if (key.key == "Enter") { if (key.key == "Enter") {
props.onSubmit?.() props.onSubmit?.()
} }
}) })
// term.onScroll((ydisp) => { // t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp) // console.log("Scroll position:", ydisp)
// }) // })
ws.addEventListener("open", () => { socket.addEventListener("open", () => {
console.log("WebSocket connected") console.log("WebSocket connected")
sdk.client.pty sdk.client.pty
.update({ .update({
ptyID: local.pty.id, ptyID: local.pty.id,
size: { size: {
cols: term.cols, cols: t.cols,
rows: term.rows, rows: t.rows,
}, },
}) })
.catch(() => {}) .catch(() => {})
}) })
ws.addEventListener("message", (event) => { socket.addEventListener("message", (event) => {
term.write(event.data) t.write(event.data)
}) })
ws.addEventListener("error", (error) => { socket.addEventListener("error", (error) => {
console.error("WebSocket error:", error) console.error("WebSocket error:", error)
props.onConnectError?.(error) props.onConnectError?.(error)
}) })
ws.addEventListener("close", () => { socket.addEventListener("close", () => {
console.log("WebSocket disconnected") console.log("WebSocket disconnected")
}) })
}) })
@@ -211,18 +222,21 @@ export const Terminal = (props: TerminalProps) => {
window.removeEventListener("resize", handleResize) window.removeEventListener("resize", handleResize)
} }
container.removeEventListener("pointerdown", handlePointerDown) container.removeEventListener("pointerdown", handlePointerDown)
if (serializeAddon && props.onCleanup) {
const t = term
if (serializeAddon && props.onCleanup && t) {
const buffer = serializeAddon.serialize() const buffer = serializeAddon.serialize()
props.onCleanup({ props.onCleanup({
...local.pty, ...local.pty,
buffer, buffer,
rows: term.rows, rows: t.rows,
cols: term.cols, cols: t.cols,
scrollY: term.getViewportY(), scrollY: t.getViewportY(),
}) })
} }
ws?.close() ws?.close()
term?.dispose() t?.dispose()
}) })
return ( return (
+70 -5
View File
@@ -1,7 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js" import { batch, onCleanup } from "solid-js"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { useServer } from "./server" import { useServer } from "./server"
@@ -19,14 +19,79 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
[key: string]: Event [key: string]: Event
}>() }>()
type Queued = { directory: string; payload: Event }
let queue: Array<Queued | undefined> = []
const coalesced = new Map<string, number>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
const events = queue
queue = []
coalesced.clear()
if (events.length === 0) return
last = Date.now()
batch(() => {
for (const event of events) {
if (!event) continue
emitter.emit(event.directory, event.payload)
}
})
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
}
const stop = () => {
flush()
}
void (async () => { void (async () => {
const events = await eventSdk.global.event() const events = await eventSdk.global.event()
let yielded = Date.now()
for await (const event of events.stream) { for await (const event of events.stream) {
emitter.emit(event.directory ?? "global", event.payload) const directory = event.directory ?? "global"
} const payload = event.payload
})().catch(() => undefined) const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = undefined
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
onCleanup(() => abort.abort()) if (Date.now() - yielded < 8) continue
yielded = Date.now()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
})()
.finally(stop)
.catch(() => undefined)
onCleanup(() => {
abort.abort()
stop()
})
const platform = usePlatform() const platform = usePlatform()
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
+3 -2
View File
@@ -23,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error" import { ErrorPage, type InitError } from "../pages/error"
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js" import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
@@ -212,7 +212,7 @@ function createGlobalSync() {
.catch((e) => setGlobalStore("error", e)) .catch((e) => setGlobalStore("error", e))
} }
globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const event = e.details const event = e.details
@@ -404,6 +404,7 @@ function createGlobalSync() {
} }
} }
}) })
onCleanup(unsub)
async function bootstrap() { async function bootstrap() {
const health = await globalSDK.client.global const health = await globalSDK.client.global
+42 -14
View File
@@ -1,11 +1,12 @@
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { batch, createMemo, onMount } from "solid-js" import { batch, createEffect, createMemo, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server" import { useServer } from "./server"
import { Project } from "@opencode-ai/sdk/v2" import { Project } from "@opencode-ai/sdk/v2"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { same } from "@/utils/same"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -23,13 +24,6 @@ export function getAvatarColors(key?: string) {
} }
} }
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type SessionTabs = { type SessionTabs = {
active?: string active?: string
all: string[] all: string[]
@@ -97,8 +91,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
: globalSync.data.project.find((x) => x.worktree === project.worktree) : globalSync.data.project.find((x) => x.worktree === project.worktree)
return [ return [
{ {
...project,
...(metadata ?? {}), ...(metadata ?? {}),
...project,
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color }, icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
}, },
] ]
@@ -115,6 +109,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return project return project
} }
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of globalSync.data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
}
}
return map
})
createEffect(() => {
const map = roots()
if (map.size === 0) return
const projects = server.projects.list()
const seen = new Set(projects.map((project) => project.worktree))
batch(() => {
for (const project of projects) {
const root = map.get(project.worktree)
if (!root) continue
server.projects.close(project.worktree)
if (!seen.has(root)) {
server.projects.open(root)
seen.add(root)
}
if (project.expanded) server.projects.expand(root)
}
})
})
const enriched = createMemo(() => server.projects.list().flatMap(enrich)) const enriched = createMemo(() => server.projects.list().flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize)) const list = createMemo(() => enriched().flatMap(colorize))
@@ -131,11 +160,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: { projects: {
list, list,
open(directory: string) { open(directory: string) {
if (server.projects.list().find((x) => x.worktree === directory)) { const root = roots().get(directory) ?? directory
return if (server.projects.list().find((x) => x.worktree === root)) return
} globalSync.project.loadSessions(root)
globalSync.project.loadSessions(directory) server.projects.open(root)
server.projects.open(directory)
}, },
close(directory: string) { close(directory: string) {
server.projects.close(directory) server.projects.close(directory)
+3 -2
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createMemo } from "solid-js" import { batch, createMemo, onCleanup } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -465,7 +465,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const searchFilesAndDirectories = (query: string) => const searchFilesAndDirectories = (query: string) =>
sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!) sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
sdk.event.listen((e) => { const unsub = sdk.event.listen((e) => {
const event = e.details const event = e.details
switch (event.type) { switch (event.type) {
case "file.watcher.updated": case "file.watcher.updated":
@@ -475,6 +475,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
break break
} }
}) })
onCleanup(unsub)
return { return {
node: async (path: string) => { node: async (path: string) => {
+3 -1
View File
@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
@@ -54,7 +55,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
}), }),
) )
globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const event = e.details const event = e.details
const base = { const base = {
@@ -104,6 +105,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
} }
} }
}) })
onCleanup(unsub)
return { return {
ready, ready,
+3 -1
View File
@@ -1,6 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
@@ -20,9 +21,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
[key in Event["type"]]: Extract<Event, { type: key }> [key in Event["type"]]: Extract<Event, { type: key }>
}>() }>()
globalSDK.event.on(props.directory, async (event) => { const unsub = globalSDK.event.on(props.directory, (event) => {
emitter.emit(event.type, event) emitter.emit(event.type, event)
}) })
onCleanup(unsub)
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url } return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
}, },
+13 -6
View File
@@ -644,13 +644,13 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id)) const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const [sessionStore] = globalSync.child(props.session.directory)
const hasPermissions = createMemo(() => { const hasPermissions = createMemo(() => {
const store = globalSync.child(props.project.worktree)[0] const permissions = sessionStore.permission?.[props.session.id] ?? []
const permissions = store.permission?.[props.session.id] ?? []
if (permissions.length > 0) return true if (permissions.length > 0) return true
const childSessions = store.session.filter((s) => s.parentID === props.session.id) const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id)
for (const child of childSessions) { for (const child of childSessions) {
const childPermissions = store.permission?.[child.id] ?? [] const childPermissions = sessionStore.permission?.[child.id] ?? []
if (childPermissions.length > 0) return true if (childPermissions.length > 0) return true
} }
return false return false
@@ -658,7 +658,7 @@ export default function Layout(props: ParentProps) {
const isWorking = createMemo(() => { const isWorking = createMemo(() => {
if (props.session.id === params.id) return false if (props.session.id === params.id) return false
if (hasPermissions()) return false if (hasPermissions()) return false
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id] const status = sessionStore.session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry" return status?.type === "busy" || status?.type === "retry"
}) })
return ( return (
@@ -759,6 +759,10 @@ export default function Layout(props: ParentProps) {
const isExpanded = createMemo(() => const isExpanded = createMemo(() =>
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded, props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
) )
const isActive = createMemo(() => {
const current = params.dir ? base64Decode(params.dir) : ""
return props.project.worktree === current || props.project.sandboxes?.includes(current)
})
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (props.mobile) { if (props.mobile) {
if (open) mobileProjects.expand(props.project.worktree) if (open) mobileProjects.expand(props.project.worktree)
@@ -777,7 +781,10 @@ export default function Layout(props: ParentProps) {
<Button <Button
as={"div"} as={"div"}
variant="ghost" variant="ghost"
class="group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg" classList={{
"group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true,
"bg-surface-raised-base-hover": isActive() && !isExpanded(),
}}
> >
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none"> <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
<ProjectAvatar <ProjectAvatar
+63 -17
View File
@@ -1,5 +1,6 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, batch } from "solid-js" import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file" import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file"
@@ -24,7 +25,7 @@ import { useSync } from "@/context/sync"
import { useTerminal, type LocalPTY } from "@/context/terminal" import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal" import { Terminal } from "@/components/terminal"
import { checksum, base64Decode } from "@opencode-ai/util/encode" import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModel } from "@/components/dialog-select-model"
@@ -48,12 +49,7 @@ import {
NewSessionView, NewSessionView,
} from "@/components/session" } from "@/components/session"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { same } from "@/utils/same"
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type DiffStyle = "unified" | "split" type DiffStyle = "unified" | "split"
@@ -253,6 +249,14 @@ export default function Page() {
messageId: undefined as string | undefined, messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "review", mobileTab: "session" as "session" | "review",
newSessionWorktree: "main", newSessionWorktree: "main",
promptHeight: 0,
})
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
const project = sync.project
if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
return "main"
}) })
const activeMessage = createMemo(() => { const activeMessage = createMemo(() => {
@@ -287,6 +291,8 @@ export default function Page() {
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let scroller: HTMLDivElement | undefined
createEffect(() => { createEffect(() => {
if (!params.id) return if (!params.id) return
@@ -666,9 +672,30 @@ export default function Page() {
const anchor = (id: string) => `message-${id}` const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => { const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el) autoScroll.scrollRef(el)
} }
createResizeObserver(
() => promptDock,
({ height }) => {
const next = Math.ceil(height)
if (next === store.promptHeight) return
const el = scroller
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
setStore("promptHeight", next)
if (stick && el) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
})
}
},
)
const updateHash = (id: string) => { const updateHash = (id: string) => {
window.history.replaceState(null, "", `#${anchor(id)}`) window.history.replaceState(null, "", `#${anchor(id)}`)
} }
@@ -774,7 +801,10 @@ export default function Page() {
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true, "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 md:flex-none py-6 md:py-3": true, "flex-1 md:flex-none py-6 md:py-3": true,
}} }}
style={{ width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%" }} style={{
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
}}
> >
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden">
<Switch> <Switch>
@@ -789,7 +819,7 @@ export default function Page() {
view={view} view={view}
diffStyle="unified" diffStyle="unified"
classes={{ classes={{
root: "pb-32", root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4", header: "px-4",
container: "px-4", container: "px-4",
}} }}
@@ -820,7 +850,7 @@ export default function Page() {
> >
<div <div
ref={autoScroll.contentRef} ref={autoScroll.contentRef}
class="flex flex-col gap-32 items-start justify-start pb-32 md:pb-40 transition-[margin]" class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
classList={{ classList={{
"mt-0.5": !showTabs(), "mt-0.5": !showTabs(),
"mt-0": showTabs(), "mt-0": showTabs(),
@@ -833,9 +863,9 @@ export default function Page() {
data-message-id={message.id} data-message-id={message.id}
classList={{ classList={{
"min-w-0 w-full max-w-full": true, "min-w-0 w-full max-w-full": true,
"last:min-h-[calc(100vh-13.5rem)] md:last:min-h-[calc(100vh-14.5rem)]": "last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
platform.platform !== "desktop", platform.platform !== "desktop",
"last:min-h-[calc(100vh-15rem)] md:last:min-h-[calc(100vh-16rem)]": "last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
platform.platform === "desktop", platform.platform === "desktop",
}} }}
> >
@@ -871,15 +901,31 @@ export default function Page() {
</Match> </Match>
<Match when={true}> <Match when={true}>
<NewSessionView <NewSessionView
worktree={store.newSessionWorktree} worktree={newSessionWorktree()}
onWorktreeChange={(value) => setStore("newSessionWorktree", value)} onWorktreeChange={(value) => {
if (value === "create") {
setStore("newSessionWorktree", value)
return
}
setStore("newSessionWorktree", "main")
const target = value === "main" ? sync.project?.worktree : value
if (!target) return
if (target === sync.data.path.directory) return
layout.projects.open(target)
navigate(`/${base64Encode(target)}/session`)
}}
/> />
</Match> </Match>
</Switch> </Switch>
</div> </div>
{/* Prompt input */} {/* Prompt input */}
<div 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"> <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"
>
<div <div
classList={{ classList={{
"w-full md:px-6 pointer-events-auto": true, "w-full md:px-6 pointer-events-auto": true,
@@ -890,7 +936,7 @@ export default function Page() {
ref={(el) => { ref={(el) => {
inputRef = el inputRef = el
}} }}
newSessionWorktree={store.newSessionWorktree} newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
/> />
</div> </div>
+166 -28
View File
@@ -1,47 +1,185 @@
import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2" import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { Prompt, FileAttachmentPart } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
type Inline =
| {
type: "file"
start: number
end: number
value: string
path: string
selection?: {
startLine: number
endLine: number
startChar: number
endChar: number
}
}
| {
type: "agent"
start: number
end: number
value: string
name: string
}
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
const queryIndex = url.indexOf("?")
if (queryIndex === -1) return undefined
const params = new URLSearchParams(url.slice(queryIndex + 1))
const startLine = Number(params.get("start"))
const endLine = Number(params.get("end"))
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
return {
startLine,
endLine,
startChar: 0,
endChar: 0,
}
}
function textPartValue(parts: Part[]) {
const candidates = parts
.filter((part): part is TextPart => part.type === "text")
.filter((part) => !part.synthetic && !part.ignored)
return candidates.reduce((best: TextPart | undefined, part) => {
if (!best) return part
if (part.text.length > best.text.length) return part
return best
}, undefined)
}
/** /**
* Extract prompt content from message parts for restoring into the prompt input. * Extract prompt content from message parts for restoring into the prompt input.
* This is used by undo to restore the original user prompt. * This is used by undo to restore the original user prompt.
*/ */
export function extractPromptFromParts(parts: Part[]): Prompt { export function extractPromptFromParts(parts: Part[]): Prompt {
const result: Prompt = [] const textPart = textPartValue(parts)
let position = 0 const text = textPart?.text ?? ""
const inline: Inline[] = []
const images: ImageAttachmentPart[] = []
for (const part of parts) { for (const part of parts) {
if (part.type === "text") { if (part.type === "file") {
const textPart = part as TextPart
if (!textPart.synthetic && textPart.text) {
result.push({
type: "text",
content: textPart.text,
start: position,
end: position + textPart.text.length,
})
position += textPart.text.length
}
} else if (part.type === "file") {
const filePart = part as FilePart const filePart = part as FilePart
if (filePart.source?.type === "file") { const sourceText = filePart.source?.text
const path = filePart.source.path if (sourceText) {
const content = "@" + path const value = sourceText.value
const attachment: FileAttachmentPart = { const start = sourceText.start
type: "file", const end = sourceText.end
path, let path = value
content, if (value.startsWith("@")) path = value.slice(1)
start: position, if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
end: position + content.length, path = filePart.source.path
} }
result.push(attachment) inline.push({
position += content.length type: "file",
start,
end,
value,
path,
selection: selectionFromFileUrl(filePart.url),
})
continue
}
if (filePart.url.startsWith("data:")) {
images.push({
type: "image",
id: filePart.id,
filename: filePart.filename ?? "attachment",
mime: filePart.mime,
dataUrl: filePart.url,
})
} }
} }
if (part.type === "agent") {
const agentPart = part as MessageAgentPart
const source = agentPart.source
if (!source) continue
inline.push({
type: "agent",
start: source.start,
end: source.end,
value: source.value,
name: agentPart.name,
})
}
} }
inline.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start
return a.end - b.end
})
const result: Prompt = []
let position = 0
let cursor = 0
const pushText = (content: string) => {
if (!content) return
result.push({
type: "text",
content,
start: position,
end: position + content.length,
})
position += content.length
}
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
const content = item.value
const attachment: FileAttachmentPart = {
type: "file",
path: item.path,
content,
start: position,
end: position + content.length,
selection: item.selection,
}
result.push(attachment)
position += content.length
}
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
const content = item.value
const mention: AgentPart = {
type: "agent",
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(mention)
position += content.length
}
for (const item of inline) {
if (item.start < 0 || item.end < item.start) continue
if (item.end > text.length) continue
if (item.start < cursor) continue
pushText(text.slice(cursor, item.start))
if (item.type === "file") {
pushFile(item)
}
if (item.type === "agent") {
pushAgent(item)
}
cursor = item.end
}
pushText(text.slice(cursor))
if (result.length === 0) { if (result.length === 0) {
result.push({ type: "text", content: "", start: 0, end: 0 }) result.push({ type: "text", content: "", start: 0, end: 0 })
} }
return result if (images.length === 0) return result
return [...result, ...images]
} }
+6
View File
@@ -0,0 +1,6 @@
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.2", "version": "1.1.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -172,8 +172,8 @@ export async function handler(
const tokensInfo = providerInfo.normalizeUsage(json.usage) const tokensInfo = providerInfo.normalizeUsage(json.usage)
await trialLimiter?.track(tokensInfo) await trialLimiter?.track(tokensInfo)
await rateLimiter?.track() await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo) const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo) await reload(authInfo, costInfo)
return new Response(body, { return new Response(body, {
status: resStatus, status: resStatus,
statusText: res.statusText, statusText: res.statusText,
@@ -206,8 +206,8 @@ export async function handler(
if (usage) { if (usage) {
const tokensInfo = providerInfo.normalizeUsage(usage) const tokensInfo = providerInfo.normalizeUsage(usage)
await trialLimiter?.track(tokensInfo) await trialLimiter?.track(tokensInfo)
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo) const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo) await reload(authInfo, costInfo)
} }
c.close() c.close()
return return
@@ -392,6 +392,7 @@ export async function handler(
monthlyUsage: BillingTable.monthlyUsage, monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
reloadTrigger: BillingTable.reloadTrigger, reloadTrigger: BillingTable.reloadTrigger,
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
}, },
user: { user: {
id: UserTable.id, id: UserTable.id,
@@ -560,61 +561,68 @@ export async function handler(
if (!authInfo) return if (!authInfo) return
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent) const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => { await Database.use((db) =>
await tx.insert(UsageTable).values({ Promise.all([
workspaceID: authInfo.workspaceID, db.insert(UsageTable).values({
id: Identifier.create("usage"), workspaceID: authInfo.workspaceID,
model: modelInfo.id, id: Identifier.create("usage"),
provider: providerInfo.id, model: modelInfo.id,
inputTokens, provider: providerInfo.id,
outputTokens, inputTokens,
reasoningTokens, outputTokens,
cacheReadTokens, reasoningTokens,
cacheWrite5mTokens, cacheReadTokens,
cacheWrite1hTokens, cacheWrite5mTokens,
cost, cacheWrite1hTokens,
keyID: authInfo.apiKeyId, cost,
}) keyID: authInfo.apiKeyId,
await tx }),
.update(BillingTable) db
.set({ .update(BillingTable)
balance: sql`${BillingTable.balance} - ${cost}`, .set({
monthlyUsage: sql` balance: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost} WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost} ELSE ${cost}
END END
`, `,
timeMonthlyUsageUpdated: sql`now()`, timeMonthlyUsageUpdated: sql`now()`,
}) })
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)) .where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
await tx db
.update(UserTable) .update(UserTable)
.set({ .set({
monthlyUsage: sql` monthlyUsage: sql`
CASE CASE
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost} WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
ELSE ${cost} ELSE ${cost}
END END
`, `,
timeMonthlyUsageUpdated: sql`now()`, timeMonthlyUsageUpdated: sql`now()`,
}) })
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))) .where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
}) db
.update(KeyTable)
await Database.use((tx) => .set({ timeUsed: sql`now()` })
tx .where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
.update(KeyTable) ]),
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
) )
return { costInMicroCents: cost }
} }
async function reload(authInfo: AuthInfo) { async function reload(authInfo: AuthInfo, costInfo: Awaited<ReturnType<typeof trackUsage>>) {
if (!authInfo) return if (!authInfo) return
if (authInfo.isFree) return if (authInfo.isFree) return
if (authInfo.provider?.credentials) return if (authInfo.provider?.credentials) return
if (!costInfo) return
const reloadTrigger = centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100)
if (authInfo.billing.balance - costInfo.costInMicroCents >= reloadTrigger) return
if (authInfo.billing.timeReloadLockedTill && authInfo.billing.timeReloadLockedTill > new Date()) return
const lock = await Database.use((tx) => const lock = await Database.use((tx) =>
tx tx
.update(BillingTable) .update(BillingTable)
@@ -625,10 +633,7 @@ export async function handler(
and( and(
eq(BillingTable.workspaceID, authInfo.workspaceID), eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true), eq(BillingTable.reload, true),
lt( lt(BillingTable.balance, reloadTrigger),
BillingTable.balance,
centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)), or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
), ),
), ),
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.2", "version": "1.1.3",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.2", "version": "1.1.3",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.2", "version": "1.1.3",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.1.2", "version": "1.1.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1
View File
@@ -2777,6 +2777,7 @@ version = "0.0.0"
dependencies = [ dependencies = [
"gtk", "gtk",
"listeners", "listeners",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
+1
View File
@@ -35,6 +35,7 @@ serde_json = "1"
tokio = "1.48.0" tokio = "1.48.0"
listeners = "0.3" listeners = "0.3"
tauri-plugin-os = "2" tauri-plugin-os = "2"
semver = "1.0.27"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2" gtk = "0.18.2"
+116
View File
@@ -0,0 +1,116 @@
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
fn get_cli_install_path() -> Option<std::path::PathBuf> {
std::env::var("HOME").ok().map(|home| {
std::path::PathBuf::from(home)
.join(CLI_INSTALL_DIR)
.join(CLI_BINARY_NAME)
})
}
pub fn get_sidecar_path() -> std::path::PathBuf {
tauri::utils::platform::current_exe()
.expect("Failed to get current exe")
.parent()
.expect("Failed to get parent dir")
.join("opencode-cli")
}
fn is_cli_installed() -> bool {
get_cli_install_path()
.map(|path| path.exists())
.unwrap_or(false)
}
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
#[tauri::command]
pub fn install_cli() -> Result<String, String> {
if cfg!(not(unix)) {
return Err("CLI installation is only supported on macOS & Linux".to_string());
}
let sidecar = get_sidecar_path();
if !sidecar.exists() {
return Err("Sidecar binary not found".to_string());
}
let temp_script = std::env::temp_dir().join("opencode-install.sh");
std::fs::write(&temp_script, INSTALL_SCRIPT)
.map_err(|e| format!("Failed to write install script: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("Failed to set script permissions: {}", e))?;
}
let output = std::process::Command::new(&temp_script)
.arg("--binary")
.arg(&sidecar)
.output()
.map_err(|e| format!("Failed to run install script: {}", e))?;
let _ = std::fs::remove_file(&temp_script);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Install script failed: {}", stderr));
}
let install_path =
get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?;
Ok(install_path.to_string_lossy().to_string())
}
pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
if cfg!(debug_assertions) {
println!("Skipping CLI sync for debug build");
return Ok(());
}
if !is_cli_installed() {
println!("No CLI installation found, skipping sync");
return Ok(());
}
let cli_path =
get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?;
let output = std::process::Command::new(&cli_path)
.arg("--version")
.output()
.map_err(|e| format!("Failed to get CLI version: {}", e))?;
if !output.status.success() {
return Err("Failed to get CLI version".to_string());
}
let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
let cli_version = semver::Version::parse(&cli_version_str)
.map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?;
let app_version = app.package_info().version.clone();
if cli_version >= app_version {
println!(
"CLI version {} is up to date (app version: {}), skipping sync",
cli_version, app_version
);
return Ok(());
}
println!(
"CLI version {} is older than app version {}, syncing",
cli_version, app_version
);
install_cli()?;
println!("Synced installed CLI");
Ok(())
}
+83 -70
View File
@@ -1,12 +1,16 @@
mod cli;
mod window_customizer; mod window_customizer;
use cli::{get_sidecar_path, install_cli, sync_cli};
use std::{ use std::{
collections::VecDeque, collections::VecDeque,
net::{SocketAddr, TcpListener}, net::{SocketAddr, TcpListener},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory}; use tauri::{
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow,
};
use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::process::{CommandChild, CommandEvent};
@@ -116,11 +120,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let (mut rx, child) = { let (mut rx, child) = {
let sidecar_path = tauri::utils::platform::current_exe() let sidecar = get_sidecar_path();
.expect("Failed to get current exe")
.parent()
.expect("Failed to get parent dir")
.join("opencode-cli");
let shell = get_user_shell(); let shell = get_user_shell();
app.shell() app.shell()
.command(&shell) .command(&shell)
@@ -130,7 +130,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
.args([ .args([
"-il", "-il",
"-c", "-c",
&format!("{} serve --port={}", sidecar_path.display(), port), &format!("{} serve --port={}", sidecar.display(), port),
]) ])
.spawn() .spawn()
.expect("Failed to spawn opencode") .expect("Failed to spawn opencode")
@@ -203,7 +203,8 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
kill_sidecar, kill_sidecar,
copy_logs_to_clipboard, copy_logs_to_clipboard,
get_logs get_logs,
install_cli
]) ])
.setup(move |app| { .setup(move |app| {
let app = app.handle().clone(); let app = app.handle().clone();
@@ -211,83 +212,95 @@ pub fn run() {
// Initialize log state // Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new())))); app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
tauri::async_runtime::spawn(async move { {
let port = get_sidecar_port(); let app = app.clone();
tauri::async_runtime::spawn(async move {
let port = get_sidecar_port();
let should_spawn_sidecar = !is_server_running(port).await; let should_spawn_sidecar = !is_server_running(port).await;
let child = if should_spawn_sidecar { let child = if should_spawn_sidecar {
let child = spawn_sidecar(&app, port); let child = spawn_sidecar(&app, port);
let timestamp = Instant::now(); let timestamp = Instant::now();
loop { loop {
if timestamp.elapsed() > Duration::from_secs(7) { if timestamp.elapsed() > Duration::from_secs(7) {
let res = app.dialog() let res = app.dialog()
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.") .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
.title("Startup Failed") .title("Startup Failed")
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string())) .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
.blocking_show_with_result(); .blocking_show_with_result();
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") { if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
match copy_logs_to_clipboard(app.clone()).await { match copy_logs_to_clipboard(app.clone()).await {
Ok(()) => println!("Logs copied to clipboard successfully"), Ok(()) => println!("Logs copied to clipboard successfully"),
Err(e) => println!("Failed to copy logs to clipboard: {}", e), Err(e) => println!("Failed to copy logs to clipboard: {}", e),
} }
} }
app.exit(1); app.exit(1);
return; return;
} }
tokio::time::sleep(Duration::from_millis(10)).await; tokio::time::sleep(Duration::from_millis(10)).await;
if is_server_running(port).await { if is_server_running(port).await {
// give the server a little bit more time to warm up // give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await; tokio::time::sleep(Duration::from_millis(10)).await;
break; break;
} }
} }
println!("Server ready after {:?}", timestamp.elapsed()); println!("Server ready after {:?}", timestamp.elapsed());
Some(child) Some(child)
} else { } else {
None None
}; };
let primary_monitor = app.primary_monitor().ok().flatten(); let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor())) .map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080)); .unwrap_or(LogicalSize::new(1920, 1080));
let mut window_builder = let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode") .title("OpenCode")
.inner_size(size.width as f64, size.height as f64) .inner_size(size.width as f64, size.height as f64)
.decorations(true) .decorations(true)
.zoom_hotkeys_enabled(true) .zoom_hotkeys_enabled(true)
.disable_drag_drop_handler() .disable_drag_drop_handler()
.initialization_script(format!( .initialization_script(format!(
r#" r#"
window.__OPENCODE__ ??= {{}}; window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled}; window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port}; window.__OPENCODE__.port = {port};
"# "#
)); ));
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
window_builder = window_builder window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay) .title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true); .hidden_title(true);
}
window_builder.build().expect("Failed to create window");
app.manage(ServerState(Arc::new(Mutex::new(child))));
});
}
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = sync_cli(app) {
eprintln!("Failed to sync CLI: {e}");
} }
});
window_builder.build().expect("Failed to create window"); }
app.manage(ServerState(Arc::new(Mutex::new(child))));
});
Ok(()) Ok(())
}); });
+13
View File
@@ -0,0 +1,13 @@
import { invoke } from "@tauri-apps/api/core"
import { message } from "@tauri-apps/plugin-dialog"
export async function installCli(): Promise<void> {
try {
const path = await invoke<string>("install_cli")
await message(`CLI installed to ${path}\n\nRestart your terminal to use the 'opencode' command.`, {
title: "CLI Installed",
})
} catch (e) {
await message(`Failed to install CLI: ${e}`, { title: "Installation Failed" })
}
}
+5
View File
@@ -2,6 +2,7 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/men
import { type as ostype } from "@tauri-apps/plugin-os" import { type as ostype } from "@tauri-apps/plugin-os"
import { runUpdater, UPDATER_ENABLED } from "./updater" import { runUpdater, UPDATER_ENABLED } from "./updater"
import { installCli } from "./cli"
export async function createMenu() { export async function createMenu() {
if (ostype() !== "macos") return if (ostype() !== "macos") return
@@ -19,6 +20,10 @@ export async function createMenu() {
action: () => runUpdater({ alertOnFail: true }), action: () => runUpdater({ alertOnFail: true }),
text: "Check For Updates...", text: "Check For Updates...",
}), }),
await MenuItem.new({
action: () => installCli(),
text: "Install CLI...",
}),
await PredefinedMenuItem.new({ await PredefinedMenuItem.new({
item: "Separator", item: "Separator",
}), }),
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.2", "version": "1.1.3",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.1.2" version = "1.1.3"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.2", "version": "1.1.3",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.1.2", "version": "1.1.3",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+5 -1
View File
@@ -335,7 +335,11 @@ export const AuthLoginCommand = cmd({
if (provider === "amazon-bedrock") { if (provider === "amazon-bedrock") {
prompts.log.info( prompts.log.info(
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID", "Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
) )
prompts.outro("Done") prompts.outro("Done")
return return
+1 -1
View File
@@ -393,7 +393,7 @@ jobs:
issues: read issues: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Run opencode - name: Run opencode
uses: anomalyco/opencode/github@latest${envStr} uses: anomalyco/opencode/github@latest${envStr}
+1
View File
@@ -50,6 +50,7 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
export const McpCommand = cmd({ export const McpCommand = cmd({
command: "mcp", command: "mcp",
describe: "manage MCP (Model Context Protocol) servers",
builder: (yargs) => builder: (yargs) =>
yargs yargs
.command(McpAddCommand) .command(McpAddCommand)
@@ -569,6 +569,7 @@ function App() {
sdk.event.on(SessionApi.Event.Error.type, (evt) => { sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => { const message = (() => {
if (!error) return "An error occurred" if (!error) return "An error occurred"
@@ -55,6 +55,7 @@ export type AutocompleteOption = {
aliases?: string[] aliases?: string[]
disabled?: boolean disabled?: boolean
description?: string description?: string
isDirectory?: boolean
onSelect?: () => void onSelect?: () => void
} }
@@ -200,8 +201,10 @@ export function Autocomplete(props: {
url = urlObj.toString() url = urlObj.toString()
} }
const isDir = item.endsWith("/")
return { return {
display: Locale.truncateMiddle(filename, width), display: Locale.truncateMiddle(filename, width),
isDirectory: isDir,
onSelect: () => { onSelect: () => {
insertPart(filename, { insertPart(filename, {
type: "file", type: "file",
@@ -511,6 +514,27 @@ export function Autocomplete(props: {
selected.onSelect?.() selected.onSelect?.()
} }
function expandDirectory() {
const selected = options()[store.selected]
if (!selected) return
const input = props.input()
const currentCursorOffset = input.cursorOffset
const displayText = selected.display.trimEnd()
const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
input.cursorOffset = store.index
const startCursor = input.logicalCursor
input.cursorOffset = currentCursorOffset
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText("@" + path)
setStore("selected", 0)
}
function show(mode: "@" | "/") { function show(mode: "@" | "/") {
command.keybinds(false) command.keybinds(false)
setStore({ setStore({
@@ -575,11 +599,21 @@ export function Autocomplete(props: {
e.preventDefault() e.preventDefault()
return return
} }
if (name === "return" || name === "tab") { if (name === "return") {
select() select()
e.preventDefault() e.preventDefault()
return return
} }
if (name === "tab") {
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
} else {
select()
}
e.preventDefault()
return
}
} }
if (!store.visible) { if (!store.visible) {
if (e.name === "@") { if (e.name === "@") {
@@ -559,6 +559,12 @@ export function Prompt(props: PromptProps) {
model: `${selectedModel.providerID}/${selectedModel.modelID}`, model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID, messageID,
variant, variant,
parts: nonTextParts
.filter((x) => x.type === "file")
.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
}) })
} else { } else {
sdk.client.session.prompt({ sdk.client.session.prompt({
@@ -40,6 +40,7 @@ import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { useSDK } from "./sdk"
type ThemeColors = { type ThemeColors = {
primary: RGBA primary: RGBA
@@ -310,32 +311,42 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}) })
}) })
const renderer = useRenderer() function resolveSystemTheme() {
renderer console.log("resolved system theme")
.getPalette({ renderer
size: 16, .getPalette({
}) size: 16,
.then((colors) => { })
if (!colors.palette[0]) { .then((colors) => {
if (store.active === "system") { if (!colors.palette[0]) {
setStore(
produce((draft) => {
draft.active = "opencode"
draft.ready = true
}),
)
}
return
}
setStore(
produce((draft) => {
draft.themes.system = generateSystem(colors, store.mode)
if (store.active === "system") { if (store.active === "system") {
draft.ready = true setStore(
produce((draft) => {
draft.active = "opencode"
draft.ready = true
}),
)
} }
}), return
) }
}) setStore(
produce((draft) => {
draft.themes.system = generateSystem(colors, store.mode)
if (store.active === "system") {
draft.ready = true
}
}),
)
})
}
const renderer = useRenderer()
resolveSystemTheme()
const sdk = useSDK()
sdk.event.on("server.instance.disposed", () => {
resolveSystemTheme()
})
const values = createMemo(() => { const values = createMemo(() => {
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode) return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
@@ -11,6 +11,7 @@
"darkBlue": "#6ba1e6", "darkBlue": "#6ba1e6",
"darkCyan": "#56b6c2", "darkCyan": "#56b6c2",
"darkYellow": "#e5c07b", "darkYellow": "#e5c07b",
"darkPanelBg": "#2a1a1599",
"lightStep6": "#d4d4d4", "lightStep6": "#d4d4d4",
"lightStep11": "#8a8a8a", "lightStep11": "#8a8a8a",
"lightStep12": "#1a1a1a", "lightStep12": "#1a1a1a",
@@ -20,7 +21,8 @@
"lightOrange": "#EC5B2B", "lightOrange": "#EC5B2B",
"lightBlue": "#0062d1", "lightBlue": "#0062d1",
"lightCyan": "#318795", "lightCyan": "#318795",
"lightYellow": "#b0851f" "lightYellow": "#b0851f",
"lightPanelBg": "#fff5f099"
}, },
"theme": { "theme": {
"primary": { "primary": {
@@ -71,6 +73,10 @@
"dark": "transparent", "dark": "transparent",
"light": "transparent" "light": "transparent"
}, },
"backgroundMenu": {
"dark": "darkPanelBg",
"light": "lightPanelBg"
},
"border": { "border": {
"dark": "darkOrange", "dark": "darkOrange",
"light": "lightOrange" "light": "lightOrange"
@@ -1204,7 +1204,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
) )
}} }}
</For> </For>
<Show when={props.message.error}> <Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
<box <box
border={["left"]} border={["left"]}
paddingTop={1} paddingTop={1}
@@ -1219,15 +1219,27 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
</box> </box>
</Show> </Show>
<Switch> <Switch>
<Match when={props.last || final()}> <Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}>
<box paddingLeft={3}> <box paddingLeft={3}>
<text marginTop={1}> <text marginTop={1}>
<span style={{ fg: local.agent.color(props.message.agent) }}> </span>{" "} <span
style={{
fg:
props.message.error?.name === "MessageAbortedError"
? theme.textMuted
: local.agent.color(props.message.agent),
}}
>
{" "}
</span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span> <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span> <span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
<Show when={duration()}> <Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span> <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show> </Show>
<Show when={props.message.error?.name === "MessageAbortedError"}>
<span style={{ fg: theme.textMuted }}> · interrupted</span>
</Show>
</text> </text>
</box> </box>
</Match> </Match>
+45 -20
View File
@@ -395,27 +395,52 @@ export namespace Config {
}) })
export type PermissionRule = z.infer<typeof PermissionRule> export type PermissionRule = z.infer<typeof PermissionRule>
// Capture original key order before zod reorders, then rebuild in original order
const permissionPreprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __originalKeys: Object.keys(val), ...val }
}
return val
}
const permissionTransform = (x: unknown): Record<string, PermissionRule> => {
if (typeof x === "string") return { "*": x as PermissionAction }
const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
const { __originalKeys, ...rest } = obj
if (!__originalKeys) return rest as Record<string, PermissionRule>
const result: Record<string, PermissionRule> = {}
for (const key of __originalKeys) {
if (key in rest) result[key] = rest[key] as PermissionRule
}
return result
}
export const Permission = z export const Permission = z
.object({ .preprocess(
read: PermissionRule.optional(), permissionPreprocess,
edit: PermissionRule.optional(), z
glob: PermissionRule.optional(), .object({
grep: PermissionRule.optional(), __originalKeys: z.string().array().optional(),
list: PermissionRule.optional(), read: PermissionRule.optional(),
bash: PermissionRule.optional(), edit: PermissionRule.optional(),
task: PermissionRule.optional(), glob: PermissionRule.optional(),
external_directory: PermissionRule.optional(), grep: PermissionRule.optional(),
todowrite: PermissionAction.optional(), list: PermissionRule.optional(),
todoread: PermissionAction.optional(), bash: PermissionRule.optional(),
webfetch: PermissionAction.optional(), task: PermissionRule.optional(),
websearch: PermissionAction.optional(), external_directory: PermissionRule.optional(),
codesearch: PermissionAction.optional(), todowrite: PermissionAction.optional(),
lsp: PermissionRule.optional(), todoread: PermissionAction.optional(),
doom_loop: PermissionAction.optional(), webfetch: PermissionAction.optional(),
}) websearch: PermissionAction.optional(),
.catchall(PermissionRule) codesearch: PermissionAction.optional(),
.or(PermissionAction) lsp: PermissionRule.optional(),
.transform((x) => (typeof x === "string" ? { "*": x } : x)) doom_loop: PermissionAction.optional(),
})
.catchall(PermissionRule)
.or(PermissionAction),
)
.transform(permissionTransform)
.meta({ .meta({
ref: "PermissionConfig", ref: "PermissionConfig",
}) })
+34 -12
View File
@@ -15,7 +15,7 @@ import { Flag } from "../flag/flag"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
// Direct imports for bundled providers // Direct imports for bundled providers
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock" import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic" import { createAnthropic } from "@ai-sdk/anthropic"
import { createAzure } from "@ai-sdk/azure" import { createAzure } from "@ai-sdk/azure"
import { createGoogleGenerativeAI } from "@ai-sdk/google" import { createGoogleGenerativeAI } from "@ai-sdk/google"
@@ -168,10 +168,22 @@ export namespace Provider {
} }
}, },
"amazon-bedrock": async () => { "amazon-bedrock": async () => {
const config = await Config.get()
const providerConfig = config.provider?.["amazon-bedrock"]
const auth = await Auth.get("amazon-bedrock") const auth = await Auth.get("amazon-bedrock")
const awsProfile = Env.get("AWS_PROFILE")
// Region precedence: 1) config file, 2) env var, 3) default
const configRegion = providerConfig?.options?.region
const envRegion = Env.get("AWS_REGION")
const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
// Profile: config file takes precedence over env var
const configProfile = providerConfig?.options?.profile
const envProfile = Env.get("AWS_PROFILE")
const profile = configProfile ?? envProfile
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID") const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
const awsRegion = Env.get("AWS_REGION")
const awsBearerToken = iife(() => { const awsBearerToken = iife(() => {
const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK") const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
@@ -183,20 +195,30 @@ export namespace Provider {
return undefined return undefined
}) })
if (!awsProfile && !awsAccessKeyId && !awsBearerToken) return { autoload: false } if (!profile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
const defaultRegion = awsRegion ?? "us-east-1"
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers")) const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
// Build credential provider options (only pass profile if specified)
const credentialProviderOptions = profile ? { profile } : {}
const providerOptions: AmazonBedrockProviderSettings = {
region: defaultRegion,
credentialProvider: fromNodeProviderChain(credentialProviderOptions),
}
// Add custom endpoint if specified (endpoint takes precedence over baseURL)
const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
if (endpoint) {
providerOptions.baseURL = endpoint
}
return { return {
autoload: true, autoload: true,
options: { options: providerOptions,
region: defaultRegion,
credentialProvider: fromNodeProviderChain(),
},
async getModel(sdk: any, modelID: string, options?: Record<string, any>) { async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
// Skip region prefixing if model already has global prefix // Skip region prefixing if model already has a cross-region inference profile prefix
if (modelID.startsWith("global.")) { if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
return sdk.languageModel(modelID) return sdk.languageModel(modelID)
} }
@@ -17,6 +17,28 @@ function mimeToModality(mime: string): Modality | undefined {
export namespace ProviderTransform { export namespace ProviderTransform {
function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
if (msg.content === "") return undefined
return msg
}
if (!Array.isArray(msg.content)) return msg
const filtered = msg.content.filter((part) => {
if (part.type === "text" || part.type === "reasoning") {
return part.text !== ""
}
return true
})
if (filtered.length === 0) return undefined
return { ...msg, content: filtered }
})
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
}
if (model.api.id.includes("claude")) { if (model.api.id.includes("claude")) {
return msgs.map((msg) => { return msgs.map((msg) => {
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
+24 -7
View File
@@ -12,6 +12,9 @@ import { Shell } from "@/shell/shell"
export namespace Pty { export namespace Pty {
const log = Log.create({ service: "pty" }) const log = Log.create({ service: "pty" })
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const pty = lazy(async () => { const pty = lazy(async () => {
const { spawn } = await import("bun-pty") const { spawn } = await import("bun-pty")
return spawn return spawn
@@ -126,15 +129,19 @@ export namespace Pty {
} }
state().set(id, session) state().set(id, session)
ptyProcess.onData((data) => { ptyProcess.onData((data) => {
if (session.subscribers.size === 0) { let open = false
session.buffer += data
return
}
for (const ws of session.subscribers) { for (const ws of session.subscribers) {
if (ws.readyState === 1) { if (ws.readyState !== 1) {
ws.send(data) session.subscribers.delete(ws)
continue
} }
open = true
ws.send(data)
} }
if (open) return
session.buffer += data
if (session.buffer.length <= BUFFER_LIMIT) return
session.buffer = session.buffer.slice(-BUFFER_LIMIT)
}) })
ptyProcess.onExit(({ exitCode }) => { ptyProcess.onExit(({ exitCode }) => {
log.info("session exited", { id, exitCode }) log.info("session exited", { id, exitCode })
@@ -196,8 +203,18 @@ export namespace Pty {
log.info("client connected to session", { id }) log.info("client connected to session", { id })
session.subscribers.add(ws) session.subscribers.add(ws)
if (session.buffer) { if (session.buffer) {
ws.send(session.buffer) const buffer = session.buffer.length <= BUFFER_LIMIT ? session.buffer : session.buffer.slice(-BUFFER_LIMIT)
session.buffer = "" session.buffer = ""
try {
for (let i = 0; i < buffer.length; i += BUFFER_CHUNK) {
ws.send(buffer.slice(i, i + BUFFER_CHUNK))
}
} catch {
session.subscribers.delete(ws)
session.buffer = buffer
ws.close()
return
}
} }
return { return {
onMessage: (message: string | ArrayBuffer) => { onMessage: (message: string | ArrayBuffer) => {
+17 -3
View File
@@ -1422,10 +1422,23 @@ export namespace SessionPrompt {
arguments: z.string(), arguments: z.string(),
command: z.string(), command: z.string(),
variant: z.string().optional(), variant: z.string().optional(),
parts: z
.array(
z.discriminatedUnion("type", [
MessageV2.FilePart.omit({
messageID: true,
sessionID: true,
}).partial({
id: true,
}),
]),
)
.optional(),
}) })
export type CommandInput = z.infer<typeof CommandInput> export type CommandInput = z.infer<typeof CommandInput>
const bashRegex = /!`([^`]+)`/g const bashRegex = /!`([^`]+)`/g
const argsRegex = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g // Match [Image N] as single token, quoted strings, or non-space sequences
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g const quoteTrimRegex = /^["']|["']$/g
/** /**
@@ -1516,6 +1529,7 @@ export namespace SessionPrompt {
throw error throw error
} }
const templateParts = await resolvePromptParts(template)
const parts = const parts =
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
? [ ? [
@@ -1525,10 +1539,10 @@ export namespace SessionPrompt {
description: command.description ?? "", description: command.description ?? "",
command: input.command, command: input.command,
// TODO: how can we make task tool accept a more complex input? // TODO: how can we make task tool accept a more complex input?
prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""), prompt: templateParts.find((y) => y.type === "text")?.text ?? "",
}, },
] ]
: await resolvePromptParts(template) : [...templateParts, ...(input.parts ?? [])]
const result = (await prompt({ const result = (await prompt({
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -868,3 +868,48 @@ test("merges legacy tools with existing permission config", async () => {
}, },
}) })
}) })
test("permission config preserves key order", 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",
permission: {
"*": "deny",
edit: "ask",
write: "ask",
external_directory: "ask",
read: "allow",
todowrite: "allow",
todoread: "allow",
"thoughts_*": "allow",
"reasoning_model_*": "allow",
"tools_*": "allow",
"pr_comments_*": "allow",
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(Object.keys(config.permission!)).toEqual([
"*",
"edit",
"write",
"external_directory",
"read",
"todowrite",
"todoread",
"thoughts_*",
"reasoning_model_*",
"tools_*",
"pr_comments_*",
])
},
})
})
+2
View File
@@ -42,6 +42,8 @@ delete process.env["GOOGLE_GENERATIVE_AI_API_KEY"]
delete process.env["AZURE_OPENAI_API_KEY"] delete process.env["AZURE_OPENAI_API_KEY"]
delete process.env["AWS_ACCESS_KEY_ID"] delete process.env["AWS_ACCESS_KEY_ID"]
delete process.env["AWS_PROFILE"] delete process.env["AWS_PROFILE"]
delete process.env["AWS_REGION"]
delete process.env["AWS_BEARER_TOKEN_BEDROCK"]
delete process.env["OPENROUTER_API_KEY"] delete process.env["OPENROUTER_API_KEY"]
delete process.env["GROQ_API_KEY"] delete process.env["GROQ_API_KEY"]
delete process.env["MISTRAL_API_KEY"] delete process.env["MISTRAL_API_KEY"]
@@ -1,11 +1,40 @@
import { test, expect } from "bun:test" import { test, expect, mock } from "bun:test"
import path from "path" import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance" // === Mocks ===
import { Provider } from "../../src/provider/provider" // These mocks are required because Provider.list() triggers:
import { Env } from "../../src/env" // 1. BunProc.install("@aws-sdk/credential-providers") - in bedrock custom loader
import { Auth } from "../../src/auth" // 2. Plugin.list() which calls BunProc.install() for default plugins
import { Global } from "../../src/global" // Without mocks, these would attempt real package installations that timeout in tests.
mock.module("../../src/bun/index", () => ({
BunProc: {
install: async (pkg: string) => pkg,
run: async () => {
throw new Error("BunProc.run should not be called in tests")
},
which: () => process.execPath,
InstallFailedError: class extends Error {},
},
}))
mock.module("@aws-sdk/credential-providers", () => ({
fromNodeProviderChain: () => async () => ({
accessKeyId: "mock-access-key-id",
secretAccessKey: "mock-secret-access-key",
}),
}))
const mockPlugin = () => ({})
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
// Import after mocks are set up
const { tmpdir } = await import("../fixture/fixture")
const { Instance } = await import("../../src/project/instance")
const { Provider } = await import("../../src/provider/provider")
const { Env } = await import("../../src/env")
const { Global } = await import("../../src/global")
test("Bedrock: config region takes precedence over AWS_REGION env var", async () => { test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
@@ -34,13 +63,12 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await Provider.list()
expect(providers["amazon-bedrock"]).toBeDefined() expect(providers["amazon-bedrock"]).toBeDefined()
// Region from config should be used (not env var)
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1") expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
}, },
}) })
}) })
test("Bedrock: falls back to AWS_REGION env var when no config", async () => { test("Bedrock: falls back to AWS_REGION env var when no config region", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
await Bun.write( await Bun.write(
@@ -65,129 +93,6 @@ test("Bedrock: falls back to AWS_REGION env var when no config", async () => {
}) })
}) })
test("Bedrock: without explicit region config, uses AWS_REGION env or defaults", 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("AWS_PROFILE", "default")
// AWS_REGION might be set in the environment, use that or default
},
fn: async () => {
const providers = await Provider.list()
expect(providers["amazon-bedrock"]).toBeDefined()
// Should have some region set (either from env or default)
expect(providers["amazon-bedrock"].options?.region).toBeDefined()
expect(typeof providers["amazon-bedrock"].options?.region).toBe("string")
},
})
})
test("Bedrock: uses config region in provider options", 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",
provider: {
"amazon-bedrock": {
options: {
region: "eu-north-1",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await Provider.list()
const bedrockProvider = providers["amazon-bedrock"]
expect(bedrockProvider).toBeDefined()
expect(bedrockProvider.options?.region).toBe("eu-north-1")
},
})
})
test("Bedrock: respects config region for different instances", async () => {
// First instance with EU config
await using tmp1 = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
region: "eu-west-1",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp1.path,
init: async () => {
Env.set("AWS_PROFILE", "default")
Env.set("AWS_REGION", "us-east-1")
},
fn: async () => {
const providers1 = await Provider.list()
expect(providers1["amazon-bedrock"].options?.region).toBe("eu-west-1")
},
})
// Second instance with US config
await using tmp2 = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"amazon-bedrock": {
options: {
region: "us-west-2",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp2.path,
init: async () => {
Env.set("AWS_PROFILE", "default")
Env.set("AWS_REGION", "eu-west-1")
},
fn: async () => {
const providers2 = await Provider.list()
expect(providers2["amazon-bedrock"].options?.region).toBe("us-west-2")
},
})
})
test("Bedrock: loads when bearer token from auth.json is present", async () => { test("Bedrock: loads when bearer token from auth.json is present", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
@@ -207,7 +112,6 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
}, },
}) })
// Setup auth.json with bearer token for amazon-bedrock
const authPath = path.join(Global.Path.data, "auth.json") const authPath = path.join(Global.Path.data, "auth.json")
await Bun.write( await Bun.write(
authPath, authPath,
@@ -222,7 +126,6 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
init: async () => { init: async () => {
// Clear env vars so only auth.json should trigger autoload
Env.set("AWS_PROFILE", "") Env.set("AWS_PROFILE", "")
Env.set("AWS_ACCESS_KEY_ID", "") Env.set("AWS_ACCESS_KEY_ID", "")
Env.set("AWS_BEARER_TOKEN_BEDROCK", "") Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
@@ -234,3 +137,69 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
}, },
}) })
}) })
test("Bedrock: config profile takes precedence over AWS_PROFILE env var", 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",
provider: {
"amazon-bedrock": {
options: {
profile: "my-custom-profile",
region: "us-east-1",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("AWS_PROFILE", "default")
Env.set("AWS_ACCESS_KEY_ID", "test-key-id")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
},
})
})
test("Bedrock: includes custom endpoint in options when specified", 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",
provider: {
"amazon-bedrock": {
options: {
endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].options?.endpoint).toBe(
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
)
},
})
})
@@ -410,6 +410,181 @@ describe("ProviderTransform.message - empty image handling", () => {
}) })
}) })
describe("ProviderTransform.message - anthropic empty content filtering", () => {
const anthropicModel = {
id: "anthropic/claude-3-5-sonnet",
providerID: "anthropic",
api: {
id: "claude-3-5-sonnet-20241022",
url: "https://api.anthropic.com",
npm: "@ai-sdk/anthropic",
},
name: "Claude 3.5 Sonnet",
capabilities: {
temperature: true,
reasoning: false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0.003,
output: 0.015,
cache: { read: 0.0003, write: 0.00375 },
},
limit: {
context: 200000,
output: 8192,
},
status: "active",
options: {},
headers: {},
} as any
test("filters out messages with empty string content", () => {
const msgs = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "" },
{ role: "user", content: "World" },
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
expect(result).toHaveLength(2)
expect(result[0].content).toBe("Hello")
expect(result[1].content).toBe("World")
})
test("filters out empty text parts from array content", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "text", text: "" },
{ type: "text", text: "Hello" },
{ type: "text", text: "" },
],
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(1)
expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" })
})
test("filters out empty reasoning parts from array content", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "reasoning", text: "" },
{ type: "text", text: "Answer" },
{ type: "reasoning", text: "" },
],
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(1)
expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" })
})
test("removes entire message when all parts are empty", () => {
const msgs = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [
{ type: "text", text: "" },
{ type: "reasoning", text: "" },
],
},
{ role: "user", content: "World" },
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
expect(result).toHaveLength(2)
expect(result[0].content).toBe("Hello")
expect(result[1].content).toBe("World")
})
test("keeps non-text/reasoning parts even if text parts are empty", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "text", text: "" },
{ type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } },
],
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(1)
expect(result[0].content[0]).toEqual({
type: "tool-call",
toolCallId: "123",
toolName: "bash",
input: { command: "ls" },
})
})
test("keeps messages with valid text alongside empty parts", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "reasoning", text: "Thinking..." },
{ type: "text", text: "" },
{ type: "text", text: "Result" },
],
},
] as any[]
const result = ProviderTransform.message(msgs, anthropicModel)
expect(result).toHaveLength(1)
expect(result[0].content).toHaveLength(2)
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." })
expect(result[0].content[1]).toEqual({ type: "text", text: "Result" })
})
test("does not filter for non-anthropic providers", () => {
const openaiModel = {
...anthropicModel,
providerID: "openai",
api: {
id: "gpt-4",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
}
const msgs = [
{ role: "assistant", content: "" },
{
role: "assistant",
content: [{ type: "text", text: "" }],
},
] as any[]
const result = ProviderTransform.message(msgs, openaiModel)
expect(result).toHaveLength(2)
expect(result[0].content).toBe("")
expect(result[1].content).toHaveLength(1)
})
})
describe("ProviderTransform.variants", () => { describe("ProviderTransform.variants", () => {
const createMockModel = (overrides: Partial<any> = {}): any => ({ const createMockModel = (overrides: Partial<any> = {}): any => ({
id: "test/test-model", id: "test/test-model",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.2", "version": "1.1.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.2", "version": "1.1.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+10
View File
@@ -24,6 +24,7 @@ import type {
ExperimentalResourceListResponses, ExperimentalResourceListResponses,
FileListResponses, FileListResponses,
FilePartInput, FilePartInput,
FilePartSource,
FileReadResponses, FileReadResponses,
FileStatusResponses, FileStatusResponses,
FindFilesResponses, FindFilesResponses,
@@ -1451,6 +1452,14 @@ export class Session extends HeyApiClient {
arguments?: string arguments?: string
command?: string command?: string
variant?: string variant?: string
parts?: Array<{
id?: string
type: "file"
mime: string
filename?: string
url: string
source?: FilePartSource
}>
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
@@ -1467,6 +1476,7 @@ export class Session extends HeyApiClient {
{ in: "body", key: "arguments" }, { in: "body", key: "arguments" },
{ in: "body", key: "command" }, { in: "body", key: "command" },
{ in: "body", key: "variant" }, { in: "body", key: "variant" },
{ in: "body", key: "parts" },
], ],
}, },
], ],
+10 -1
View File
@@ -1222,6 +1222,7 @@ export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConf
export type PermissionConfig = export type PermissionConfig =
| { | {
__originalKeys?: Array<string>
read?: PermissionRuleConfig read?: PermissionRuleConfig
edit?: PermissionRuleConfig edit?: PermissionRuleConfig
glob?: PermissionRuleConfig glob?: PermissionRuleConfig
@@ -1237,7 +1238,7 @@ export type PermissionConfig =
codesearch?: PermissionActionConfig codesearch?: PermissionActionConfig
lsp?: PermissionRuleConfig lsp?: PermissionRuleConfig
doom_loop?: PermissionActionConfig doom_loop?: PermissionActionConfig
[key: string]: PermissionRuleConfig | PermissionActionConfig | undefined [key: string]: PermissionRuleConfig | Array<string> | PermissionActionConfig | undefined
} }
| PermissionActionConfig | PermissionActionConfig
@@ -3292,6 +3293,14 @@ export type SessionCommandData = {
arguments: string arguments: string
command: string command: string
variant?: string variant?: string
parts?: Array<{
id?: string
type: "file"
mime: string
filename?: string
url: string
source?: FilePartSource
}>
} }
path: { path: {
/** /**
+38
View File
@@ -2667,6 +2667,38 @@
}, },
"variant": { "variant": {
"type": "string" "type": "string"
},
"parts": {
"type": "array",
"items": {
"anyOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"const": "file"
},
"mime": {
"type": "string"
},
"filename": {
"type": "string"
},
"url": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/FilePartSource"
}
},
"required": ["type", "mime", "url"]
}
]
}
} }
}, },
"required": ["arguments", "command"] "required": ["arguments", "command"]
@@ -8221,6 +8253,12 @@
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"__originalKeys": {
"type": "array",
"items": {
"type": "string"
}
},
"read": { "read": {
"$ref": "#/components/schemas/PermissionRuleConfig" "$ref": "#/components/schemas/PermissionRuleConfig"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.2", "version": "1.1.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.2", "version": "1.1.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.2", "version": "1.1.3",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.1.2", "version": "1.1.3",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+35
View File
@@ -205,6 +205,41 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo
--- ---
#### Provider-Specific Options
Some providers support additional configuration options beyond the generic `timeout` and `apiKey` settings.
##### Amazon Bedrock
Amazon Bedrock supports AWS-specific configuration:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"amazon-bedrock": {
"options": {
"region": "us-east-1",
"profile": "my-aws-profile",
"endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com"
}
}
}
}
```
- `region` - AWS region for Bedrock (defaults to `AWS_REGION` env var or `us-east-1`)
- `profile` - AWS named profile from `~/.aws/credentials` (defaults to `AWS_PROFILE` env var)
- `endpoint` - Custom endpoint URL for VPC endpoints. This is an alias for the generic `baseURL` option using AWS-specific terminology. If both are specified, `endpoint` takes precedence.
:::note
Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over profile-based authentication. See [authentication precedence](/docs/providers#authentication-precedence) for details.
:::
[Learn more about Amazon Bedrock configuration](/docs/providers#amazon-bedrock).
---
### Themes ### Themes
You can configure the theme you want to use in your OpenCode config through the `theme` option. You can configure the theme you want to use in your OpenCode config through the `theme` option.
+4 -4
View File
@@ -58,7 +58,7 @@ Or you can set it up manually.
id-token: write id-token: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 1 fetch-depth: 1
@@ -134,7 +134,7 @@ jobs:
issues: write issues: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Run OpenCode - name: Run OpenCode
uses: anomalyco/opencode/github@latest uses: anomalyco/opencode/github@latest
@@ -171,7 +171,7 @@ jobs:
pull-requests: read pull-requests: read
issues: read issues: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: anomalyco/opencode/github@latest - uses: anomalyco/opencode/github@latest
env: env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -221,7 +221,7 @@ jobs:
return days >= 30; return days >= 30;
result-encoding: string result-encoding: string
- uses: actions/checkout@v4 - uses: actions/checkout@v6
if: steps.check.outputs.result == 'true' if: steps.check.outputs.result == 'true'
- uses: anomalyco/opencode/github@latest - uses: anomalyco/opencode/github@latest
+81 -12
View File
@@ -107,27 +107,96 @@ To use Amazon Bedrock with OpenCode:
You need to have access to the model you want in Amazon Bedrock. You need to have access to the model you want in Amazon Bedrock.
::: :::
1. You'll need either to set one of the following environment variables: 2. **Configure authentication** using one of the following methods:
- `AWS_ACCESS_KEY_ID`: You can get this by creating an IAM user and generating
an access key for it.
- `AWS_PROFILE`: First login through AWS IAM Identity Center (or AWS SSO) using
`aws sso login`. Then get the name of the profile you want to use.
- `AWS_BEARER_TOKEN_BEDROCK`: You can generate a long-term API key from the
Amazon Bedrock console.
Once you have one of the above, set it while running opencode. #### Environment Variables (Quick Start)
Set one of these environment variables while running opencode:
```bash ```bash
AWS_ACCESS_KEY_ID=XXX opencode # Option 1: Using AWS access keys
AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=YYY opencode
# Option 2: Using named AWS profile
AWS_PROFILE=my-profile opencode
# Option 3: Using Bedrock bearer token
AWS_BEARER_TOKEN_BEDROCK=XXX opencode
``` ```
Or add it to your bash profile. Or add them to your bash profile:
```bash title="~/.bash_profile" ```bash title="~/.bash_profile"
export AWS_ACCESS_KEY_ID=XXX export AWS_PROFILE=my-dev-profile
export AWS_REGION=us-east-1
``` ```
1. Run the `/models` command to select the model you want. #### Configuration File (Recommended)
For project-specific or persistent configuration, use `opencode.json`:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"amazon-bedrock": {
"options": {
"region": "us-east-1",
"profile": "my-aws-profile"
}
}
}
}
```
**Available options:**
- `region` - AWS region (e.g., `us-east-1`, `eu-west-1`)
- `profile` - AWS named profile from `~/.aws/credentials`
- `endpoint` - Custom endpoint URL for VPC endpoints (alias for generic `baseURL` option)
:::tip
Configuration file options take precedence over environment variables.
:::
#### Advanced: VPC Endpoints
If you're using VPC endpoints for Bedrock:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"amazon-bedrock": {
"options": {
"region": "us-east-1",
"profile": "production",
"endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com"
}
}
}
}
```
:::note
The `endpoint` option is an alias for the generic `baseURL` option, using AWS-specific terminology. If both `endpoint` and `baseURL` are specified, `endpoint` takes precedence.
:::
#### Authentication Methods
- **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**: Create an IAM user and generate access keys in the AWS Console
- **`AWS_PROFILE`**: Use named profiles from `~/.aws/credentials`. First configure with `aws configure --profile my-profile` or `aws sso login`
- **`AWS_BEARER_TOKEN_BEDROCK`**: Generate long-term API keys from the Amazon Bedrock console
#### Authentication Precedence
Amazon Bedrock uses the following authentication priority:
1. **Bearer Token** - `AWS_BEARER_TOKEN_BEDROCK` environment variable or token from `/connect` command
2. **AWS Credential Chain** - Profile, access keys, shared credentials, IAM roles, instance metadata
:::note
When a bearer token is set (via `/connect` or `AWS_BEARER_TOKEN_BEDROCK`), it takes precedence over all AWS credential methods including configured profiles.
:::
3. Run the `/models` command to select the model you want.
```txt ```txt
/models /models
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.1.2", "version": "1.1.3",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",