Compare commits

..

12 Commits

Author SHA1 Message Date
Aiden Cline
ac3d0cb5a3 review cleanup 2026-01-18 00:24:11 -06:00
Aiden Cline
06d69ab609 cleanup 2026-01-18 00:17:13 -06:00
Aiden Cline
c2cc486c7d exclude write tool too 2026-01-17 23:46:23 -06:00
Aiden Cline
8a6b8e5339 tweak wording to say Patched for ui rendered tool parts 2026-01-17 23:25:07 -06:00
Aiden Cline
cfd6a7ae96 add apply patch to desktop app 2026-01-17 22:47:35 -06:00
Aiden Cline
4173ee0e0b add lsp diagnostics to apply patch 2026-01-17 22:47:26 -06:00
Aiden Cline
22b5d7e570 rm assertion for deletes 2026-01-17 22:19:02 -06:00
Aiden Cline
f1ec28176f wip - ui 2026-01-17 22:03:47 -06:00
Aiden Cline
ab78a46396 wip 2026-01-17 21:15:27 -06:00
Aiden Cline
2ed18ea1fe wip 2026-01-17 20:48:09 -06:00
Aiden Cline
40eddce435 wip 2026-01-17 15:13:57 -06:00
Aiden Cline
78f8cc9418 wip 2026-01-17 14:25:29 -06:00
163 changed files with 2212 additions and 4878 deletions

View File

@@ -18,54 +18,6 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install Playwright browsers
working-directory: packages/app
run: bunx playwright install --with-deps
- name: Seed opencode data
working-directory: packages/opencode
run: bun script/seed-e2e.ts
env:
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
- name: Run opencode server
run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 &
env:
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
OPENCODE_CLIENT: "app"
- name: Wait for opencode server
run: |
for i in {1..60}; do
curl -fsS "http://localhost:4096/global/health" > /dev/null && exit 0
sleep 1
done
exit 1
- name: run
run: |
git config --global user.email "bot@opencode.ai"
@@ -74,20 +26,3 @@ jobs:
bun turbo test
env:
CI: true
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
PLAYWRIGHT_SERVER_HOST: "localhost"
PLAYWRIGHT_SERVER_PORT: "4096"
VITE_OPENCODE_SERVER_HOST: "localhost"
VITE_OPENCODE_SERVER_PORT: "4096"
OPENCODE_CLIENT: "app"
timeout-minutes: 30

View File

@@ -10,22 +10,20 @@ on:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- "flake.lock"
- ".github/workflows/update-nix-hashes.yml"
pull_request:
paths:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- "flake.lock"
- ".github/workflows/update-nix-hashes.yml"
jobs:
update-node-modules-hashes:
update-flake:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
TITLE: node_modules hashes
TITLE: flake.lock
steps:
- name: Checkout repository
@@ -44,6 +42,193 @@ jobs:
git config --global user.email "action@github.com"
git config --global user.name "Github Action"
- name: Update ${{ env.TITLE }}
run: |
set -euo pipefail
echo "Updating $TITLE..."
nix flake update
echo "$TITLE updated successfully"
- name: Commit ${{ env.TITLE }} changes
env:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
set -euo pipefail
echo "Checking for changes in tracked files..."
summarize() {
local status="$1"
{
echo "### Nix $TITLE"
echo ""
echo "- ref: ${GITHUB_REF_NAME}"
echo "- status: ${status}"
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
}
FILES=(flake.lock flake.nix)
STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then
echo "No changes detected."
summarize "no changes"
exit 0
fi
echo "Changes detected:"
echo "$STATUS"
echo "Staging files..."
git add "${FILES[@]}"
echo "Committing changes..."
git commit -m "Update $TITLE"
echo "Changes committed"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
echo "Pulling latest from branch: $BRANCH"
git pull --rebase --autostash origin "$BRANCH"
echo "Pushing changes to branch: $BRANCH"
git push origin HEAD:"$BRANCH"
echo "Changes pushed successfully"
summarize "committed $(git rev-parse --short HEAD)"
compute-node-modules-hash:
needs: update-flake
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
strategy:
fail-fast: false
matrix:
include:
- system: x86_64-linux
host: blacksmith-4vcpu-ubuntu-2404
- system: aarch64-linux
host: blacksmith-4vcpu-ubuntu-2404-arm
- system: x86_64-darwin
host: macos-15-intel
- system: aarch64-darwin
host: macos-latest
runs-on: ${{ matrix.host }}
env:
SYSTEM: ${{ matrix.system }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Compute node_modules hash
run: |
set -euo pipefail
DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
HASH_FILE="nix/hashes.json"
OUTPUT_FILE="hash-${SYSTEM}.txt"
export NIX_KEEP_OUTPUTS=1
export NIX_KEEP_DERIVATIONS=1
BUILD_LOG=$(mktemp)
TMP_JSON=$(mktemp)
trap 'rm -f "$BUILD_LOG" "$TMP_JSON"' EXIT
if [ ! -f "$HASH_FILE" ]; then
mkdir -p "$(dirname "$HASH_FILE")"
echo '{"nodeModules":{}}' > "$HASH_FILE"
fi
# Set dummy hash to force nix to rebuild and reveal correct hash
jq --arg system "$SYSTEM" --arg value "$DUMMY" \
'.nodeModules = (.nodeModules // {}) | .nodeModules[$system] = $value' "$HASH_FILE" > "$TMP_JSON"
mv "$TMP_JSON" "$HASH_FILE"
MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules"
DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")"
echo "Building node_modules for ${SYSTEM} to discover correct hash..."
echo "Attempting to realize derivation: ${DRV_PATH}"
REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true)
BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true)
CORRECT_HASH=""
if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then
echo "Realized node_modules output: $BUILD_PATH"
CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true)
fi
# Try to extract hash from build log
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
fi
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
fi
# Try to hash from kept failed build directory
if [ -z "$CORRECT_HASH" ]; then
KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1 || true)
if [ -z "$KEPT_DIR" ]; then
KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1 || true)
fi
if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then
HASH_PATH="$KEPT_DIR"
[ -d "$KEPT_DIR/build" ] && HASH_PATH="$KEPT_DIR/build"
if [ -d "$HASH_PATH/node_modules" ]; then
CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true)
fi
fi
fi
if [ -z "$CORRECT_HASH" ]; then
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
cat "$BUILD_LOG"
exit 1
fi
echo "$CORRECT_HASH" > "$OUTPUT_FILE"
echo "Hash for ${SYSTEM}: $CORRECT_HASH"
- name: Upload hash artifact
uses: actions/upload-artifact@v6
with:
name: hash-${{ matrix.system }}
path: hash-${{ matrix.system }}.txt
retention-days: 1
commit-node-modules-hashes:
needs: compute-node-modules-hash
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
TITLE: node_modules hashes
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Configure git
run: |
git config --global user.email "action@github.com"
git config --global user.name "Github Action"
- name: Pull latest changes
env:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
@@ -51,47 +236,54 @@ jobs:
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git pull --rebase --autostash origin "$BRANCH"
- name: Compute all node_modules hashes
- name: Download all hash artifacts
uses: actions/download-artifact@v7
with:
pattern: hash-*
merge-multiple: true
- name: Merge hashes into hashes.json
run: |
set -euo pipefail
HASH_FILE="nix/hashes.json"
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
if [ ! -f "$HASH_FILE" ]; then
mkdir -p "$(dirname "$HASH_FILE")"
echo '{"nodeModules":{}}' > "$HASH_FILE"
fi
for SYSTEM in $SYSTEMS; do
echo "Computing hash for ${SYSTEM}..."
BUILD_LOG=$(mktemp)
trap 'rm -f "$BUILD_LOG"' EXIT
echo "Merging hashes into ${HASH_FILE}..."
# The updater derivations use fakeHash, so they will fail and reveal the correct hash
UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules"
shopt -s nullglob
files=(hash-*.txt)
if [ ${#files[@]} -eq 0 ]; then
echo "No hash files found, nothing to update"
exit 0
fi
nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
EXPECTED_SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
for sys in $EXPECTED_SYSTEMS; do
if [ ! -f "hash-${sys}.txt" ]; then
echo "WARNING: Missing hash file for $sys"
fi
done
if [ -z "$CORRECT_HASH" ]; then
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
cat "$BUILD_LOG"
exit 1
for f in "${files[@]}"; do
system="${f#hash-}"
system="${system%.txt}"
hash=$(cat "$f")
if [ -z "$hash" ]; then
echo "WARNING: Empty hash for $system, skipping"
continue
fi
echo " ${SYSTEM}: ${CORRECT_HASH}"
jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \
'.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
echo " $system: $hash"
jq --arg sys "$system" --arg h "$hash" \
'.nodeModules = (.nodeModules // {}) | .nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
mv "${HASH_FILE}.tmp" "$HASH_FILE"
done
echo "All hashes computed:"
echo "All hashes merged:"
cat "$HASH_FILE"
- name: Commit ${{ env.TITLE }} changes

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -56,7 +56,6 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "1.57.0",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
@@ -71,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -105,7 +104,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -132,7 +131,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -156,7 +155,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -180,7 +179,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -209,7 +208,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -238,7 +237,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -254,7 +253,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.26",
"version": "1.1.25",
"bin": {
"opencode": "./bin/opencode",
},
@@ -282,7 +281,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.2",
"@gitlab/gitlab-ai-provider": "3.1.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -358,7 +357,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -378,7 +377,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.26",
"version": "1.1.25",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.4",
"@tsconfig/node22": "catalog:",
@@ -389,7 +388,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -402,7 +401,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -443,7 +442,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"zod": "catalog:",
},
@@ -454,7 +453,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -503,7 +502,6 @@
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.0.2",
"@playwright/test": "1.51.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
@@ -919,7 +917,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-p0NZhZJSavWDX9r/Px/mOK2YIC803GZa8iRzcg3f1C6S0qfea/HBTe4/NWvT2+2kWIwhCePGuI4FN2UFiUWXUg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -1357,8 +1355,6 @@
"@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
"@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="],
"@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
@@ -3295,10 +3291,6 @@
"planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="],
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
@@ -4435,8 +4427,6 @@
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1768569498,
"narHash": "sha256-bB6Nt99Cj8Nu5nIUq0GLmpiErIT5KFshMQJGMZwgqUo=",
"lastModified": 1768456270,
"narHash": "sha256-NgaL2CCiUR6nsqUIY4yxkzz07iQUlUCany44CFv+OxY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "be5afa0fcb31f0a96bf9ecba05a516c66fcd8114",
"rev": "f4606b01b39e09065df37905a2133905246db9ed",
"type": "github"
},
"original": {

142
flake.nix
View File

@@ -6,7 +6,11 @@
};
outputs =
{ self, nixpkgs, ... }:
{
self,
nixpkgs,
...
}:
let
systems = [
"aarch64-linux"
@@ -14,56 +18,100 @@
"aarch64-darwin"
"x86_64-darwin"
];
forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
rev = self.shortRev or self.dirtyShortRev or "dirty";
in
{
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [
bun
nodejs_20
pkg-config
openssl
git
];
};
});
inherit (nixpkgs) lib;
forEachSystem = lib.genAttrs systems;
pkgsFor = system: nixpkgs.legacyPackages.${system};
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
bunTarget = {
"aarch64-linux" = "bun-linux-arm64";
"x86_64-linux" = "bun-linux-x64";
"aarch64-darwin" = "bun-darwin-arm64";
"x86_64-darwin" = "bun-darwin-x64";
};
packages = forEachSystem (
pkgs:
# Parse "bun-{os}-{cpu}" to {os, cpu}
parseBunTarget =
target:
let
node_modules = pkgs.callPackage ./nix/node_modules.nix {
inherit rev;
};
opencode = pkgs.callPackage ./nix/opencode.nix {
inherit node_modules;
};
desktop = pkgs.callPackage ./nix/desktop.nix {
inherit opencode;
};
# nixpkgs cpu naming to bun cpu naming
cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; };
# matrix of node_modules builds - these will always fail due to fakeHash usage
# but allow computation of the correct hash from any build machine for any cpu/os
# see the update-nix-hashes workflow for usage
moduleUpdaters = pkgs.lib.listToAttrs (
pkgs.lib.concatMap (cpu:
map (os: {
name = "${cpu}-${os}_node_modules";
value = node_modules.override {
bunCpu = cpuMap.${cpu};
bunOs = os;
hash = pkgs.lib.fakeHash;
};
}) [ "linux" "darwin" ]
) [ "x86_64" "aarch64" ]
);
parts = lib.splitString "-" target;
in
{
default = opencode;
inherit opencode desktop;
} // moduleUpdaters
os = builtins.elemAt parts 1;
cpu = builtins.elemAt parts 2;
};
hashesFile = "${./nix}/hashes.json";
hashesData =
if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { };
# Lookup hash: supports per-system ({system: hash}) or legacy single hash
nodeModulesHashFor =
system:
if builtins.isAttrs hashesData.nodeModules then
hashesData.nodeModules.${system}
else
hashesData.nodeModules;
modelsDev = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
pkgs."models-dev"
);
in
{
devShells = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = with pkgs; [
bun
nodejs_20
pkg-config
openssl
git
];
};
}
);
packages = forEachSystem (
system:
let
pkgs = pkgsFor system;
bunPlatform = parseBunTarget bunTarget.${system};
mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
hash = nodeModulesHashFor system;
bunCpu = bunPlatform.cpu;
bunOs = bunPlatform.os;
};
mkOpencode = pkgs.callPackage ./nix/opencode.nix { };
mkDesktop = pkgs.callPackage ./nix/desktop.nix { };
opencodePkg = mkOpencode {
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
target = bunTarget.${system};
modelsDev = "${modelsDev.${system}}/dist/_api.json";
inherit mkNodeModules;
};
desktopPkg = mkDesktop {
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
mkNodeModules = mkNodeModules;
opencode = opencodePkg;
};
in
{
default = self.packages.${system}.opencode;
opencode = opencodePkg;
desktop = desktopPkg;
}
);
};
}

View File

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

40
nix/bundle.ts Normal file
View File

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

View File

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

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=",
"aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=",
"aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=",
"x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg="
"x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=",
"aarch64-linux": "sha256-3/BSRsl5pI0Iz3qAFZxIkOehFLZ2Ox9UsbdDHYzqlVg=",
"aarch64-darwin": "sha256-86d/G1q6xiHSSlm+/irXoKLb/yLQbV348uuSrBV70+Q=",
"x86_64-darwin": "sha256-WYaP44PWRGtoG1DIuUJUH4DvuaCuFhlJZ9fPzGsiIfE="
}
}

62
nix/node-modules.nix Normal file
View File

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

View File

@@ -1,85 +0,0 @@
{
lib,
stdenvNoCC,
bun,
bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64",
bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin",
rev ? "dirty",
hash ?
(lib.pipe ./hashes.json [
builtins.readFile
builtins.fromJSON
]).nodeModules.${stdenvNoCC.hostPlatform.system},
}:
let
packageJson = lib.pipe ../packages/opencode/package.json [
builtins.readFile
builtins.fromJSON
];
in
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = "${packageJson.version}-${rev}";
src = lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
lib.fileset.unions [
../packages
../bun.lock
../package.json
../patches
../install
]
);
};
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
"GIT_PROXY_COMMAND"
"SOCKS_SERVER"
];
nativeBuildInputs = [
bun
];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="${bunCpu}" \
--os="${bunOs}" \
--frozen-lockfile \
--ignore-scripts \
--no-progress \
--linker=isolated
bun --bun ${./scripts/canonicalize-node-modules.ts}
bun --bun ${./scripts/normalize-bun-binaries.ts}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
find . -type d -name node_modules -exec cp -R --parents {} $out \;
runHook postInstall
'';
dontFixup = true;
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = hash;
meta.platforms = [
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
}

View File

@@ -1,48 +1,61 @@
{
lib,
stdenvNoCC,
callPackage,
bun,
sysctl,
makeBinaryWrapper,
models-dev,
ripgrep,
installShellFiles,
versionCheckHook,
writableTmpDirAsHomeHook,
node_modules ? callPackage ./node-modules.nix { },
makeBinaryWrapper,
}:
args:
let
inherit (args) scripts;
mkModules =
attrs:
args.mkNodeModules (
attrs
// {
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
}
);
in
stdenvNoCC.mkDerivation (finalAttrs: {
pname = "opencode";
inherit (node_modules) version src;
inherit node_modules;
inherit (args) version src;
node_modules = mkModules {
inherit (finalAttrs) version src;
};
nativeBuildInputs = [
bun
installShellFiles
makeBinaryWrapper
models-dev
writableTmpDirAsHomeHook
];
configurePhase = ''
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
runHook postConfigure
'';
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
env.OPENCODE_VERSION = finalAttrs.version;
env.OPENCODE_CHANNEL = "local";
env.MODELS_DEV_API_JSON = args.modelsDev;
env.OPENCODE_VERSION = args.version;
env.OPENCODE_CHANNEL = "stable";
dontConfigure = true;
buildPhase = ''
runHook preBuild
cd ./packages/opencode
bun --bun ./script/build.ts --single --skip-install
bun --bun ./script/schema.ts schema.json
cp -r ${finalAttrs.node_modules}/node_modules .
cp -r ${finalAttrs.node_modules}/packages .
(
cd packages/opencode
chmod -R u+w ./node_modules
mkdir -p ./node_modules/@opencode-ai
rm -f ./node_modules/@opencode-ai/{script,sdk,plugin}
ln -s $(pwd)/../../packages/script ./node_modules/@opencode-ai/script
ln -s $(pwd)/../../packages/sdk/js ./node_modules/@opencode-ai/sdk
ln -s $(pwd)/../../packages/plugin ./node_modules/@opencode-ai/plugin
cp ${./bundle.ts} ./bundle.ts
chmod +x ./bundle.ts
bun run ./bundle.ts
)
runHook postBuild
'';
@@ -50,47 +63,76 @@ stdenvNoCC.mkDerivation (finalAttrs: {
installPhase = ''
runHook preInstall
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
install -Dm644 schema.json $out/share/opencode/schema.json
cd packages/opencode
if [ ! -d dist ]; then
echo "ERROR: dist directory missing after bundle step"
exit 1
fi
wrapProgram $out/bin/opencode \
--prefix PATH : ${
lib.makeBinPath (
[
ripgrep
]
# bun runs sysctl to detect if dunning on rosetta2
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
)
}
mkdir -p $out/lib/opencode
cp -r dist $out/lib/opencode/
chmod -R u+w $out/lib/opencode/dist
# Select bundled worker assets deterministically (sorted find output)
worker_file=$(find "$out/lib/opencode/dist" -type f \( -path '*/tui/worker.*' -o -name 'worker.*' \) | sort | head -n1)
parser_worker_file=$(find "$out/lib/opencode/dist" -type f -name 'parser.worker.*' | sort | head -n1)
if [ -z "$worker_file" ]; then
echo "ERROR: bundled worker not found"
exit 1
fi
main_wasm=$(printf '%s\n' "$out"/lib/opencode/dist/tree-sitter-*.wasm | sort | head -n1)
wasm_list=$(find "$out/lib/opencode/dist" -maxdepth 1 -name 'tree-sitter-*.wasm' -print)
for patch_file in "$worker_file" "$parser_worker_file"; do
[ -z "$patch_file" ] && continue
[ ! -f "$patch_file" ] && continue
if [ -n "$wasm_list" ] && grep -q 'tree-sitter' "$patch_file"; then
# Rewrite wasm references to absolute store paths to avoid runtime resolve failures.
bun --bun ${scripts + "/patch-wasm.ts"} "$patch_file" "$main_wasm" $wasm_list
fi
done
mkdir -p $out/lib/opencode/node_modules
cp -r ../../node_modules/.bun $out/lib/opencode/node_modules/
mkdir -p $out/lib/opencode/node_modules/@opentui
mkdir -p $out/bin
makeWrapper ${bun}/bin/bun $out/bin/opencode \
--add-flags "run" \
--add-flags "$out/lib/opencode/dist/src/index.js" \
--prefix PATH : ${lib.makeBinPath [ ripgrep ]} \
--argv0 opencode
runHook postInstall
'';
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions
installShellCompletion --cmd opencode \
--bash <($out/bin/opencode completion) \
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
postInstall = ''
for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do
if [ -d "$pkg" ]; then
pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/')
ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \
$out/lib/opencode/node_modules/@opentui/$pkgName
fi
done
'';
nativeInstallCheckInputs = [
versionCheckHook
writableTmpDirAsHomeHook
];
doInstallCheck = true;
versionCheckKeepEnvironment = [ "HOME" ];
versionCheckProgramArg = "--version";
passthru = {
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
};
dontFixup = true;
meta = {
description = "The open source coding agent";
homepage = "https://opencode.ai/";
description = "AI coding agent built for the terminal";
longDescription = ''
OpenCode is a terminal-based agent that can build anything.
It combines a TypeScript/JavaScript core with a Go-based TUI
to provide an interactive AI coding experience.
'';
homepage = "https://github.com/anomalyco/opencode";
license = lib.licenses.mit;
platforms = [
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
mainProgram = "opencode";
inherit (node_modules.meta) platforms;
};
})

120
nix/scripts/bun-build.ts Normal file
View File

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

43
nix/scripts/patch-wasm.ts Normal file
View File

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

View File

@@ -44,7 +44,6 @@
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"@playwright/test": "1.51.0",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",

View File

@@ -1,3 +1 @@
src/assets/theme.css
e2e/test-results
e2e/playwright-report

View File

@@ -29,21 +29,6 @@ It correctly bundles Solid in production mode and optimizes the build for the be
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## E2E Testing
The Playwright runner expects the app already running at `http://localhost:3000`.
```bash
bun add -D @playwright/test
bunx playwright install
bun run test:e2e
```
Environment options:
- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`)
- `PLAYWRIGHT_PORT` (default: `3000`)
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)

View File

@@ -1,45 +0,0 @@
import { test, expect } from "./fixtures"
import { promptSelector } from "./utils"
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}`
const created = await sdk.session.create({ title }).then((r) => r.data)
if (!created?.id) throw new Error("Session create did not return an id")
const sessionID = created.id
try {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
await gotoSession(sessionID)
const contextButton = page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
await expect(contextButton).toBeVisible()
await contextButton.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})

View File

@@ -1,23 +0,0 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await gotoSession()
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(fileItem).toBeVisible()
await fileItem.click()
await expect(dialog).toHaveCount(0)
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible()
})

View File

@@ -1,40 +0,0 @@
import { test as base, expect } from "@playwright/test"
import { createSdk, dirSlug, getWorktree, promptSelector, sessionPath } from "./utils"
type TestFixtures = {
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
}
type WorkerFixtures = {
directory: string
slug: string
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
directory: [
async ({}, use) => {
const directory = await getWorktree()
await use(directory)
},
{ scope: "worker" },
],
slug: [
async ({ directory }, use) => {
await use(dirSlug(directory))
},
{ scope: "worker" },
],
sdk: async ({ directory }, use) => {
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
}
await use(gotoSession)
},
})
export { expect }

View File

@@ -1,21 +0,0 @@
import { test, expect } from "./fixtures"
import { serverName } from "./utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
})
test("server picker dialog opens from home", async ({ page }) => {
await page.goto("/")
const trigger = page.getByRole("button", { name: serverName })
await expect(trigger).toBeVisible()
await trigger.click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
})

View File

@@ -1,9 +0,0 @@
import { test, expect } from "./fixtures"
import { dirPath, promptSelector } from "./utils"
test("project route redirects to /session", async ({ page, directory, slug }) => {
await page.goto(dirPath(directory))
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await expect(page.locator(promptSelector)).toBeVisible()
})

View File

@@ -1,15 +0,0 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})

View File

@@ -1,21 +0,0 @@
import { test, expect } from "./fixtures"
import { promptSelector } from "./utils"
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke ${Date.now()}`
const created = await sdk.session.create({ title }).then((r) => r.data)
if (!created?.id) throw new Error("Session create did not return an id")
const sessionID = created.id
try {
await gotoSession(sessionID)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("hello from e2e")
await expect(prompt).toContainText("hello from e2e")
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})

View File

@@ -1,21 +0,0 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()
const main = page.locator("main")
const closedClass = /xl:border-l/
const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
if (isClosed) {
await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(closedClass)
}
await page.keyboard.press(`${modKey}+B`)
await expect(main).toHaveClass(closedClass)
await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(closedClass)
})

View File

@@ -1,16 +0,0 @@
import { test, expect } from "./fixtures"
import { terminalSelector, terminalToggleKey } from "./utils"
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
await gotoSession()
const terminal = page.locator(terminalSelector)
const initiallyOpen = await terminal.isVisible()
if (initiallyOpen) {
await page.keyboard.press(terminalToggleKey)
await expect(terminal).toHaveCount(0)
}
await page.keyboard.press(terminalToggleKey)
await expect(terminal).toBeVisible()
})

View File

@@ -1,38 +0,0 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
export const serverUrl = `http://${serverHost}:${serverPort}`
export const serverName = `${serverHost}:${serverPort}`
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote"
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}
export async function getWorktree() {
const sdk = createSdk()
const result = await sdk.path.get()
const data = result.data
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
return data.worktree
}
export function dirSlug(directory: string) {
return base64Encode(directory)
}
export function dirPath(directory: string) {
return `/${dirSlug(directory)}`
}
export function sessionPath(directory: string, sessionID?: string) {
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
}

View File

@@ -4,10 +4,10 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v2.svg" />
<link rel="shortcut icon" href="/favicon-v2.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.26",
"version": "1.1.25",
"description": "",
"type": "module",
"exports": {
@@ -12,16 +12,11 @@
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"test": "playwright test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report"
"serve": "vite preview"
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "1.57.0",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",

View File

@@ -1,43 +0,0 @@
import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
export default defineConfig({
testDir: "./e2e",
outputDir: "./e2e/test-results",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,
url: baseURL,
reuseExistingServer: reuse,
timeout: 120_000,
env: {
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
},
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
})

View File

@@ -1 +0,0 @@
../../ui/src/assets/favicon/apple-touch-icon-v2.png

View File

@@ -1 +0,0 @@
../../ui/src/assets/favicon/favicon-96x96-v2.png

View File

@@ -1 +0,0 @@
../../ui/src/assets/favicon/favicon-v2.ico

View File

@@ -1 +0,0 @@
../../ui/src/assets/favicon/favicon-v2.svg

View File

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

View File

@@ -22,20 +22,16 @@ export function DialogEditProject(props: { project: LocalProject }) {
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.override || "",
iconUrl: props.project.icon?.url || "",
saving: false,
})
const [dragOver, setDragOver] = createSignal(false)
const [iconHover, setIconHover] = createSignal(false)
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
setStore("iconUrl", e.target?.result as string)
setIconHover(false)
}
reader.onload = (e) => setStore("iconUrl", e.target?.result as string)
reader.readAsDataURL(file)
}
@@ -74,15 +70,15 @@ export function DialogEditProject(props: { project: LocalProject }) {
await globalSDK.client.project.update({
projectID: props.project.id,
name,
icon: { color: store.color, override: store.iconUrl },
icon: { color: store.color, url: store.iconUrl },
})
setStore("saving", false)
dialog.close()
}
return (
<Dialog title="Edit project" class="w-full max-w-[480px] mx-auto">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6">
<Dialog title="Edit project">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
<div class="flex flex-col gap-4">
<TextField
autofocus
@@ -96,24 +92,17 @@ export function DialogEditProject(props: { project: LocalProject }) {
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">Icon</label>
<div class="flex gap-3 items-start">
<div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
<div class="relative">
<div
class="relative size-16 rounded-md transition-colors cursor-pointer"
class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
"border-border-base hover:border-border-strong": !dragOver(),
"overflow-hidden": !!store.iconUrl,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
if (store.iconUrl && iconHover()) {
clearIcon()
} else {
document.getElementById("icon-upload")?.click()
}
}}
onClick={() => document.getElementById("icon-upload")?.click()}
>
<Show
when={store.iconUrl}
@@ -130,48 +119,20 @@ export function DialogEditProject(props: { project: LocalProject }) {
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
</Show>
</div>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "64px",
height: "64px",
background: "rgba(0,0,0,0.6)",
"border-radius": "6px",
"z-index": 10,
"pointer-events": "none",
opacity: iconHover() && !store.iconUrl ? 1 : 0,
display: "flex",
"align-items": "center",
"justify-content": "center",
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-invert-base" />
</div>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "64px",
height: "64px",
background: "rgba(0,0,0,0.6)",
"border-radius": "6px",
"z-index": 10,
"pointer-events": "none",
opacity: iconHover() && store.iconUrl ? 1 : 0,
display: "flex",
"align-items": "center",
"justify-content": "center",
}}
>
<Icon name="trash" size="large" class="text-icon-invert-base" />
</div>
<Show when={store.iconUrl}>
<button
type="button"
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover"
onClick={clearIcon}
>
<Icon name="close" class="size-3 text-icon-base" />
</button>
</Show>
</div>
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>Recommended size 128x128px</span>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
<span>Click or drag an image</span>
<span>Recommended: 128x128px</span>
</div>
</div>
</div>
@@ -179,25 +140,20 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Show when={!store.iconUrl}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">Color</label>
<div class="flex gap-1.5">
<div class="flex gap-2">
<For each={AVATAR_COLOR_KEYS}>
{(color) => (
<button
type="button"
class="relative size-8 rounded-md transition-all"
classList={{
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
"ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
store.color === color,
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
store.color !== color,
}}
style={{ background: getAvatarColors(color).background }}
onClick={() => setStore("color", color)}
>
<Avatar
fallback={store.name || defaultName()}
{...getAvatarColors(color)}
class="size-full rounded"
/>
<Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
</button>
)}
</For>

View File

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

View File

@@ -300,8 +300,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
event.stopPropagation()
const items = Array.from(clipboardData.items)
const fileItems = items.filter((item) => item.kind === "file")
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) {
for (const item of imageItems) {
@@ -311,16 +310,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (fileItems.length > 0) {
showToast({
title: "Unsupported paste",
description: "Only images or PDFs can be pasted here.",
})
return
}
const plainText = clipboardData.getData("text/plain") ?? ""
if (!plainText) return
addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
@@ -1066,16 +1056,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let session = info()
if (!session && isNewSession) {
session = await client.session
.create()
.then((x) => x.data ?? undefined)
.catch((err) => {
showToast({
title: "Failed to create session",
description: errorMessage(err),
})
return undefined
})
session = await client.session.create().then((x) => x.data ?? undefined)
if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
if (!session) return

View File

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

View File

@@ -33,6 +33,8 @@ type SessionTabs = {
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
terminalOpened?: boolean
reviewPanelOpened?: boolean
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
@@ -76,11 +78,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
terminal: {
height: 280,
opened: false,
},
review: {
diffStyle: "split" as ReviewDiffStyle,
panelOpened: true,
},
session: {
width: 600,
@@ -172,7 +172,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
prune(keep)
return
}
@@ -208,10 +208,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
})
const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
const usedColors = new Set<AvatarColorKey>()
function pickAvailableColor(used: Set<string>): AvatarColorKey {
const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
function pickAvailableColor(): AvatarColorKey {
const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
return available[Math.floor(Math.random() * available.length)]
}
@@ -222,15 +222,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const metadata = projectID
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
return {
...(metadata ?? {}),
...project,
icon: {
url: metadata?.icon?.url,
override: metadata?.icon?.override,
color: metadata?.icon?.color,
return [
{
...(metadata ?? {}),
...project,
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
},
]
}
function colorize(project: LocalProject) {
if (project.icon?.color) return project
const color = pickAvailableColor()
usedColors.add(color)
project.icon = { ...project.icon, color }
if (project.id) {
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
}
return project
}
const roots = createMemo(() => {
@@ -268,37 +277,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
})
const enriched = createMemo(() => server.projects.list().map(enrich))
const list = createMemo(() => {
const projects = enriched()
return projects.map((project) => {
const color = project.icon?.color ?? colors[project.worktree]
if (!color) return project
const icon = project.icon ? { ...project.icon, color } : { color }
return { ...project, icon }
})
})
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
const used = new Set<string>()
for (const project of projects) {
const color = project.icon?.color ?? colors[project.worktree]
if (color) used.add(color)
}
for (const project of projects) {
if (project.icon?.color) continue
if (colors[project.worktree]) continue
const color = pickAvailableColor(used)
used.add(color)
setColors(project.worktree, color)
if (!project.id) continue
void globalSdk.client.project.update({ projectID: project.id, icon: { color } })
}
})
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize))
onMount(() => {
Promise.all(
@@ -399,31 +379,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
touch(sessionKey)
scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
function setTerminalOpened(next: boolean) {
const current = store.terminal
const current = store.sessionView[sessionKey]
if (!current) {
setStore("terminal", { height: 280, opened: next })
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
return
}
const value = current.opened ?? false
const value = current.terminalOpened ?? false
if (value === next) return
setStore("terminal", "opened", next)
setStore("sessionView", sessionKey, "terminalOpened", next)
}
function setReviewPanelOpened(next: boolean) {
const current = store.review
const current = store.sessionView[sessionKey]
if (!current) {
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
return
}
const value = current.panelOpened ?? true
const value = current.reviewPanelOpened ?? true
if (value === next) return
setStore("review", "panelOpened", next)
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
}
return {
@@ -464,6 +444,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!current) {
setStore("sessionView", sessionKey, {
scroll: {},
terminalOpened: false,
reviewPanelOpened: true,
reviewOpen: open,
})
return

View File

@@ -25,11 +25,11 @@ type TerminalCacheEntry = {
dispose: VoidFunction
}
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, session?: string) {
const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`]
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
Persist.workspace(dir, "terminal", legacy),
Persist.scoped(dir, id, "terminal", [legacy]),
createStore<{
active?: string
all: LocalPTY[]
@@ -43,28 +43,17 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
const parse = (title: string) => {
const match = title.match(/^Terminal (\d+)$/)
if (!match) return
const value = Number(match[1])
if (!Number.isFinite(value) || value <= 0) return
return value
}
const existingTitleNumbers = new Set(
store.all.flatMap((pty) => {
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
if (direct !== undefined) return [direct]
const parsed = parse(pty.title)
if (parsed === undefined) return []
return [parsed]
store.all.map((pty) => {
const match = pty.titleNumber
return match
}),
)
const nextNumber =
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
(number) => !existingTitleNumbers.has(number),
) ?? 1
let nextNumber = 1
while (existingTitleNumbers.has(nextNumber)) {
nextNumber++
}
sdk.client.pty
.create({ title: `Terminal ${nextNumber}` })
@@ -177,8 +166,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const load = (dir: string, session?: string) => {
const key = `${dir}:${WORKSPACE_KEY}`
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -187,7 +176,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createTerminalSession(sdk, dir, session),
value: createTerminalSession(sdk, dir, id),
dispose,
}))
@@ -196,18 +185,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => load(params.dir!, params.id))
const session = createMemo(() => load(params.dir!, params.id))
return {
ready: () => workspace().ready(),
all: () => workspace().all(),
active: () => workspace().active(),
new: () => workspace().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
clone: (id: string) => workspace().clone(id),
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to),
ready: () => session().ready(),
all: () => session().all(),
active: () => session().active(),
new: () => session().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
clone: (id: string) => session().clone(id),
open: (id: string) => session().open(id),
close: (id: string) => session().close(id),
move: (id: string, to: number) => session().move(id, to),
}
},
})

View File

@@ -37,7 +37,7 @@ const platform: Platform = {
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v2.png",
icon: "https://opencode.ai/favicon-96x96.png",
})
notification.onclick = () => {
window.focus()

View File

@@ -28,14 +28,12 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
import { Session } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { createStore, produce, reconcile } from "solid-js/store"
import {
@@ -401,24 +399,7 @@ export default function Layout(props: ParentProps) {
const currentProject = createMemo(() => {
const directory = params.dir ? base64Decode(params.dir) : undefined
if (!directory) return
const projects = layout.projects.list()
const sandbox = projects.find((p) => p.sandboxes?.includes(directory))
if (sandbox) return sandbox
const direct = projects.find((p) => p.worktree === directory)
if (direct) return direct
const [child] = globalSync.child(directory)
const id = child.project
if (!id) return
const meta = globalSync.data.project.find((p) => p.id === id)
const root = meta?.worktree
if (!root) return
return projects.find((p) => p.worktree === root)
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
createEffect(
@@ -540,7 +521,7 @@ export default function Layout(props: ParentProps) {
running: number
}
const prefetchChunk = 600
const prefetchChunk = 200
const prefetchConcurrency = 1
const prefetchPendingLimit = 6
const prefetchToken = { value: 0 }
@@ -925,241 +906,6 @@ export default function Layout(props: ParentProps) {
}
}
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return "Request failed"
}
const deleteWorkspace = async (directory: string) => {
const current = currentProject()
if (!current) return
if (directory === current.worktree) return
const result = await globalSDK.client.worktree
.remove({ directory: current.worktree, worktreeRemoveInput: { directory } })
.then((x) => x.data)
.catch((err) => {
showToast({
title: "Failed to delete workspace",
description: errorMessage(err),
})
return false
})
if (!result) return
layout.projects.close(directory)
layout.projects.open(current.worktree)
if (params.dir && base64Decode(params.dir) === directory) {
navigateToProject(current.worktree)
}
}
const resetWorkspace = async (directory: string) => {
const current = currentProject()
if (!current) return
if (directory === current.worktree) return
const progress = showToast({
persistent: true,
title: "Resetting workspace",
description: "This may take a minute.",
})
const dismiss = () => toaster.dismiss(progress)
const sessions = await globalSDK.client.session
.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [])
const result = await globalSDK.client.worktree
.reset({ directory: current.worktree, worktreeResetInput: { directory } })
.then((x) => x.data)
.catch((err) => {
dismiss()
showToast({
title: "Failed to reset workspace",
description: errorMessage(err),
})
return false
})
if (!result) {
dismiss()
return
}
const archivedAt = Date.now()
await Promise.all(
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
globalSDK.client.session
.update({
sessionID: session.id,
directory: session.directory,
time: { archived: archivedAt },
})
.catch(() => undefined),
),
)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
dismiss()
const href = `/${base64Encode(directory)}/session`
navigate(href)
layout.mobileSidebar.hide()
showToast({
title: "Workspace reset",
description: "Workspace now matches the default branch.",
})
}
function DialogDeleteWorkspace(props: { directory: string }) {
const name = createMemo(() => getFilename(props.directory))
const [data, setData] = createStore({
status: "loading" as "loading" | "ready" | "error",
dirty: false,
})
onMount(() => {
const current = currentProject()
if (!current) {
setData({ status: "error", dirty: false })
return
}
globalSDK.client.file
.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
const dirty = files.length > 0
setData({ status: "ready", dirty })
})
.catch(() => {
setData({ status: "error", dirty: false })
})
})
const handleDelete = async () => {
await deleteWorkspace(props.directory)
dialog.close()
}
const description = () => {
if (data.status === "loading") return "Checking for unmerged changes..."
if (data.status === "error") return "Unable to verify git status."
if (!data.dirty) return "No unmerged changes detected."
return "Unmerged changes detected in this workspace."
}
return (
<Dialog title="Delete workspace" fit>
<div class="flex flex-col gap-4 px-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">Delete workspace "{name()}"?</span>
<span class="text-12-regular text-text-weak">{description()}</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" disabled={data.status === "loading"} onClick={handleDelete}>
Delete workspace
</Button>
</div>
</div>
</Dialog>
)
}
function DialogResetWorkspace(props: { directory: string }) {
const name = createMemo(() => getFilename(props.directory))
const [state, setState] = createStore({
status: "loading" as "loading" | "ready" | "error",
dirty: false,
sessions: [] as Session[],
})
const refresh = async () => {
const sessions = await globalSDK.client.session
.list({ directory: props.directory })
.then((x) => x.data ?? [])
.catch(() => [])
const active = sessions.filter((session) => session.time.archived === undefined)
setState({ sessions: active })
}
onMount(() => {
const current = currentProject()
if (!current) {
setState({ status: "error", dirty: false })
return
}
globalSDK.client.file
.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
const dirty = files.length > 0
setState({ status: "ready", dirty })
void refresh()
})
.catch(() => {
setState({ status: "error", dirty: false })
})
})
const handleReset = () => {
dialog.close()
void resetWorkspace(props.directory)
}
const archivedCount = () => state.sessions.length
const description = () => {
if (state.status === "loading") return "Checking for unmerged changes..."
if (state.status === "error") return "Unable to verify git status."
if (!state.dirty) return "No unmerged changes detected."
return "Unmerged changes detected in this workspace."
}
const archivedLabel = () => {
const count = archivedCount()
if (count === 0) return "No active sessions will be archived."
const label = count === 1 ? "1 session" : `${count} sessions`
return `${label} will be archived.`
}
return (
<Dialog title="Reset workspace" fit>
<div class="flex flex-col gap-4 px-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">Reset workspace "{name()}"?</span>
<span class="text-12-regular text-text-weak">
{description()} {archivedLabel()} This will reset the workspace to match the default branch.
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" disabled={state.status === "loading"} onClick={handleReset}>
Reset workspace
</Button>
</div>
</div>
</Dialog>
)
}
createEffect(
on(
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
@@ -1229,15 +975,11 @@ export default function Layout(props: ParentProps) {
function workspaceIds(project: LocalProject | undefined) {
if (!project) return []
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const active = currentProject()
const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined
const next = directory && directory !== project.worktree && !dirs.includes(directory) ? [...dirs, directory] : dirs
const existing = store.workspaceOrder[project.worktree]
if (!existing) return next
if (!existing) return dirs
const keep = existing.filter((d) => next.includes(d))
const missing = next.filter((d) => !existing.includes(d))
const keep = existing.filter((d) => dirs.includes(d))
const missing = dirs.filter((d) => !existing.includes(d))
return [...keep, ...missing]
}
@@ -1276,7 +1018,7 @@ export default function Layout(props: ParentProps) {
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)"
const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)"
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
@@ -1284,7 +1026,7 @@ export default function Layout(props: ParentProps) {
<div class="size-full rounded overflow-clip">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.override}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
style={
@@ -1297,7 +1039,7 @@ export default function Layout(props: ParentProps) {
<Show when={notifications().length > 0 && props.notify}>
<div
classList={{
"absolute top-px right-px size-1.5 rounded-full z-10": true,
"absolute -top-px -right-px size-2 rounded-full z-10": true,
"bg-icon-critical-base": hasError(),
"bg-text-interactive-base": !hasError(),
}}
@@ -1307,13 +1049,7 @@ export default function Layout(props: ParentProps) {
)
}
const SessionItem = (props: {
session: Session
slug: string
mobile?: boolean
dense?: boolean
popover?: boolean
}): JSX.Element => {
const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => {
const notification = useNotification()
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
@@ -1347,101 +1083,57 @@ export default function Layout(props: ParentProps) {
return agent?.color
})
const hoverMessages = createMemo(() =>
sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
)
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
const messageLabel = (message: Message) => {
const parts = sessionStore.part[message.id] ?? []
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
return text?.text
}
const item = (
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<InlineEditor
id={`session:${props.session.id}`}
value={() => props.session.title}
onSave={(next) => renameSession(props.session, next)}
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
stopPropagation
/>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div>
</A>
)
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<Show
when={hoverEnabled()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
{item}
</Tooltip>
}
>
<HoverCard openDelay={150} closeDelay={100} placement="right" gutter={12} trigger={item}>
<Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages</div>}>
<MessageNav
messages={hoverMessages() ?? []}
current={undefined}
getLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive()) {
sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
navigate(`${props.slug}/session/${props.session.id}`)
return
}
window.history.replaceState(null, "", `#message-${message.id}`)
window.dispatchEvent(new HashChangeEvent("hashchange"))
}}
size="normal"
class="w-60"
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={16} openDelay={1000}>
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<InlineEditor
id={`session:${props.session.id}`}
value={() => props.session.title}
onSave={(next) => renameSession(props.session, next)}
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
stopPropagation
/>
</Show>
</HoverCard>
</Show>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div>
</A>
</Tooltip>
<div
class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
>
@@ -1507,7 +1199,6 @@ export default function Layout(props: ParentProps) {
const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
const [menuOpen, setMenuOpen] = createSignal(false)
const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() =>
workspaceStore.session
@@ -1542,91 +1233,62 @@ export default function Layout(props: ParentProps) {
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
<div class="px-2 py-1">
<div class="group/workspace relative">
<div class="flex items-center gap-1">
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center justify-center shrink-0 size-6">
<Icon name="branch" size="small" />
</div>
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
<Show
when={!local()}
fallback={
<span class="text-14-medium text-text-base min-w-0 truncate">
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
</span>
}
>
<InlineEditor
id={`workspace:${props.directory}`}
value={workspaceValue}
onSave={(next) => {
const trimmed = next.trim()
if (!trimmed) return
renameWorkspace(props.directory, trimmed)
setEditor("value", workspaceValue())
}}
class="text-14-medium text-text-base min-w-0 truncate"
displayClass="text-14-medium text-text-base min-w-0 truncate"
editing={workspaceEditActive()}
stopPropagation={false}
openOnDblClick={false}
/>
</Show>
<Icon
name={open() ? "chevron-down" : "chevron-right"}
size="small"
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
/>
<div class="group/trigger relative">
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center justify-center shrink-0 size-6">
<Icon name="branch" size="small" />
</div>
</Collapsible.Trigger>
<div
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
classList={{
"opacity-100 pointer-events-auto": menuOpen(),
"opacity-0 pointer-events-none": !menuOpen(),
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
}}
>
<DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
<Tooltip value="More options" placement="top">
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" class="size-6 rounded-md" />
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
<DropdownMenu.ItemLabel>New session</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={local()}
onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
>
<DropdownMenu.ItemLabel>Reset workspace</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={local()}
onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
>
<DropdownMenu.ItemLabel>Delete workspace</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<TooltipKeybind placement="right" title="New session" keybind={command.keybind("session.new")}>
<IconButton
icon="plus-small"
variant="ghost"
class="size-6 rounded-md"
onClick={() => navigate(`/${slug()}/session`)}
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
<Show
when={!local()}
fallback={
<span class="text-14-medium text-text-base min-w-0 truncate">
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
</span>
}
>
<InlineEditor
id={`workspace:${props.directory}`}
value={workspaceValue}
onSave={(next) => {
const trimmed = next.trim()
if (!trimmed) return
renameWorkspace(props.directory, trimmed)
setEditor("value", workspaceValue())
}}
class="text-14-medium text-text-base min-w-0 truncate"
displayClass="text-14-medium text-text-base min-w-0 truncate"
editing={workspaceEditActive()}
stopPropagation={false}
openOnDblClick={false}
/>
</TooltipKeybind>
</Show>
<Icon
name={open() ? "chevron-down" : "chevron-right"}
size="small"
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/trigger:opacity-100 group-focus-within/trigger:opacity-100"
/>
</div>
</Collapsible.Trigger>
<div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
<TooltipKeybind
class="pointer-events-auto"
placement="right"
title="New session"
keybind={command.keybind("session.new")}
>
<IconButton
icon="plus-small"
variant="ghost"
class="size-6 rounded-md"
onClick={() => navigate(`/${slug()}/session`)}
/>
</TooltipKeybind>
</div>
</div>
</div>
<Collapsible.Content>
<nav class="flex flex-col gap-1 px-2">
<Button
@@ -1676,8 +1338,6 @@ export default function Layout(props: ParentProps) {
const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)())
const [open, setOpen] = createSignal(false)
const label = (directory: string) => {
const [data] = globalSync.child(directory)
const kind = directory === props.project.worktree ? "local" : "sandbox"
@@ -1710,8 +1370,7 @@ export default function Layout(props: ParentProps) {
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!selected() && !open(),
"bg-surface-base-hover border border-border-weak-base": !selected() && open(),
!selected(),
}}
onClick={() => navigateToProject(props.project.worktree)}
>
@@ -1722,17 +1381,9 @@ export default function Layout(props: ParentProps) {
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<HoverCard
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={6}
trigger={trigger}
onOpenChange={setOpen}
>
<HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={6} trigger={trigger}>
<div class="-m-3 flex flex-col w-72">
<div class="px-4 pt-2 pb-1 text-14-medium text-text-strong truncate">{displayName(props.project)}</div>
<div class="px-4 pb-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-2 pb-2 flex flex-col gap-2">
<Show
when={workspaceEnabled()}
@@ -1744,7 +1395,6 @@ export default function Layout(props: ParentProps) {
slug={base64Encode(props.project.worktree)}
dense
mobile={props.mobile}
popover={false}
/>
)}
</For>
@@ -1761,13 +1411,7 @@ export default function Layout(props: ParentProps) {
</div>
<For each={sessions(directory)}>
{(session) => (
<SessionItem
session={session}
slug={base64Encode(directory)}
dense
mobile={props.mobile}
popover={false}
/>
<SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} />
)}
</For>
</div>
@@ -1859,6 +1503,15 @@ export default function Layout(props: ParentProps) {
const projectId = createMemo(() => project()?.id ?? "")
const workspaces = createMemo(() => workspaceIds(project()))
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return "Request failed"
}
const createWorkspace = async () => {
const current = project()
if (!current) return
@@ -1920,7 +1573,7 @@ export default function Layout(props: ParentProps) {
</DragDropProvider>
</div>
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings" class="hidden">
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings">
<IconButton disabled icon="settings-gear" variant="ghost" size="large" />
</Tooltip>
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
@@ -1957,16 +1610,7 @@ export default function Layout(props: ParentProps) {
stopPropagation
/>
<Tooltip
placement={sidebarProps.mobile ? "bottom" : "top"}
gutter={2}
value={project()?.worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<Tooltip placement="right" value={project()?.worktree} class="shrink-0">
<span class="text-12-regular text-text-base truncate">
{project()?.worktree.replace(homedir(), "~")}
</span>
@@ -2008,7 +1652,7 @@ export default function Layout(props: ParentProps) {
<Button
size="large"
icon="plus-small"
class="w-full"
class="w-full max-w-[256px]"
onClick={() => {
navigate(`/${base64Encode(p.worktree)}/session`)
layout.mobileSidebar.hide()
@@ -2025,7 +1669,7 @@ export default function Layout(props: ParentProps) {
>
<>
<div class="py-4 px-3">
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
<Button size="large" icon="plus-small" class="w-full max-w-[256px]" onClick={createWorkspace}>
New workspace
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
@@ -18,7 +18,7 @@ import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { Mark } from "@opencode-ai/ui/logo"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
@@ -167,7 +167,6 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const permission = usePermission()
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
@@ -531,9 +530,13 @@ export default function Page() {
title: "Cycle thinking effort",
description: "Switch to the next effort level",
category: "Model",
keybind: "shift+mod+d",
keybind: "shift+mod+t",
onSelect: () => {
local.model.variant.cycle()
showToast({
title: "Thinking effort changed",
description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"),
})
},
},
{
@@ -651,72 +654,6 @@ export default function Page() {
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
...(sync.data.config.share !== "disabled"
? [
{
id: "session.share",
title: "Share session",
description: "Share this session and copy the URL to clipboard",
category: "Session",
slash: "share",
disabled: !params.id || !!info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.share({ sessionID: params.id })
.then((res) => {
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
showToast({
title: "Failed to copy URL to clipboard",
variant: "error",
}),
)
})
.then(() =>
showToast({
title: "Session shared",
description: "Share URL copied to clipboard!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to share session",
description: "An error occurred while sharing the session",
variant: "error",
}),
)
},
},
{
id: "session.unshare",
title: "Unshare session",
description: "Stop sharing this session",
category: "Session",
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: "Session unshared",
description: "Session unshared successfully!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to unshare session",
description: "An error occurred while unsharing the session",
variant: "error",
}),
)
},
},
]
: []),
])
const handleKeyDown = (event: KeyboardEvent) => {
@@ -789,14 +726,17 @@ export default function Page() {
.filter((tab) => tab !== "context"),
)
const mobileReview = createMemo(() => !isDesktop() && view().reviewPanel.opened() && store.mobileTab === "review")
const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
const showTabs = createMemo(() => view().reviewPanel.opened())
const showTabs = createMemo(
() => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()),
)
const activeTab = createMemo(() => {
const active = tabs().active()
if (active) return active
if (hasReview()) return "review"
if (reviewTab()) return "review"
const first = openedTabs()[0]
if (first) return first
@@ -944,30 +884,17 @@ export default function Page() {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
createEffect(() => {
const sessionID = params.id
if (!sessionID) return
const raw = sessionStorage.getItem("opencode.pendingMessage")
if (!raw) return
const parts = raw.split("|")
const pendingSessionID = parts[0]
const messageID = parts[1]
if (!pendingSessionID || !messageID) return
if (pendingSessionID !== sessionID) return
sessionStorage.removeItem("opencode.pendingMessage")
setPendingMessage(messageID)
})
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
if (!root) return false
if (!root) {
el.scrollIntoView({ behavior, block: "start" })
return
}
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
@@ -981,15 +908,7 @@ export default function Page() {
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (!el) {
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
scrollToElement(next, behavior)
})
return
}
scrollToElement(el, behavior)
if (el) scrollToElement(el, behavior)
})
updateHash(message.id)
@@ -997,57 +916,10 @@ export default function Page() {
}
const el = document.getElementById(anchor(message.id))
if (!el) {
updateHash(message.id)
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
return
}
if (scrollToElement(el, behavior)) {
updateHash(message.id)
return
}
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
if (el) scrollToElement(el, behavior)
updateHash(message.id)
}
const applyHash = (behavior: ScrollBehavior) => {
const hash = window.location.hash.slice(1)
if (!hash) {
autoScroll.forceScrollToBottom()
return
}
const match = hash.match(/^message-(.+)$/)
if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) {
scrollToMessage(msg, behavior)
return
}
// If we have a message hash but the message isn't loaded/rendered yet,
// don't fall back to "bottom". We'll retry once messages arrive.
return
}
const target = document.getElementById(hash)
if (target) {
scrollToElement(target, behavior)
return
}
autoScroll.forceScrollToBottom()
}
const getActiveMessageId = (container: HTMLDivElement) => {
const cutoff = container.scrollTop + 100
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
@@ -1088,47 +960,31 @@ export default function Page() {
if (!sessionID || !ready) return
requestAnimationFrame(() => {
applyHash("auto")
const hash = window.location.hash.slice(1)
if (!hash) {
autoScroll.forceScrollToBottom()
return
}
const hashTarget = document.getElementById(hash)
if (hashTarget) {
scrollToElement(hashTarget, "auto")
return
}
const match = hash.match(/^message-(.+)$/)
if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) {
scrollToMessage(msg, "auto")
return
}
}
autoScroll.forceScrollToBottom()
})
})
// Retry message navigation once the target message is actually loaded.
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
// dependencies
visibleUserMessages().length
store.turnStart
const targetId =
pendingMessage() ??
(() => {
const hash = window.location.hash.slice(1)
const match = hash.match(/^message-(.+)$/)
if (!match) return undefined
return match[1]
})()
if (!targetId) return
if (store.messageId === targetId) return
const msg = visibleUserMessages().find((m) => m.id === targetId)
if (!msg) return
if (pendingMessage() === targetId) setPendingMessage(undefined)
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
const handler = () => requestAnimationFrame(() => applyHash("auto"))
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})
createEffect(() => {
document.addEventListener("keydown", handleKeyDown)
})
@@ -1178,8 +1034,8 @@ export default function Page() {
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
{/* Mobile tab bar - only shown on mobile when user opened review */}
<Show when={!isDesktop() && view().reviewPanel.opened()}>
{/* Mobile tab bar - only shown on mobile when there are diffs */}
<Show when={!isDesktop() && hasReview()}>
<Tabs class="h-auto">
<Tabs.List>
<Tabs.Trigger
@@ -1196,10 +1052,7 @@ export default function Page() {
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "review")}
>
<Switch>
<Match when={hasReview()}>{reviewCount()} Files Changed</Match>
<Match when={true}>Review</Match>
</Switch>
{reviewCount()} Files Changed
</Tabs.Trigger>
</Tabs.List>
</Tabs>
@@ -1224,40 +1077,41 @@ export default function Page() {
when={!mobileReview()}
fallback={
<div class="relative h-full overflow-hidden">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle="unified"
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
classes={{
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4",
container: "px-4",
}}
/>
</Show>
</Match>
<Match when={true}>
<div class="px-4 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
<Mark class="w-6 opacity-40" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
</div>
</Match>
</Switch>
<Show
when={diffsReady()}
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle="unified"
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
classes={{
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4",
container: "px-4",
}}
/>
</Show>
</div>
}
>
<div class="relative w-full h-full min-w-0">
<Show when={isDesktop()}>
<div class="absolute inset-0 pointer-events-none z-10">
<SessionMessageRail
messages={visibleUserMessages()}
current={activeMessage()}
onMessageSelect={scrollToMessage}
wide={!showTabs()}
class="pointer-events-auto"
/>
</div>
</Show>
<div
ref={setScrollRef}
onScroll={(e) => {
@@ -1266,29 +1120,11 @@ export default function Page() {
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
>
<Show when={info()?.title}>
<div
classList={{
"sticky top-0 z-30 bg-background-stronger": true,
"w-full": true,
"px-4 md:px-6": true,
"md:max-w-200 md:mx-auto": !showTabs(),
}}
>
<div class="h-10 flex items-center">
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
</div>
</div>
</Show>
<div
ref={autoScroll.contentRef}
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={{
"w-full": true,
"md:max-w-200 md:mx-auto": !showTabs(),
"mt-0.5": !showTabs(),
"mt-0": showTabs(),
}}
@@ -1339,7 +1175,6 @@ export default function Page() {
data-message-id={message.id}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200": !showTabs(),
"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",
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
@@ -1356,8 +1191,15 @@ export default function Page() {
}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container:
"px-4 md:px-6 " +
(!showTabs()
? "md:max-w-200 md:mx-auto"
: visibleUserMessages().length > 1
? "md:pr-6 md:pl-18"
: ""),
}}
/>
</div>
@@ -1395,7 +1237,7 @@ export default function Page() {
{/* Prompt input */}
<div
ref={(el) => (promptDock = el)}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
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
classList={{
@@ -1447,7 +1289,7 @@ export default function Page() {
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Show when={true}>
<Show when={reviewTab()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-3">
<Show when={diffs()}>
@@ -1500,36 +1342,26 @@ export default function Page() {
</div>
</Tabs.List>
</div>
<Show when={true}>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/>
</Show>
</Match>
<Match when={true}>
<div class="px-6 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
<Mark class="w-6 opacity-40" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
</div>
</Match>
</Switch>
<Show
when={diffsReady()}
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/>
</Show>
</div>
</Show>
</Tabs.Content>

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.26",
"version": "1.1.25",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.26",
"version": "1.1.25",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -218,7 +218,6 @@ export namespace Billing {
customer: customer.customerID,
customer_update: {
name: "auto",
address: "auto",
},
}
: {

View File

@@ -1,20 +0,0 @@
import { describe, expect, test } from "bun:test"
import { getWeekBounds } from "./date"
describe("util.date.getWeekBounds", () => {
test("returns a Monday-based week for Sunday dates", () => {
const date = new Date("2026-01-18T12:00:00Z")
const bounds = getWeekBounds(date)
expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z")
expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z")
})
test("returns a seven day window", () => {
const date = new Date("2026-01-14T12:00:00Z")
const bounds = getWeekBounds(date)
const span = bounds.end.getTime() - bounds.start.getTime()
expect(span).toBe(7 * 24 * 60 * 60 * 1000)
})
})

View File

@@ -1,7 +1,7 @@
export function getWeekBounds(date: Date) {
const offset = (date.getUTCDay() + 6) % 7
const dayOfWeek = date.getUTCDay()
const start = new Date(date)
start.setUTCDate(date.getUTCDate() - offset)
start.setUTCDate(date.getUTCDate() - dayOfWeek + 1)
start.setUTCHours(0, 0, 0, 0)
const end = new Date(start)
end.setUTCDate(start.getUTCDate() + 7)

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.26",
"version": "1.1.25",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -35,7 +35,7 @@ export const subjects = createSubjects({
const MY_THEME: Theme = {
...THEME_OPENAUTH,
logo: "https://opencode.ai/favicon-v2.svg",
logo: "https://opencode.ai/favicon.svg",
}
export default {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.26",
"version": "1.1.25",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -4,10 +4,10 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v2.svg" />
<link rel="shortcut icon" href="/favicon-v2.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.26",
"version": "1.1.25",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -12,7 +12,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Splash } from "@opencode-ai/ui/logo"
import { Logo } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
@@ -26,16 +26,17 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
)
}
// Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements).
// This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows.
const originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => {
if (!(elt instanceof Element)) {
// Fall back to a safe element when a non-element is passed.
return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined)
}
return originalGetComputedStyle(elt, pseudoElt ?? undefined)
}) as typeof window.getComputedStyle
const isWindows = ostype() === "windows"
if (isWindows) {
const originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => {
if (!(elt instanceof Element)) {
// WebView2 can call into Floating UI with non-elements; fall back to a safe element.
return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined)
}
return originalGetComputedStyle(elt, pseudoElt ?? undefined)
}) as typeof window.getComputedStyle
}
let update: Update | null = null
@@ -253,7 +254,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v2.png",
icon: "https://opencode.ai/favicon-96x96.png",
})
notification.onclick = () => {
const win = getCurrentWindow()
@@ -356,7 +357,8 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
<Logo class="w-xl opacity-12 animate-pulse" />
<div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
</div>
}
>

View File

@@ -7,7 +7,7 @@
"light": "#07C983",
"dark": "#15803D"
},
"favicon": "/favicon-v2.svg",
"favicon": "/favicon.svg",
"navigation": {
"tabs": [
{

View File

@@ -1,19 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.06145 23.1079C5.26816 22.3769 -3.39077 20.6274 1.4173 5.06384C9.6344 6.09939 16.9728 14.0644 9.06145 23.1079Z" fill="url(#paint0_linear_17557_2021)"/>
<path d="M8.91928 23.0939C5.27642 21.2223 0.78371 4.20891 17.0071 0C20.7569 7.19341 19.6212 16.5452 8.91928 23.0939Z" fill="url(#paint1_linear_17557_2021)"/>
<path d="M8.91388 23.0788C8.73534 19.8817 10.1585 9.08525 23.5699 13.1107C23.1812 20.1229 18.984 26.4182 8.91388 23.0788Z" fill="url(#paint2_linear_17557_2021)"/>
<defs>
<linearGradient id="paint0_linear_17557_2021" x1="3.77557" y1="5.91571" x2="5.23185" y2="21.5589" gradientUnits="userSpaceOnUse">
<stop stop-color="#18E299"/>
<stop offset="1" stop-color="#15803D"/>
</linearGradient>
<linearGradient id="paint1_linear_17557_2021" x1="12.1711" y1="-0.718425" x2="10.1897" y2="22.9832" gradientUnits="userSpaceOnUse">
<stop stop-color="#16A34A"/>
<stop offset="1" stop-color="#4ADE80"/>
</linearGradient>
<linearGradient id="paint2_linear_17557_2021" x1="23.1327" y1="15.353" x2="9.33841" y2="18.5196" gradientUnits="userSpaceOnUse">
<stop stop-color="#4ADE80"/>
<stop offset="1" stop-color="#0D9373"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.26",
"version": "1.1.25",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -16,6 +16,7 @@ import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary"
import { NamedError } from "@opencode-ai/util/error"
import { DateTime } from "luxon"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { createStore } from "solid-js/store"
import z from "zod"
import NotFound from "../[...404]"
@@ -295,13 +296,13 @@ export default function () {
{(message) => (
<SessionTurn
sessionID={data().sessionID}
sessionTitle={info().title}
messageID={message.id}
stepsExpanded={store.expandedSteps[message.id] ?? false}
onStepsExpandedToggle={() => setStore("expandedSteps", message.id, (v) => !v)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container: "px-4",
}}
/>
@@ -352,16 +353,26 @@ export default function () {
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"mx-auto max-w-200": !wide(),
}}
>
<div
classList={{
"w-full flex justify-start items-start min-w-0 px-6": true,
"w-full flex justify-start items-start min-w-0": true,
"max-w-200 mx-auto px-6": wide(),
"pr-6 pl-18": !wide() && messages().length > 1,
"px-6": !wide() && messages().length === 1,
}}
>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
@@ -375,7 +386,13 @@ export default function () {
classes={{
root: "grow",
content: "flex flex-col justify-between",
container: "w-full pb-20 px-6",
container:
"w-full pb-20 " +
(wide()
? "max-w-200 mx-auto px-6"
: messages().length > 1
? "pr-6 pl-18"
: "px-6"),
}}
>
<div

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.26"
version = "1.1.25"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.26",
"version": "1.1.25",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.26",
"version": "1.1.25",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -70,7 +70,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.2",
"@gitlab/gitlab-ai-provider": "3.1.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",

View File

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

View File

@@ -1,50 +0,0 @@
const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano"
const parts = model.split("/")
const providerID = parts[0] ?? "opencode"
const modelID = parts[1] ?? "gpt-5-nano"
const now = Date.now()
const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Session } = await import("../src/session")
const { Identifier } = await import("../src/id/id")
const { Project } = await import("../src/project/project")
await Instance.provide({
directory: dir,
init: InstanceBootstrap,
fn: async () => {
const session = await Session.create({ title })
const messageID = Identifier.descending("message")
const partID = Identifier.descending("part")
const message = {
id: messageID,
sessionID: session.id,
role: "user" as const,
time: { created: now },
agent: "build",
model: {
providerID,
modelID,
},
}
const part = {
id: partID,
sessionID: session.id,
messageID,
type: "text" as const,
text,
time: { start: now },
}
await Session.updateMessage(message)
await Session.updatePart(part)
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
},
})
}
await seed()

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
import { Config } from "../config/config"
import z from "zod"
import { Provider } from "../provider/provider"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { generateObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncation"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform"
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
@@ -278,12 +276,10 @@ export namespace Agent {
const defaultModel = input.model ?? (await Provider.defaultModel())
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const language = await Provider.getLanguage(model)
const system = SystemPrompt.header(defaultModel.providerID)
system.push(PROMPT_GENERATE)
const existing = await list()
const params = {
const result = await generateObject({
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
@@ -309,24 +305,7 @@ export namespace Agent {
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(model, {
instructions: SystemPrompt.instructions(),
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
}
const result = await generateObject(params)
})
return result.object
}
}

View File

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

View File

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

View File

@@ -85,7 +85,6 @@ export function Autocomplete(props: {
index: 0,
selected: 0,
visible: false as AutocompleteRef["visible"],
input: "keyboard" as "keyboard" | "mouse",
})
const [positionTick, setPositionTick] = createSignal(0)
@@ -129,14 +128,6 @@ export function Autocomplete(props: {
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
})
// When the filter changes due to how TUI works, the mousemove might still be triggered
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
// that the mouseover event doesn't trigger when filtering.
createEffect(() => {
filter()
setStore("input", "keyboard")
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
const input = props.input()
const currentCursorOffset = input.cursorOffset
@@ -341,15 +332,16 @@ export function Autocomplete(props: {
)
})
const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined))
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...command.slashes()]
for (const serverCommand of sync.data.command) {
const results: AutocompleteOption[] = []
const s = session()
for (const command of sync.data.command) {
results.push({
display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""),
description: serverCommand.description,
display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
description: command.description,
onSelect: () => {
const newText = "/" + serverCommand.name + " "
const newText = "/" + command.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
@@ -357,9 +349,138 @@ export function Autocomplete(props: {
},
})
}
if (s) {
results.push(
{
display: "/undo",
description: "undo the last message",
onSelect: () => {
command.trigger("session.undo")
},
},
{
display: "/redo",
description: "redo the last message",
onSelect: () => command.trigger("session.redo"),
},
{
display: "/compact",
aliases: ["/summarize"],
description: "compact the session",
onSelect: () => command.trigger("session.compact"),
},
{
display: "/unshare",
disabled: !s.share,
description: "unshare a session",
onSelect: () => command.trigger("session.unshare"),
},
{
display: "/rename",
description: "rename session",
onSelect: () => command.trigger("session.rename"),
},
{
display: "/copy",
description: "copy session transcript to clipboard",
onSelect: () => command.trigger("session.copy"),
},
{
display: "/export",
description: "export session transcript to file",
onSelect: () => command.trigger("session.export"),
},
{
display: "/timeline",
description: "jump to message",
onSelect: () => command.trigger("session.timeline"),
},
{
display: "/fork",
description: "fork from message",
onSelect: () => command.trigger("session.fork"),
},
{
display: "/thinking",
description: "toggle thinking visibility",
onSelect: () => command.trigger("session.toggle.thinking"),
},
)
if (sync.data.config.share !== "disabled") {
results.push({
display: "/share",
disabled: !!s.share?.url,
description: "share a session",
onSelect: () => command.trigger("session.share"),
})
}
}
results.sort((a, b) => a.display.localeCompare(b.display))
results.push(
{
display: "/new",
aliases: ["/clear"],
description: "create a new session",
onSelect: () => command.trigger("session.new"),
},
{
display: "/models",
description: "list models",
onSelect: () => command.trigger("model.list"),
},
{
display: "/agents",
description: "list agents",
onSelect: () => command.trigger("agent.list"),
},
{
display: "/session",
aliases: ["/resume", "/continue"],
description: "list sessions",
onSelect: () => command.trigger("session.list"),
},
{
display: "/status",
description: "show status",
onSelect: () => command.trigger("opencode.status"),
},
{
display: "/mcp",
description: "toggle MCPs",
onSelect: () => command.trigger("mcp.list"),
},
{
display: "/theme",
description: "toggle theme",
onSelect: () => command.trigger("theme.switch"),
},
{
display: "/editor",
description: "open editor",
onSelect: () => command.trigger("prompt.editor", "prompt"),
},
{
display: "/connect",
description: "connect to a provider",
onSelect: () => command.trigger("provider.connect"),
},
{
display: "/help",
description: "show help",
onSelect: () => command.trigger("help.show"),
},
{
display: "/commands",
description: "show all commands",
onSelect: () => command.show(),
},
{
display: "/exit",
aliases: ["/quit", "/q"],
description: "exit the app",
onSelect: () => command.trigger("app.exit"),
},
)
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
if (!max) return results
return results.map((item) => ({
@@ -373,8 +494,9 @@ export function Autocomplete(props: {
const agentsValue = agents()
const commandsValue = commands()
const mixed: AutocompleteOption[] =
const mixed: AutocompleteOption[] = (
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
).filter((x) => x.disabled !== true)
const currentFilter = filter()
@@ -534,13 +656,11 @@ export function Autocomplete(props: {
const isNavDown = name === "down" || (ctrlOnly && name === "n")
if (isNavUp) {
setStore("input", "keyboard")
move(-1)
e.preventDefault()
return
}
if (isNavDown) {
setStore("input", "keyboard")
move(1)
e.preventDefault()
return
@@ -623,17 +743,7 @@ export function Autocomplete(props: {
paddingRight={1}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseOver={() => {
if (store.input !== "mouse") return
moveTo(index)
}}
onMouseDown={() => {
setStore("input", "mouse")
moveTo(index)
}}
onMouseOver={() => moveTo(index)}
onMouseUp={() => select()}
>
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>

View File

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

View File

@@ -106,7 +106,7 @@ const TIPS = [
"Use plugins to send OS notifications when sessions complete",
"Create a plugin to prevent OpenCode from reading sensitive files",
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
"Use {highlight}opencode --continue{/highlight} to resume the last session",
"Use {highlight}opencode run --continue{/highlight} to resume the last session",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode",

View File

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

View File

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

View File

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

View File

@@ -280,7 +280,6 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
reply: "reject",
requestID: props.request.id,
})
return
}
sdk.client.permission.reply({
reply: "once",
@@ -457,11 +456,6 @@ function Prompt<const T extends Record<string, string>>(props: {
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
setStore("selected", option)
props.onSelect(option)
}}
>
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
{props.options[option]}

View File

@@ -38,7 +38,7 @@ export interface DialogSelectOption<T = any> {
disabled?: boolean
bg?: RGBA
gutter?: JSX.Element
onSelect?: (ctx: DialogContext) => void
onSelect?: (ctx: DialogContext, trigger?: "prompt") => void
}
export type DialogSelectRef<T> = {
@@ -52,7 +52,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({
selected: 0,
filter: "",
input: "keyboard" as "keyboard" | "mouse",
})
createEffect(
@@ -84,14 +83,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return result
})
// When the filter changes due to how TUI works, the mousemove might still be triggered
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard
// that the mouseover event doesn't trigger when filtering.
createEffect(() => {
filtered()
setStore("input", "keyboard")
})
const grouped = createMemo(() => {
const result = pipe(
filtered(),
@@ -166,15 +157,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const keybind = useKeybind()
useKeyboard((evt) => {
setStore("input", "keyboard")
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0)
if (evt.name === "end") moveTo(flat().length - 1)
if (evt.name === "return") {
const option = selected()
if (option) {
@@ -271,20 +259,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box
id={JSON.stringify(option.value)}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseUp={() => {
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
if (store.input !== "mouse") return
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
onMouseDown={() => {
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
@@ -358,7 +337,6 @@ function Option(props: {
fg={props.active ? fg : props.current ? theme.primary : theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
paddingLeft={3}
>
{Locale.truncate(props.title, 61)}

View File

@@ -125,25 +125,9 @@ export namespace Clipboard {
if (os === "win32") {
console.log("clipboard: using powershell")
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Bun.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
// need to escape backticks because powershell uses them as escape code
const escaped = text.replace(/"/g, '""').replace(/`/g, "``")
await $`powershell -NonInteractive -NoProfile -Command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet()
}
}

View File

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

View File

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

View File

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

View File

@@ -162,32 +162,34 @@ export namespace Ripgrep {
})
}
if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
const entries = await zipFileReader.getEntries()
let rgEntry: any
for (const entry of entries) {
if (entry.filename.endsWith("rg.exe")) {
rgEntry = entry
break
if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
const entries = await zipFileReader.getEntries()
let rgEntry: any
for (const entry of entries) {
if (entry.filename.endsWith("rg.exe")) {
rgEntry = entry
break
}
}
}
if (!rgEntry) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "rg.exe not found in zip archive",
})
}
if (!rgEntry) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "rg.exe not found in zip archive",
})
}
const rgBlob = await rgEntry.getData(new BlobWriter())
if (!rgBlob) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "Failed to extract rg.exe from zip archive",
})
const rgBlob = await rgEntry.getData(new BlobWriter())
if (!rgBlob) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "Failed to extract rg.exe from zip archive",
})
}
await Bun.write(filepath, await rgBlob.arrayBuffer())
await zipFileReader.close()
}
await Bun.write(filepath, await rgBlob.arrayBuffer())
await zipFileReader.close()
}
await fs.unlink(archivePath)
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)

View File

@@ -226,7 +226,7 @@ export const rlang: Info = {
}
export const uvformat: Info = {
name: "uv",
name: "uv format",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
@@ -337,6 +337,23 @@ export const rustfmt: Info = {
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
return Bun.which("rustfmt") !== null
if (!Bun.which("rustfmt")) return false
const configs = ["rustfmt.toml", ".rustfmt.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) return true
}
return false
},
}
export const cargofmt: Info = {
name: "cargofmt",
command: ["cargo", "fmt", "--", "$FILE"],
extensions: [".rs"],
async enabled() {
if (!Bun.which("cargo")) return false
const found = await Filesystem.findUp("Cargo.toml", Instance.directory, Instance.worktree)
return found.length > 0
},
}

View File

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

View File

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

View File

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

View File

@@ -11,8 +11,6 @@ import { Instance } from "./instance"
import { Vcs } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
@@ -24,8 +22,6 @@ export async function InstanceBootstrap() {
FileWatcher.init()
File.init()
Vcs.init()
Snapshot.init()
Truncate.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {

View File

@@ -25,7 +25,6 @@ export namespace Project {
icon: z
.object({
url: z.string().optional(),
override: z.string().optional(),
color: z.string().optional(),
})
.optional(),
@@ -191,7 +190,6 @@ export namespace Project {
if (!existing.sandboxes) existing.sandboxes = []
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const result: Info = {
...existing,
worktree,
@@ -215,7 +213,6 @@ export namespace Project {
export async function discover(input: Info) {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
const matches = await Array.fromAsync(
@@ -296,7 +293,6 @@ export namespace Project {
...draft.icon,
}
if (input.icon.url !== undefined) draft.icon.url = input.icon.url
if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined
if (input.icon.color !== undefined) draft.icon.color = input.icon.color
}
draft.time.updated = Date.now()
@@ -321,19 +317,4 @@ export namespace Project {
}
return valid
}
export async function removeSandbox(projectID: string, directory: string) {
const result = await Storage.update<Info>(["project", projectID], (draft) => {
const sandboxes = draft.sandboxes ?? []
draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory)
draft.time.updated = Date.now()
})
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
return result
}
}

View File

@@ -41,18 +41,6 @@ import { ProviderTransform } from "./transform"
export namespace Provider {
const log = Log.create({ service: "provider" })
function isGpt5OrLater(modelID: string): boolean {
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) {
return false
}
return Number(match[1]) >= 5
}
function shouldUseCopilotResponsesApi(modelID: string): boolean {
return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
}
const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
"@ai-sdk/amazon-bedrock": createAmazonBedrock,
"@ai-sdk/anthropic": createAnthropic,
@@ -132,7 +120,10 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
if (modelID.includes("codex")) {
return sdk.responses(modelID)
}
return sdk.chat(modelID)
},
options: {},
}
@@ -141,7 +132,10 @@ export namespace Provider {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
if (modelID.includes("codex")) {
return sdk.responses(modelID)
}
return sdk.chat(modelID)
},
options: {},
}
@@ -601,10 +595,7 @@ export namespace Provider {
api: {
id: model.id,
url: provider.api!,
npm: iife(() => {
if (provider.id.startsWith("github-copilot")) return "@ai-sdk/github-copilot"
return model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible"
}),
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
},
status: model.status ?? "active",
headers: model.headers ?? {},
@@ -911,6 +902,16 @@ export namespace Provider {
continue
}
if (providerID === "github-copilot" || providerID === "github-copilot-enterprise") {
provider.models = mapValues(provider.models, (model) => ({
...model,
api: {
...model.api,
npm: "@ai-sdk/github-copilot",
},
}))
}
const configProvider = config.provider?.[providerID]
for (const [modelID, model] of Object.entries(provider.models)) {

View File

@@ -815,20 +815,14 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
// flag that checks if there have been client-side tool calls (not executed by openai)
let hasFunctionCall = false
// Track reasoning by output_index instead of item_id
// GitHub Copilot rotates encrypted item IDs on every event
const activeReasoning: Record<
number,
string,
{
canonicalId: string // the item.id from output_item.added
encryptedContent?: string | null
summaryParts: number[]
}
> = {}
// Track current active reasoning output_index for correlating summary events
let currentReasoningOutputIndex: number | null = null
// Track a stable text part id for the current assistant message.
// Copilot may change item_id across text deltas; normalize to one id.
let currentTextId: string | null = null
@@ -939,12 +933,10 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
},
})
} else if (isResponseOutputItemAddedReasoningChunk(value)) {
activeReasoning[value.output_index] = {
canonicalId: value.item.id,
activeReasoning[value.item.id] = {
encryptedContent: value.item.encrypted_content,
summaryParts: [0],
}
currentReasoningOutputIndex = value.output_index
controller.enqueue({
type: "reasoning-start",
@@ -1099,25 +1091,22 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
currentTextId = null
}
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
const activeReasoningPart = activeReasoning[value.output_index]
const activeReasoningPart = activeReasoning[value.item.id]
if (activeReasoningPart) {
for (const summaryIndex of activeReasoningPart.summaryParts) {
controller.enqueue({
type: "reasoning-end",
id: `${activeReasoningPart.canonicalId}:${summaryIndex}`,
id: `${value.item.id}:${summaryIndex}`,
providerMetadata: {
openai: {
itemId: activeReasoningPart.canonicalId,
itemId: value.item.id,
reasoningEncryptedContent: value.item.encrypted_content ?? null,
},
},
})
}
delete activeReasoning[value.output_index]
if (currentReasoningOutputIndex === value.output_index) {
currentReasoningOutputIndex = null
}
}
delete activeReasoning[value.item.id]
}
} else if (isResponseFunctionCallArgumentsDeltaChunk(value)) {
const toolCall = ongoingToolCalls[value.output_index]
@@ -1209,40 +1198,32 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
logprobs.push(value.logprobs)
}
} else if (isResponseReasoningSummaryPartAddedChunk(value)) {
const activeItem =
currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
// the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk.
if (activeItem && value.summary_index > 0) {
activeItem.summaryParts.push(value.summary_index)
if (value.summary_index > 0) {
activeReasoning[value.item_id]?.summaryParts.push(value.summary_index)
controller.enqueue({
type: "reasoning-start",
id: `${activeItem.canonicalId}:${value.summary_index}`,
id: `${value.item_id}:${value.summary_index}`,
providerMetadata: {
openai: {
itemId: activeItem.canonicalId,
reasoningEncryptedContent: activeItem.encryptedContent ?? null,
itemId: value.item_id,
reasoningEncryptedContent: activeReasoning[value.item_id]?.encryptedContent ?? null,
},
},
})
}
} else if (isResponseReasoningSummaryTextDeltaChunk(value)) {
const activeItem =
currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
if (activeItem) {
controller.enqueue({
type: "reasoning-delta",
id: `${activeItem.canonicalId}:${value.summary_index}`,
delta: value.delta,
providerMetadata: {
openai: {
itemId: activeItem.canonicalId,
},
controller.enqueue({
type: "reasoning-delta",
id: `${value.item_id}:${value.summary_index}`,
delta: value.delta,
providerMetadata: {
openai: {
itemId: value.item_id,
},
})
}
},
})
} else if (isResponseFinishedChunk(value)) {
finishReason = mapOpenAIResponseFinishReason({
finishReason: value.response.incomplete_details?.reason,

View File

@@ -123,8 +123,11 @@ export namespace ProviderTransform {
return result
}
if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
const field = model.capabilities.interleaved.field
if (
model.capabilities.interleaved &&
typeof model.capabilities.interleaved === "object" &&
model.capabilities.interleaved.field === "reasoning_content"
) {
return msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
@@ -133,7 +136,7 @@ export namespace ProviderTransform {
// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
// Include reasoning_content | reasoning_details directly on the message for all assistant messages
// Include reasoning_content directly on the message for all assistant messages
if (reasoningText) {
return {
...msg,
@@ -142,7 +145,7 @@ export namespace ProviderTransform {
...msg.providerOptions,
openaiCompatible: {
...(msg.providerOptions as any)?.openaiCompatible,
[field]: reasoningText,
reasoning_content: reasoningText,
},
},
}
@@ -322,42 +325,15 @@ export namespace ProviderTransform {
const id = model.id.toLowerCase()
if (id.includes("deepseek") || id.includes("minimax") || id.includes("glm") || id.includes("mistral")) return {}
// see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks
if (id.includes("grok") && id.includes("grok-3-mini")) {
if (model.api.npm === "@openrouter/ai-sdk-provider") {
return {
low: { reasoning: { effort: "low" } },
high: { reasoning: { effort: "high" } },
}
}
return {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}
}
if (id.includes("grok")) return {}
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
if (!model.id.includes("gpt") && !model.id.includes("gemini-3")) return {}
if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("grok-4")) return {}
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
// TODO: YOU CANNOT SET max_tokens if this is set!!!
case "@ai-sdk/gateway":
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/github-copilot":
return Object.fromEntries(
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
effort,
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
]),
)
case "@ai-sdk/cerebras":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras
case "@ai-sdk/togetherai":
@@ -533,11 +509,7 @@ export namespace ProviderTransform {
const result: Record<string, any> = {}
// openai and providers using openai package should set store to false by default.
if (
input.model.providerID === "openai" ||
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/github-copilot"
) {
if (input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai") {
result["store"] = false
}

View File

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

View File

@@ -1,61 +0,0 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log"
export namespace Scheduler {
const log = Log.create({ service: "scheduler" })
export type Task = {
id: string
interval: number
run: () => Promise<void>
scope?: "instance" | "global"
}
type Timer = ReturnType<typeof setInterval>
type Entry = {
tasks: Map<string, Task>
timers: Map<string, Timer>
}
const create = (): Entry => {
const tasks = new Map<string, Task>()
const timers = new Map<string, Timer>()
return { tasks, timers }
}
const shared = create()
const state = Instance.state(
() => create(),
async (entry) => {
for (const timer of entry.timers.values()) {
clearInterval(timer)
}
entry.tasks.clear()
entry.timers.clear()
},
)
export function register(task: Task) {
const scope = task.scope ?? "instance"
const entry = scope === "global" ? shared : state()
const current = entry.timers.get(task.id)
if (current && scope === "global") return
if (current) clearInterval(current)
entry.tasks.set(task.id, task)
void run(task)
const timer = setInterval(() => {
void run(task)
}, task.interval)
timer.unref()
entry.timers.set(task.id, timer)
}
async function run(task: Task) {
log.info("run", { id: task.id })
await task.run().catch((error) => {
log.error("run failed", { id: task.id, error })
})
}
}

View File

@@ -133,57 +133,6 @@ export const ExperimentalRoutes = lazy(() =>
return c.json(sandboxes)
},
)
.delete(
"/worktree",
describeRoute({
summary: "Remove worktree",
description: "Remove a git worktree and delete its branch.",
operationId: "worktree.remove",
responses: {
200: {
description: "Worktree removed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", Worktree.remove.schema),
async (c) => {
const body = c.req.valid("json")
await Worktree.remove(body)
await Project.removeSandbox(Instance.project.id, body.directory)
return c.json(true)
},
)
.post(
"/worktree/reset",
describeRoute({
summary: "Reset worktree",
description: "Reset a worktree branch to the primary default branch.",
operationId: "worktree.reset",
responses: {
200: {
description: "Worktree reset",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", Worktree.reset.schema),
async (c) => {
const body = c.req.valid("json")
await Worktree.reset(body)
return c.json(true)
},
)
.get(
"/resource",
describeRoute({

View File

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

View File

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

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