diff --git a/.github/workflows/nix-desktop.yml b/.github/workflows/nix-desktop.yml index 01cfaed78b4..3d7c4803133 100644 --- a/.github/workflows/nix-desktop.yml +++ b/.github/workflows/nix-desktop.yml @@ -9,6 +9,7 @@ on: - "nix/**" - "packages/app/**" - "packages/desktop/**" + - ".github/workflows/nix-desktop.yml" pull_request: paths: - "flake.nix" @@ -16,6 +17,7 @@ on: - "nix/**" - "packages/app/**" - "packages/desktop/**" + - ".github/workflows/nix-desktop.yml" workflow_dispatch: jobs: @@ -26,7 +28,7 @@ jobs: os: - blacksmith-4vcpu-ubuntu-2404 - blacksmith-4vcpu-ubuntu-2404-arm - - macos-15 + - macos-15-intel - macos-latest runs-on: ${{ matrix.os }} timeout-minutes: 60 diff --git a/.github/workflows/update-nix-hashes.yml b/.github/workflows/update-nix-hashes.yml index 19373f748f2..f9817fe1eac 100644 --- a/.github/workflows/update-nix-hashes.yml +++ b/.github/workflows/update-nix-hashes.yml @@ -10,22 +10,36 @@ on: - "bun.lock" - "package.json" - "packages/*/package.json" + - ".github/workflows/update-nix-hashes.yml" pull_request: paths: - "bun.lock" - "package.json" - "packages/*/package.json" + - ".github/workflows/update-nix-hashes.yml" jobs: - update-flake: + 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 + 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: - TITLE: flake.lock + SYSTEM: ${{ matrix.system }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 @@ -35,96 +49,104 @@ jobs: - name: Setup Nix uses: nixbuild/nix-quick-install-action@v34 - - name: Configure git - run: | - git config --global user.email "action@github.com" - git config --global user.name "Github Action" - - - name: Update ${{ env.TITLE }} + - name: Compute node_modules hash 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 + DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + HASH_FILE="nix/hashes.json" + OUTPUT_FILE="hash-${SYSTEM}.txt" - echo "🔍 Checking for changes in tracked files..." + export NIX_KEEP_OUTPUTS=1 + export NIX_KEEP_DERIVATIONS=1 - 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" + 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 - 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" + if [ -z "$CORRECT_HASH" ]; then + echo "Failed to determine correct node_modules hash for ${SYSTEM}." + cat "$BUILD_LOG" + exit 1 + fi - BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - echo "🌳 Pulling latest from branch: $BRANCH" - git pull --rebase origin "$BRANCH" - echo "🚀 Pushing changes to branch: $BRANCH" - git push origin HEAD:"$BRANCH" - echo "✅ Changes pushed successfully" + echo "$CORRECT_HASH" > "$OUTPUT_FILE" + echo "Hash for ${SYSTEM}: $CORRECT_HASH" - summarize "committed $(git rev-parse --short HEAD)" + - name: Upload hash artifact + uses: actions/upload-artifact@v6 + with: + name: hash-${{ matrix.system }} + path: hash-${{ matrix.system }}.txt + retention-days: 1 - update-node-modules-hash: - needs: update-flake + 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 - 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 }} + runs-on: blacksmith-4vcpu-ubuntu-2404 env: - SYSTEM: ${{ matrix.system }} - TITLE: node_modules hash (${{ matrix.system }}) + TITLE: node_modules hashes steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 ref: ${{ github.head_ref || github.ref_name }} repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 - - name: Configure git run: | git config --global user.email "action@github.com" @@ -135,14 +157,57 @@ jobs: TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} run: | BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - git pull origin "$BRANCH" + git pull --rebase --autostash origin "$BRANCH" + + - name: Download all hash artifacts + uses: actions/download-artifact@v7 + with: + pattern: hash-* + merge-multiple: true - - name: Update ${{ env.TITLE }} + - name: Merge hashes into hashes.json run: | set -euo pipefail - echo "🔄 Updating $TITLE..." - nix/scripts/update-hashes.sh - echo "✅ $TITLE updated successfully" + + HASH_FILE="nix/hashes.json" + + if [ ! -f "$HASH_FILE" ]; then + mkdir -p "$(dirname "$HASH_FILE")" + echo '{"nodeModules":{}}' > "$HASH_FILE" + fi + + echo "Merging hashes into ${HASH_FILE}..." + + shopt -s nullglob + files=(hash-*.txt) + if [ ${#files[@]} -eq 0 ]; then + echo "No hash files found, nothing to update" + exit 0 + fi + + 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 + + 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: $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 merged:" + cat "$HASH_FILE" - name: Commit ${{ env.TITLE }} changes env: @@ -150,7 +215,8 @@ jobs: run: | set -euo pipefail - echo "🔍 Checking for changes in tracked files..." + HASH_FILE="nix/hashes.json" + echo "Checking for changes..." summarize() { local status="$1" @@ -166,27 +232,22 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" } - FILES=(nix/hashes.json) + FILES=("$HASH_FILE") STATUS="$(git status --short -- "${FILES[@]}" || true)" if [ -z "$STATUS" ]; then - echo "✅ No changes detected." + echo "No changes detected." summarize "no changes" exit 0 fi - echo "📝 Changes detected:" + 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 origin "$BRANCH" - echo "🚀 Pushing changes to branch: $BRANCH" + git pull --rebase --autostash origin "$BRANCH" git push origin HEAD:"$BRANCH" - echo "✅ Changes pushed successfully" + echo "Changes pushed successfully" summarize "committed $(git rev-parse --short HEAD)" diff --git a/.gitignore b/.gitignore index 75fa054a5e4..78a77f81982 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ opencode.json a.out target .scripts +.direnv/ # Local dev files opencode-dev diff --git a/README.md b/README.md index d0ba487402f..64ca1ef7a6f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ curl -fsSL https://opencode.ai/install | bash # Package managers npm i -g opencode-ai@latest # or bun/pnpm/yarn -scoop bucket add extras; scoop install extras/opencode # Windows +scoop install opencode # Windows choco install opencode # Windows brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date) brew install opencode # macOS and Linux (official brew formula, updated less) @@ -52,6 +52,8 @@ OpenCode is also available as a desktop application. Download directly from the ```bash # macOS (Homebrew) brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop ``` #### Installation Directory diff --git a/README.zh-CN.md b/README.zh-CN.md index 30757f5fe9d..4b56e0fb0b0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,7 +26,7 @@ curl -fsSL https://opencode.ai/install | bash # 软件包管理器 npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn -scoop bucket add extras; scoop install extras/opencode # Windows +scoop install opencode # Windows choco install opencode # Windows brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新) brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低) @@ -52,6 +52,8 @@ OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](htt ```bash # macOS (Homebrew Cask) brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop ``` #### 安装目录 diff --git a/README.zh-TW.md b/README.zh-TW.md index 9e27c48f27e..66664a70305 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -26,7 +26,7 @@ curl -fsSL https://opencode.ai/install | bash # 套件管理員 npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn -scoop bucket add extras; scoop install extras/opencode # Windows +scoop install opencode # Windows choco install opencode # Windows brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新) brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低) @@ -52,6 +52,8 @@ OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (rele ```bash # macOS (Homebrew Cask) brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop ``` #### 安裝目錄 diff --git a/STATS.md b/STATS.md index e09c57e8f41..a2041d49ac6 100644 --- a/STATS.md +++ b/STATS.md @@ -202,3 +202,5 @@ | 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) | | 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) | diff --git a/bun.lock b/bun.lock index e71d700e1cb..a9cabb31114 100644 --- a/bun.lock +++ b/bun.lock @@ -281,7 +281,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.1.1", + "@gitlab/gitlab-ai-provider": "3.1.2", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -424,6 +424,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "strip-ansi": "7.1.2", "virtua": "catalog:", }, "devDependencies": { @@ -916,7 +917,7 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-p0NZhZJSavWDX9r/Px/mOK2YIC803GZa8iRzcg3f1C6S0qfea/HBTe4/NWvT2+2kWIwhCePGuI4FN2UFiUWXUg=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], diff --git a/flake.lock b/flake.lock index 58bdca6bf6a..5ef276f0a08 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768395095, - "narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=", + "lastModified": 1768302833, + "narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5", + "rev": "61db79b0c6b838d9894923920b612048e1201926", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 4219a7e8e10..20833fc49ed 100644 --- a/flake.nix +++ b/flake.nix @@ -6,10 +6,7 @@ }; outputs = - { - nixpkgs, - ... - }: + { self, nixpkgs, ... }: let systems = [ "aarch64-linux" @@ -17,122 +14,35 @@ "aarch64-darwin" "x86_64-darwin" ]; - inherit (nixpkgs) lib; - forEachSystem = lib.genAttrs systems; - pkgsFor = system: nixpkgs.legacyPackages.${system}; - packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json); - bunTarget = { - "aarch64-linux" = "bun-linux-arm64"; - "x86_64-linux" = "bun-linux-x64"; - "aarch64-darwin" = "bun-darwin-arm64"; - "x86_64-darwin" = "bun-darwin-x64"; - }; - - # Parse "bun-{os}-{cpu}" to {os, cpu} - parseBunTarget = - target: - let - parts = lib.splitString "-" target; - in - { - os = builtins.elemAt parts 1; - cpu = builtins.elemAt parts 2; - }; - - hashesFile = "${./nix}/hashes.json"; - hashesData = - if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { }; - # Lookup hash: supports per-system ({system: hash}) or legacy single hash - nodeModulesHashFor = - system: - if builtins.isAttrs hashesData.nodeModules then - hashesData.nodeModules.${system} - else - hashesData.nodeModules; - modelsDev = forEachSystem ( - system: - let - pkgs = pkgsFor system; - in - pkgs."models-dev" - ); + forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + rev = self.shortRev or self.dirtyShortRev or "dirty"; in { - devShells = forEachSystem ( - system: - let - pkgs = pkgsFor system; - in - { - default = pkgs.mkShell { - packages = with pkgs; [ - bun - nodejs_20 - pkg-config - openssl - git - ]; - }; - } - ); + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = with pkgs; [ + bun + nodejs_20 + pkg-config + openssl + git + ]; + }; + }); packages = forEachSystem ( - system: + pkgs: let - pkgs = pkgsFor system; - bunPlatform = parseBunTarget bunTarget.${system}; - mkNodeModules = pkgs.callPackage ./nix/node-modules.nix { - hash = nodeModulesHashFor system; - bunCpu = bunPlatform.cpu; - bunOs = bunPlatform.os; + opencode = pkgs.callPackage ./nix/opencode.nix { + inherit rev; }; - mkOpencode = pkgs.callPackage ./nix/opencode.nix { }; - mkDesktop = pkgs.callPackage ./nix/desktop.nix { }; - - opencodePkg = mkOpencode { - inherit (packageJson) version; - src = ./.; - scripts = ./nix/scripts; - target = bunTarget.${system}; - modelsDev = "${modelsDev.${system}}/dist/_api.json"; - inherit mkNodeModules; - }; - - desktopPkg = mkDesktop { - inherit (packageJson) version; - src = ./.; - scripts = ./nix/scripts; - mkNodeModules = mkNodeModules; - opencode = opencodePkg; + desktop = pkgs.callPackage ./nix/desktop.nix { + inherit opencode; }; in { - default = opencodePkg; - desktop = desktopPkg; - } - ); - - apps = forEachSystem ( - system: - let - pkgs = pkgsFor system; - in - { - opencode-dev = { - type = "app"; - meta = { - description = "Nix devshell shell for OpenCode"; - runtimeInputs = [ pkgs.bun ]; - }; - program = "${ - pkgs.writeShellApplication { - name = "opencode-dev"; - text = '' - exec bun run dev "$@" - ''; - } - }/bin/opencode-dev"; - }; + default = opencode; + inherit opencode desktop; } ); }; diff --git a/github/README.md b/github/README.md index 8238bdc42aa..17b24ffb1d6 100644 --- a/github/README.md +++ b/github/README.md @@ -91,8 +91,10 @@ This will walk you through installing the GitHub app, creating the workflow, and uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true ``` 3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys. diff --git a/nix/bundle.ts b/nix/bundle.ts deleted file mode 100644 index effb1dff7cc..00000000000 --- a/nix/bundle.ts +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bun - -import solidPlugin from "./node_modules/@opentui/solid/scripts/solid-plugin" -import path from "path" -import fs from "fs" - -const dir = process.cwd() -const parser = fs.realpathSync(path.join(dir, "node_modules/@opentui/core/parser.worker.js")) -const worker = "./src/cli/cmd/tui/worker.ts" -const version = process.env.OPENCODE_VERSION ?? "local" -const channel = process.env.OPENCODE_CHANNEL ?? "local" - -fs.rmSync(path.join(dir, "dist"), { recursive: true, force: true }) - -const result = await Bun.build({ - entrypoints: ["./src/index.ts", worker, parser], - outdir: "./dist", - target: "bun", - sourcemap: "none", - tsconfig: "./tsconfig.json", - plugins: [solidPlugin], - external: ["@opentui/core"], - define: { - OPENCODE_VERSION: `'${version}'`, - OPENCODE_CHANNEL: `'${channel}'`, - // Leave undefined so runtime picks bundled/dist worker or fallback in code. - OPENCODE_WORKER_PATH: "undefined", - OTUI_TREE_SITTER_WORKER_PATH: 'new URL("./cli/cmd/tui/parser.worker.js", import.meta.url).href', - }, -}) - -if (!result.success) { - console.error("bundle failed") - for (const log of result.logs) console.error(log) - process.exit(1) -} - -const parserOut = path.join(dir, "dist/src/cli/cmd/tui/parser.worker.js") -fs.mkdirSync(path.dirname(parserOut), { recursive: true }) -await Bun.write(parserOut, Bun.file(parser)) diff --git a/nix/desktop.nix b/nix/desktop.nix index 4b659413aaa..9625f75c271 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -2,144 +2,99 @@ lib, stdenv, rustPlatform, - bun, pkg-config, - dbus ? null, - openssl, - glib ? null, - gtk3 ? null, - libsoup_3 ? null, - webkitgtk_4_1 ? null, - librsvg ? null, - libappindicator-gtk3 ? null, + cargo-tauri, + bun, + nodejs, cargo, rustc, - makeBinaryWrapper, - nodejs, jq, + wrapGAppsHook4, + makeWrapper, + dbus, + glib, + gtk4, + libsoup_3, + librsvg, + libappindicator, + glib-networking, + openssl, + webkitgtk_4_1, + gst_all_1, + opencode, }: -args: -let - scripts = args.scripts; - mkModules = - attrs: - args.mkNodeModules ( - attrs - // { - canonicalizeScript = scripts + "/canonicalize-node-modules.ts"; - normalizeBinsScript = scripts + "/normalize-bun-binaries.ts"; - } - ); -in -rustPlatform.buildRustPackage rec { +rustPlatform.buildRustPackage (finalAttrs: { pname = "opencode-desktop"; - version = args.version; - - src = args.src; + inherit (opencode) + version + src + node_modules + patches + ; - # We need to set the root for cargo, but we also need access to the whole repo. - postUnpack = '' - # Update sourceRoot to point to the tauri app - sourceRoot+=/packages/desktop/src-tauri - ''; - - cargoLock = { - lockFile = ../packages/desktop/src-tauri/Cargo.lock; - allowBuiltinFetchGit = true; - }; - - node_modules = mkModules { - version = version; - src = src; - }; + cargoRoot = "packages/desktop/src-tauri"; + cargoLock.lockFile = ../packages/desktop/src-tauri/Cargo.lock; + buildAndTestSubdir = finalAttrs.cargoRoot; nativeBuildInputs = [ pkg-config + cargo-tauri.hook bun - makeBinaryWrapper + nodejs # for patchShebangs node_modules cargo rustc - nodejs jq - ]; - - buildInputs = [ - openssl + makeWrapper ] - ++ lib.optionals stdenv.isLinux [ + ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ]; + + buildInputs = lib.optionals stdenv.isLinux [ dbus glib - gtk3 + gtk4 libsoup_3 - webkitgtk_4_1 librsvg - libappindicator-gtk3 + libappindicator + glib-networking + openssl + webkitgtk_4_1 + gst_all_1.gstreamer + gst_all_1.gst-plugins-base + gst_all_1.gst-plugins-good ]; - preBuild = '' - # Restore node_modules - pushd ../../.. - - # Copy node_modules from the fixed-output derivation - # We use cp -r --no-preserve=mode to ensure we can write to them if needed, - # though we usually just read. - cp -r ${node_modules}/node_modules . - cp -r ${node_modules}/packages . + strictDeps = true; - # 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 + preBuild = '' + cp -a ${finalAttrs.node_modules}/{node_modules,packages} . + chmod -R u+w node_modules packages patchShebangs node_modules + patchShebangs packages/desktop/node_modules - # Copy sidecar mkdir -p packages/desktop/src-tauri/sidecars - targetTriple=${stdenv.hostPlatform.rust.rustcTarget} - cp ${args.opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-$targetTriple - - # Merge prod config into tauri.conf.json - if ! jq -s '.[0] * .[1]' \ - packages/desktop/src-tauri/tauri.conf.json \ - packages/desktop/src-tauri/tauri.prod.conf.json \ - > packages/desktop/src-tauri/tauri.conf.json.tmp; then - echo "Error: failed to merge tauri.conf.json with tauri.prod.conf.json" >&2 - exit 1 - fi - mv packages/desktop/src-tauri/tauri.conf.json.tmp packages/desktop/src-tauri/tauri.conf.json - - # Build the frontend - cd packages/desktop - - # The 'build' script runs 'bun run typecheck && vite build'. - bun run build - - popd + cp ${opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-${stdenv.hostPlatform.rust.rustcTarget} ''; - # Tauri bundles the assets during the rust build phase (which happens after preBuild). - # It looks for them in the location specified in tauri.conf.json. + # see publish-tauri job in .github/workflows/publish.yml + tauriBuildFlags = [ + "--config" + "tauri.prod.conf.json" + "--no-sign" # no code signing or auto updates + ]; - postInstall = lib.optionalString stdenv.isLinux '' - # Wrap the binary to ensure it finds the libraries - wrapProgram $out/bin/opencode-desktop \ - --prefix LD_LIBRARY_PATH : ${ - lib.makeLibraryPath [ - gtk3 - webkitgtk_4_1 - librsvg - glib - libsoup_3 - ] - } + # FIXME: workaround for concerns about case insensitive filesystems + # should be removed once binary is renamed or decided otherwise + # darwin output is a .app bundle so no conflict + postFixup = lib.optionalString stdenv.hostPlatform.isLinux '' + mv $out/bin/OpenCode $out/bin/opencode-desktop + sed -i 's|^Exec=OpenCode$|Exec=opencode-desktop|' $out/share/applications/OpenCode.desktop ''; - meta = with lib; { + meta = { description = "OpenCode Desktop App"; homepage = "https://opencode.ai"; - license = licenses.mit; - maintainers = with maintainers; [ ]; + license = lib.licenses.mit; mainProgram = "opencode-desktop"; - platforms = platforms.linux ++ platforms.darwin; + inherit (opencode.meta) platforms; }; -} +}) \ No newline at end of file diff --git a/nix/hashes.json b/nix/hashes.json index 255e44fe366..5bbdf921bbd 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-07XxcHLuToM4QfWVyaPLACxjPZ93ZM7gtpX2o08Lp18=", - "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", - "aarch64-darwin": "sha256-U2UvE70nM0OI0VhIku8qnX+ptPbA+Q/y1BGXbFMcyt4=", - "x86_64-darwin": "sha256-CpZFHBMPJSib2Vqs6oC8HQjQtviPUMa/qezHAe22N/A=" + "x86_64-linux": "sha256-D1VXuKJagfq3mxh8Xs8naHoYNJUJzAM9JLJqpHcItDk=", + "aarch64-linux": "sha256-9wXcg50Sv56Wb2x5NWe15olNGE/uMiDkmGRmqPoeW1U=", + "aarch64-darwin": "sha256-i5eTTjsNAARwcw69sd6wuse2BKTUi/Vfgo4M28l+RoY=", + "x86_64-darwin": "sha256-oFtQnIzgTS2zcjkhBTnXxYqr20KXdA2I+b908piLs+c=" } } diff --git a/nix/node-modules.nix b/nix/node-modules.nix deleted file mode 100644 index 2a8f0a47cb0..00000000000 --- a/nix/node-modules.nix +++ /dev/null @@ -1,62 +0,0 @@ -{ - hash, - lib, - stdenvNoCC, - bun, - cacert, - curl, - bunCpu, - bunOs, -}: -args: -stdenvNoCC.mkDerivation { - pname = "opencode-node_modules"; - inherit (args) version src; - - impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [ - "GIT_PROXY_COMMAND" - "SOCKS_SERVER" - ]; - - nativeBuildInputs = [ - bun - cacert - curl - ]; - - dontConfigure = true; - - buildPhase = '' - runHook preBuild - export HOME=$(mktemp -d) - export BUN_INSTALL_CACHE_DIR=$(mktemp -d) - bun install \ - --cpu="${bunCpu}" \ - --os="${bunOs}" \ - --frozen-lockfile \ - --ignore-scripts \ - --no-progress \ - --linker=isolated - bun --bun ${args.canonicalizeScript} - bun --bun ${args.normalizeBinsScript} - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - mkdir -p $out - while IFS= read -r dir; do - rel="''${dir#./}" - dest="$out/$rel" - mkdir -p "$(dirname "$dest")" - cp -R "$dir" "$dest" - done < <(find . -type d -name node_modules -prune | sort) - runHook postInstall - ''; - - dontFixup = true; - - outputHashAlgo = "sha256"; - outputHashMode = "recursive"; - outputHash = hash; -} diff --git a/nix/opencode.nix b/nix/opencode.nix index 714aabe094f..4d6f8e9b423 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -2,60 +2,115 @@ lib, stdenvNoCC, bun, - ripgrep, + sysctl, makeBinaryWrapper, + models-dev, + ripgrep, + installShellFiles, + versionCheckHook, + writableTmpDirAsHomeHook, + rev ? "dirty", }: -args: let - inherit (args) scripts; - mkModules = - attrs: - args.mkNodeModules ( - attrs - // { - canonicalizeScript = scripts + "/canonicalize-node-modules.ts"; - normalizeBinsScript = scripts + "/normalize-bun-binaries.ts"; - } - ); + packageJson = lib.pipe ../packages/opencode/package.json [ + builtins.readFile + builtins.fromJSON + ]; in stdenvNoCC.mkDerivation (finalAttrs: { pname = "opencode"; - inherit (args) version src; + version = "${packageJson.version}-${rev}"; + + src = lib.fileset.toSource { + root = ../.; + fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) ( + lib.fileset.unions [ + ../packages + ../bun.lock + ../package.json + ../patches + ../install + ] + ); + }; - node_modules = mkModules { + node_modules = stdenvNoCC.mkDerivation { + pname = "${finalAttrs.pname}-node_modules"; inherit (finalAttrs) version src; + + impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [ + "GIT_PROXY_COMMAND" + "SOCKS_SERVER" + ]; + + nativeBuildInputs = [ + bun + ]; + + dontConfigure = true; + + buildPhase = '' + runHook preBuild + export HOME=$(mktemp -d) + export BUN_INSTALL_CACHE_DIR=$(mktemp -d) + bun install \ + --cpu="${if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64"}" \ + --os="${if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin"}" \ + --frozen-lockfile \ + --ignore-scripts \ + --no-progress \ + --linker=isolated + bun --bun ${./scripts/canonicalize-node-modules.ts} + bun --bun ${./scripts/normalize-bun-binaries.ts} + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out + find . -type d -name node_modules -exec cp -R --parents {} $out \; + + runHook postInstall + ''; + + dontFixup = true; + + outputHashAlgo = "sha256"; + outputHashMode = "recursive"; + outputHash = + (lib.pipe ./hashes.json [ + builtins.readFile + builtins.fromJSON + ]).nodeModules.${stdenvNoCC.hostPlatform.system}; }; nativeBuildInputs = [ bun + installShellFiles makeBinaryWrapper + models-dev + writableTmpDirAsHomeHook ]; - env.MODELS_DEV_API_JSON = args.modelsDev; - env.OPENCODE_VERSION = args.version; - env.OPENCODE_CHANNEL = "stable"; - dontConfigure = true; + configurePhase = '' + runHook preConfigure - buildPhase = '' - runHook preBuild + cp -R ${finalAttrs.node_modules}/. . - cp -r ${finalAttrs.node_modules}/node_modules . - cp -r ${finalAttrs.node_modules}/packages . + runHook postConfigure + ''; - ( - cd packages/opencode + env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; + env.OPENCODE_VERSION = finalAttrs.version; + env.OPENCODE_CHANNEL = "local"; - 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 + buildPhase = '' + runHook preBuild - cp ${./bundle.ts} ./bundle.ts - chmod +x ./bundle.ts - bun run ./bundle.ts - ) + cd ./packages/opencode + bun --bun ./script/build.ts --single --skip-install + bun --bun ./script/schema.ts schema.json runHook postBuild ''; @@ -63,76 +118,52 @@ stdenvNoCC.mkDerivation (finalAttrs: { installPhase = '' runHook preInstall - cd packages/opencode - if [ ! -d dist ]; then - echo "ERROR: dist directory missing after bundle step" - exit 1 - fi - - 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 + install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode + install -Dm644 schema.json $out/share/opencode/schema.json + + wrapProgram $out/bin/opencode \ + --prefix PATH : ${ + lib.makeBinPath ( + [ + ripgrep + ] + # bun runs sysctl to detect if dunning on rosetta2 + ++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl + ) + } runHook postInstall ''; - postInstall = '' - for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do - if [ -d "$pkg" ]; then - pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/') - ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \ - $out/lib/opencode/node_modules/@opentui/$pkgName - fi - done + postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) '' + # trick yargs into also generating zsh completions + installShellCompletion --cmd opencode \ + --bash <($out/bin/opencode completion) \ + --zsh <(SHELL=/bin/zsh $out/bin/opencode completion) ''; - dontFixup = true; + nativeInstallCheckInputs = [ + versionCheckHook + writableTmpDirAsHomeHook + ]; + doInstallCheck = true; + versionCheckKeepEnvironment = [ "HOME" ]; + versionCheckProgramArg = "--version"; + + passthru = { + jsonschema = "${placeholder "out"}/share/opencode/schema.json"; + }; meta = { - description = "AI coding agent built for the terminal"; - longDescription = '' - OpenCode is a terminal-based agent that can build anything. - It combines a TypeScript/JavaScript core with a Go-based TUI - to provide an interactive AI coding experience. - ''; - homepage = "https://github.com/anomalyco/opencode"; + description = "The open source coding agent"; + homepage = "https://opencode.ai/"; license = lib.licenses.mit; + mainProgram = "opencode"; platforms = [ "aarch64-linux" "x86_64-linux" "aarch64-darwin" "x86_64-darwin" ]; - mainProgram = "opencode"; }; }) diff --git a/nix/scripts/bun-build.ts b/nix/scripts/bun-build.ts deleted file mode 100644 index e607676cb11..00000000000 --- a/nix/scripts/bun-build.ts +++ /dev/null @@ -1,120 +0,0 @@ -import solidPlugin from "./packages/opencode/node_modules/@opentui/solid/scripts/solid-plugin" -import path from "path" -import fs from "fs" - -const version = "@VERSION@" -const pkg = path.join(process.cwd(), "packages/opencode") -const parser = fs.realpathSync(path.join(pkg, "./node_modules/@opentui/core/parser.worker.js")) -const worker = "./src/cli/cmd/tui/worker.ts" -const target = process.env["BUN_COMPILE_TARGET"] - -if (!target) { - throw new Error("BUN_COMPILE_TARGET not set") -} - -process.chdir(pkg) - -const manifestName = "opencode-assets.manifest" -const manifestPath = path.join(pkg, manifestName) - -const readTrackedAssets = () => { - if (!fs.existsSync(manifestPath)) return [] - return fs - .readFileSync(manifestPath, "utf8") - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) -} - -const removeTrackedAssets = () => { - for (const file of readTrackedAssets()) { - const filePath = path.join(pkg, file) - if (fs.existsSync(filePath)) { - fs.rmSync(filePath, { force: true }) - } - } -} - -const assets = new Set() - -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!") diff --git a/nix/scripts/patch-wasm.ts b/nix/scripts/patch-wasm.ts deleted file mode 100644 index 88a06c2bd2b..00000000000 --- a/nix/scripts/patch-wasm.ts +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bun - -import fs from "fs" -import path from "path" - -/** - * Rewrite tree-sitter wasm references inside a JS file to absolute paths. - * argv: [node, script, file, mainWasm, ...wasmPaths] - */ -const [, , file, mainWasm, ...wasmPaths] = process.argv - -if (!file || !mainWasm) { - console.error("usage: patch-wasm [wasmPaths...]") - process.exit(1) -} - -const content = fs.readFileSync(file, "utf8") -const byName = new Map() - -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) diff --git a/nix/scripts/update-hashes.sh b/nix/scripts/update-hashes.sh deleted file mode 100755 index 1e294fe4fb4..00000000000 --- a/nix/scripts/update-hashes.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" -SYSTEM=${SYSTEM:-x86_64-linux} -DEFAULT_HASH_FILE=${MODULES_HASH_FILE:-nix/hashes.json} -HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE} - -if [ ! -f "$HASH_FILE" ]; then - cat >"$HASH_FILE" </dev/null 2>&1; then - if ! git ls-files --error-unmatch "$HASH_FILE" >/dev/null 2>&1; then - git add -N "$HASH_FILE" >/dev/null 2>&1 || true - fi -fi - -export DUMMY -export NIX_KEEP_OUTPUTS=1 -export NIX_KEEP_DERIVATIONS=1 - -cleanup() { - rm -f "${JSON_OUTPUT:-}" "${BUILD_LOG:-}" "${TMP_EXPR:-}" -} - -trap cleanup EXIT - -write_node_modules_hash() { - local value="$1" - local system="${2:-$SYSTEM}" - local temp - temp=$(mktemp) - - if jq -e '.nodeModules | type == "object"' "$HASH_FILE" >/dev/null 2>&1; then - jq --arg system "$system" --arg value "$value" '.nodeModules[$system] = $value' "$HASH_FILE" >"$temp" - else - jq --arg system "$system" --arg value "$value" '.nodeModules = {($system): $value}' "$HASH_FILE" >"$temp" - fi - - mv "$temp" "$HASH_FILE" -} - -TARGET="packages.${SYSTEM}.default" -MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules" -CORRECT_HASH="" - -DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")" - -echo "Setting dummy node_modules outputHash for ${SYSTEM}..." -write_node_modules_hash "$DUMMY" - -BUILD_LOG=$(mktemp) -JSON_OUTPUT=$(mktemp) - -echo "Building node_modules for ${SYSTEM} to discover correct outputHash..." -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) -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 - -if [ -z "$CORRECT_HASH" ]; then - 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)" - fi - - if [ -z "$CORRECT_HASH" ]; then - echo "Searching for kept failed build directory..." - KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1) - - if [ -z "$KEPT_DIR" ]; then - KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1) - fi - - if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then - echo "Found kept build directory: $KEPT_DIR" - if [ -d "$KEPT_DIR/build" ]; then - HASH_PATH="$KEPT_DIR/build" - else - HASH_PATH="$KEPT_DIR" - fi - - echo "Attempting to hash: $HASH_PATH" - ls -la "$HASH_PATH" || true - - if [ -d "$HASH_PATH/node_modules" ]; then - CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true) - echo "Computed hash from kept build: $CORRECT_HASH" - fi - fi - fi -fi - -if [ -z "$CORRECT_HASH" ]; then - echo "Failed to determine correct node_modules hash for ${SYSTEM}." - echo "Build log:" - cat "$BUILD_LOG" - exit 1 -fi - -write_node_modules_hash "$CORRECT_HASH" - -jq -e --arg system "$SYSTEM" --arg hash "$CORRECT_HASH" '.nodeModules[$system] == $hash' "$HASH_FILE" >/dev/null - -echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH" - -rm -f "$BUILD_LOG" -unset BUILD_LOG diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d0678dc5369..d03d10d0ea7 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -29,7 +29,7 @@ import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) -const Loading = () =>
Loading...
+const Loading = () =>
declare global { interface Window { diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 3b80c2687f1..0e8d69628bb 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,6 +1,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useParams } from "@solidjs/router" @@ -133,14 +134,14 @@ export function DialogSelectFile() { }) return ( - + item.id} filterKeys={["title", "description", "category"]} - groupBy={(item) => (grouped() ? item.category : "")} + groupBy={(item) => item.category} onMove={handleMove} onSelect={handleSelect} > @@ -148,7 +149,7 @@ export function DialogSelectFile() { +
@@ -161,7 +162,7 @@ export function DialogSelectFile() {
} > -
+
{item.title} @@ -169,7 +170,7 @@ export function DialogSelectFile() {
- {formatKeybind(item.keybind ?? "")} + {formatKeybind(item.keybind ?? "")}
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4b083771fb8..96ed762c448 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,21 +1,24 @@ -import { createMemo, createResource, Show } from "solid-js" +import { createEffect, createMemo, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" // import { useServer } from "@/context/server" // import { useDialog } from "@opencode-ai/ui/context/dialog" +import { usePlatform } from "@/context/platform" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" import { base64Decode } from "@opencode-ai/util/encode" -import { iife } from "@opencode-ai/util/iife" + import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" +import { Keybind } from "@opencode-ai/ui/keybind" export function SessionHeader() { const globalSDK = useGlobalSDK() @@ -25,6 +28,7 @@ export function SessionHeader() { // const server = useServer() // const dialog = useDialog() const sync = useSync() + const platform = usePlatform() const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) const project = createMemo(() => { @@ -44,6 +48,78 @@ export function SessionHeader() { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey())) + const [state, setState] = createStore({ + share: false, + unshare: false, + copied: false, + timer: undefined as number | undefined, + }) + const shareUrl = createMemo(() => currentSession()?.share?.url) + + createEffect(() => { + const url = shareUrl() + if (url) return + if (state.timer) window.clearTimeout(state.timer) + setState({ copied: false, timer: undefined }) + }) + + onCleanup(() => { + if (state.timer) window.clearTimeout(state.timer) + }) + + function shareSession() { + const session = currentSession() + if (!session || state.share) return + setState("share", true) + globalSDK.client.session + .share({ sessionID: session.id, directory: projectDirectory() }) + .catch((error) => { + console.error("Failed to share session", error) + }) + .finally(() => { + setState("share", false) + }) + } + + function unshareSession() { + const session = currentSession() + if (!session || state.unshare) return + setState("unshare", true) + globalSDK.client.session + .unshare({ sessionID: session.id, directory: projectDirectory() }) + .catch((error) => { + console.error("Failed to unshare session", error) + }) + .finally(() => { + setState("unshare", false) + }) + } + + function copyLink() { + const url = shareUrl() + if (!url) return + navigator.clipboard + .writeText(url) + .then(() => { + if (state.timer) window.clearTimeout(state.timer) + setState("copied", true) + const timer = window.setTimeout(() => { + setState("copied", false) + setState("timer", undefined) + }, 3000) + setState("timer", timer) + }) + .catch((error) => { + console.error("Failed to copy share link", error) + }) + } + + function viewShare() { + const url = shareUrl() + if (!url) return + platform.openLink(url) + } + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) @@ -57,23 +133,14 @@ export function SessionHeader() { class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active" onClick={() => command.trigger("file.open")} > -
- - +
+ + Search {name()}
- - {(keybind) => ( - - {keybind()} - - )} - + {(keybind) => {keybind()}} )} @@ -167,40 +234,77 @@ export function SessionHeader() {
- - - - } - > - {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 ( - - {(shareUrl) => } + > +
+ +
+ + +
+
- ) - })} -
+
+
+ + + + + +
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 3c640d8e9fa..d8dc13e2344 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -1,5 +1,6 @@ import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) @@ -104,7 +105,15 @@ export function formatKeybind(config: string): string { if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") if (kb.key) { - const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1) + const arrows: Record = { + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + } + const displayKey = + arrows[kb.key.toLowerCase()] ?? + (kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)) parts.push(displayKey) } @@ -114,6 +123,7 @@ export function formatKeybind(config: string): string { export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { + const dialog = useDialog() const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) @@ -157,7 +167,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const handleKeyDown = (event: KeyboardEvent) => { - if (suspended()) return + if (suspended() || dialog.active) return const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 74641a0a243..96f8c63eab2 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -379,6 +379,8 @@ function createGlobalSync() { }), ) } + if (event.properties.info.parentID) break + setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } if (result.found) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5e291449f46..56d6bfbf8ca 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -501,7 +501,7 @@ export default function Layout(props: ParentProps) { const [dirStore] = globalSync.child(dir) const dirSessions = dirStore.session .filter((session) => session.directory === dirStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) result.push(...dirSessions) } @@ -510,7 +510,7 @@ export default function Layout(props: ParentProps) { const [projectStore] = globalSync.child(project.worktree) return projectStore.session .filter((session) => session.directory === projectStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) }) @@ -1018,7 +1018,7 @@ export default function Layout(props: ParentProps) { const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)" + const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( @@ -1039,7 +1039,7 @@ export default function Layout(props: ParentProps) { 0 && props.notify}>
- - prefetchSession(props.session, "high")} - onFocus={() => prefetchSession(props.session, "high")} - > -
-
- }> - - - - -
- - -
- - 0}> -
- - -
+
prefetchSession(props.session, "high")} + onFocus={() => prefetchSession(props.session, "high")} + > +
+
+ }> + + + + + +
@@ -1203,7 +1209,7 @@ export default function Layout(props: ParentProps) { const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const local = createMemo(() => props.directory === props.project.worktree) @@ -1338,6 +1344,8 @@ export default function Layout(props: ParentProps) { const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)()) + const [open, setOpen] = createSignal(false) + const label = (directory: string) => { const [data] = globalSync.child(directory) const kind = directory === props.project.worktree ? "local" : "sandbox" @@ -1349,7 +1357,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(directory) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } @@ -1358,7 +1366,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(props.project.worktree) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } @@ -1370,7 +1378,8 @@ export default function Layout(props: ParentProps) { "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(), "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": - !selected(), + !selected() && !open(), + "bg-surface-base-hover border border-border-weak-base": !selected() && open(), }} onClick={() => navigateToProject(props.project.worktree)} > @@ -1381,9 +1390,17 @@ export default function Layout(props: ParentProps) { return ( // @ts-ignore
- +
-
Recent sessions
+
{displayName(props.project)}
+
Recent sessions
workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0) @@ -1610,7 +1627,16 @@ export default function Layout(props: ParentProps) { stopPropagation /> - + {project()?.worktree.replace(homedir(), "~")} @@ -1652,7 +1678,7 @@ export default function Layout(props: ParentProps) {
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index ca5e73a9be9..f063ce35b40 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -419,7 +419,6 @@ export default function Page() { { id: "session.new", title: "New session", - description: "Create a new session", category: "Session", keybind: "mod+shift+s", slash: "new", @@ -437,7 +436,7 @@ export default function Page() { { id: "terminal.toggle", title: "Toggle terminal", - description: "Show or hide the terminal", + description: "", category: "View", keybind: "ctrl+`", slash: "terminal", @@ -446,7 +445,7 @@ export default function Page() { { id: "review.toggle", title: "Toggle review", - description: "Show or hide the review panel", + description: "", category: "View", keybind: "mod+shift+r", onSelect: () => view().reviewPanel.toggle(), @@ -534,10 +533,6 @@ export default function Page() { keybind: "shift+mod+t", onSelect: () => { local.model.variant.cycle() - showToast({ - title: "Thinking effort changed", - description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"), - }) }, }, { @@ -655,6 +650,72 @@ export default function Page() { disabled: !params.id || visibleUserMessages().length === 0, onSelect: () => dialog.show(() => ), }, + ...(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) => { @@ -1092,7 +1153,7 @@ export default function Page() { file.load(path) }} classes={{ - root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + root: "pb-[calc(var(--prompt-height,8rem)+24px)]", header: "px-4", container: "px-4", }} @@ -1238,7 +1299,7 @@ export default function Page() { {/* Prompt input */}
(promptDock = el)} - class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" + class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" >
{ await tx .update(BillingTable) - .set({ subscriptionID: null, subscriptionCouponID: null }) + .set({ subscriptionID: null, subscription: null }) .where(eq(BillingTable.workspaceID, workspaceID)) await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID)) diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index 2546ad3ef15..a5f92a29acf 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -65,7 +65,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => buffer = newBuffer const messages = [] - while (buffer.length >= 4) { // first 4 bytes are the total length (big-endian) const totalLength = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false) @@ -121,7 +120,9 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => const parsedDataResult = JSON.parse(data) delete parsedDataResult.p - const bytes = atob(parsedDataResult.bytes) + const binary = atob(parsedDataResult.bytes) + const uint8 = Uint8Array.from(binary, (c) => c.charCodeAt(0)) + const bytes = decoder.decode(uint8) const eventName = JSON.parse(bytes).type messages.push([`event: ${eventName}`, "\n", `data: ${bytes}`, "\n\n"].join("")) } diff --git a/packages/console/core/migrations/0053_gigantic_hardball.sql b/packages/console/core/migrations/0053_gigantic_hardball.sql new file mode 100644 index 00000000000..72d43135f44 --- /dev/null +++ b/packages/console/core/migrations/0053_gigantic_hardball.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` ADD `subscription` json; \ No newline at end of file diff --git a/packages/console/core/migrations/0054_numerous_annihilus.sql b/packages/console/core/migrations/0054_numerous_annihilus.sql new file mode 100644 index 00000000000..299847db64f --- /dev/null +++ b/packages/console/core/migrations/0054_numerous_annihilus.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` DROP COLUMN `subscription_coupon_id`; \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0053_snapshot.json b/packages/console/core/migrations/meta/0053_snapshot.json new file mode 100644 index 00000000000..75a2cb7c929 --- /dev/null +++ b/packages/console/core/migrations/meta/0053_snapshot.json @@ -0,0 +1,1242 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "32a0c40b-a269-4ad1-a5a0-52b1f18932aa", + "prevId": "00774acd-a1e5-49c0-b296-cacc9506a566", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": ["provider", "subject"], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": ["time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription": { + "name": "subscription", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_coupon_id": { + "name": "subscription_coupon_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_plan": { + "name": "subscription_plan", + "type": "enum('20','100','200')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_subscription_booked": { + "name": "time_subscription_booked", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": ["customer_id"], + "isUnique": true + }, + "global_subscription_id": { + "name": "global_subscription_id", + "columns": ["subscription_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolling_usage": { + "name": "rolling_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fixed_usage": { + "name": "fixed_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_rolling_updated": { + "name": "time_rolling_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_fixed_updated": { + "name": "time_fixed_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_user_id": { + "name": "workspace_user_id", + "columns": ["workspace_id", "user_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscription_workspace_id_id_pk": { + "name": "subscription_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": ["workspace_id", "time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": ["ip", "interval"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": ["ip"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": ["workspace_id", "model"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": ["workspace_id", "provider"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": ["workspace_id", "account_id"], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": ["workspace_id", "email"], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": ["account_id"], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": ["email"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/console/core/migrations/meta/0054_snapshot.json b/packages/console/core/migrations/meta/0054_snapshot.json new file mode 100644 index 00000000000..a1e3851d857 --- /dev/null +++ b/packages/console/core/migrations/meta/0054_snapshot.json @@ -0,0 +1,1235 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "a0ade64b-b735-4a70-8d39-ebd84bc9e924", + "prevId": "32a0c40b-a269-4ad1-a5a0-52b1f18932aa", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": ["provider", "subject"], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": ["time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription": { + "name": "subscription", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_plan": { + "name": "subscription_plan", + "type": "enum('20','100','200')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_subscription_booked": { + "name": "time_subscription_booked", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": ["customer_id"], + "isUnique": true + }, + "global_subscription_id": { + "name": "global_subscription_id", + "columns": ["subscription_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolling_usage": { + "name": "rolling_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fixed_usage": { + "name": "fixed_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_rolling_updated": { + "name": "time_rolling_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_fixed_updated": { + "name": "time_fixed_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_user_id": { + "name": "workspace_user_id", + "columns": ["workspace_id", "user_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscription_workspace_id_id_pk": { + "name": "subscription_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": ["workspace_id", "time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": ["ip", "interval"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": ["ip"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": ["workspace_id", "model"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": ["workspace_id", "provider"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": ["workspace_id", "account_id"], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": ["workspace_id", "email"], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": ["account_id"], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": ["email"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index cdf4f63906d..dd0957e51ca 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -372,6 +372,20 @@ "when": 1768343920467, "tag": "0052_aromatic_agent_zero", "breakpoints": true + }, + { + "idx": 53, + "version": "5", + "when": 1768599366758, + "tag": "0053_gigantic_hardball", + "breakpoints": true + }, + { + "idx": 54, + "version": "5", + "when": 1768603665356, + "tag": "0054_numerous_annihilus", + "breakpoints": true } ] } diff --git a/packages/console/core/script/black-gift.ts b/packages/console/core/script/black-gift.ts new file mode 100644 index 00000000000..3fbf210ab5c --- /dev/null +++ b/packages/console/core/script/black-gift.ts @@ -0,0 +1,112 @@ +import { Billing } from "../src/billing.js" +import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js" +import { UserTable } from "../src/schema/user.sql.js" +import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js" +import { Identifier } from "../src/identifier.js" +import { centsToMicroCents } from "../src/util/price.js" +import { AuthTable } from "../src/schema/auth.sql.js" + +const plan = "200" +const workspaceID = process.argv[2] +const seats = parseInt(process.argv[3]) + +console.log(`Gifting ${seats} seats of Black to workspace ${workspaceID}`) + +if (!workspaceID || !seats) throw new Error("Usage: bun foo.ts ") + +// Get workspace user +const users = await Database.use((tx) => + tx + .select({ + id: UserTable.id, + role: UserTable.role, + email: AuthTable.subject, + }) + .from(UserTable) + .leftJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email"))) + .where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))), +) +if (users.length === 0) throw new Error(`Error: No users found in workspace ${workspaceID}`) +if (users.length !== seats) + throw new Error(`Error: Workspace ${workspaceID} has ${users.length} users, expected ${seats}`) +const adminUser = users.find((user) => user.role === "admin") +if (!adminUser) throw new Error(`Error: No admin user found in workspace ${workspaceID}`) +if (!adminUser.email) throw new Error(`Error: Admin user ${adminUser.id} has no email`) + +// Get Billing +const billing = await Database.use((tx) => + tx + .select({ + customerID: BillingTable.customerID, + subscriptionID: BillingTable.subscriptionID, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspaceID)) + .then((rows) => rows[0]), +) +if (!billing) throw new Error(`Error: Workspace ${workspaceID} has no billing record`) +if (billing.subscriptionID) throw new Error(`Error: Workspace ${workspaceID} already has a subscription`) + +// Look up the Stripe customer by email +const customerID = + billing.customerID ?? + (await (() => + Billing.stripe() + .customers.create({ + email: adminUser.email, + metadata: { + workspaceID, + }, + }) + .then((customer) => customer.id))()) +console.log(`Customer ID: ${customerID}`) + +const couponID = "JAIr0Pe1" +const subscription = await Billing.stripe().subscriptions.create({ + customer: customerID!, + items: [ + { + price: `price_1SmfyI2StuRr0lbXovxJNeZn`, + discounts: [{ coupon: couponID }], + quantity: 2, + }, + ], +}) +console.log(`Subscription ID: ${subscription.id}`) + +await Database.transaction(async (tx) => { + // Set customer id, subscription id, and payment method on workspace billing + await tx + .update(BillingTable) + .set({ + customerID, + subscriptionID: subscription.id, + subscription: { status: "subscribed", coupon: couponID, seats, plan }, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + + // Create a row in subscription table + for (const user of users) { + await tx.insert(SubscriptionTable).values({ + workspaceID, + id: Identifier.create("subscription"), + userID: user.id, + }) + } + // + // // Create a row in payments table + // await tx.insert(PaymentTable).values({ + // workspaceID, + // id: Identifier.create("payment"), + // amount: centsToMicroCents(amountInCents), + // customerID, + // invoiceID, + // paymentID, + // enrichment: { + // type: "subscription", + // couponID, + // }, + // }) +}) + +console.log(`done`) diff --git a/packages/console/core/script/onboard-zen-black.ts b/packages/console/core/script/black-onboard.ts similarity index 95% rename from packages/console/core/script/onboard-zen-black.ts rename to packages/console/core/script/black-onboard.ts index 3ee8809739d..77e5b779e35 100644 --- a/packages/console/core/script/onboard-zen-black.ts +++ b/packages/console/core/script/black-onboard.ts @@ -12,7 +12,7 @@ const email = process.argv[3] console.log(`Onboarding workspace ${workspaceID} for email ${email}`) if (!workspaceID || !email) { - console.error("Usage: bun onboard-zen-black.ts ") + console.error("Usage: bun foo.ts ") process.exit(1) } @@ -50,7 +50,7 @@ const existingSubscription = await Database.use((tx) => tx .select({ workspaceID: BillingTable.workspaceID }) .from(BillingTable) - .where(eq(BillingTable.subscriptionID, subscriptionID)) + .where(sql`JSON_EXTRACT(${BillingTable.subscription}, '$.id') = ${subscriptionID}`) .then((rows) => rows[0]), ) if (existingSubscription) { @@ -128,10 +128,15 @@ await Database.transaction(async (tx) => { .set({ customerID, subscriptionID, - subscriptionCouponID: couponID, paymentMethodID, paymentMethodLast4, paymentMethodType, + subscription: { + status: "subscribed", + coupon: couponID, + seats: 1, + plan: "200", + }, }) .where(eq(BillingTable.workspaceID, workspaceID)) diff --git a/packages/console/core/script/black-transfer.ts b/packages/console/core/script/black-transfer.ts index a7947fe7223..e962ba5d361 100644 --- a/packages/console/core/script/black-transfer.ts +++ b/packages/console/core/script/black-transfer.ts @@ -18,7 +18,7 @@ const fromBilling = await Database.use((tx) => .select({ customerID: BillingTable.customerID, subscriptionID: BillingTable.subscriptionID, - subscriptionCouponID: BillingTable.subscriptionCouponID, + subscription: BillingTable.subscription, paymentMethodID: BillingTable.paymentMethodID, paymentMethodType: BillingTable.paymentMethodType, paymentMethodLast4: BillingTable.paymentMethodLast4, @@ -119,7 +119,7 @@ await Database.transaction(async (tx) => { .set({ customerID: fromPrevPayment.customerID, subscriptionID: null, - subscriptionCouponID: null, + subscription: null, paymentMethodID: fromPrevPaymentMethods.data[0].id, paymentMethodLast4: fromPrevPaymentMethods.data[0].card?.last4 ?? null, paymentMethodType: fromPrevPaymentMethods.data[0].type, @@ -131,7 +131,7 @@ await Database.transaction(async (tx) => { .set({ customerID: fromBilling.customerID, subscriptionID: fromBilling.subscriptionID, - subscriptionCouponID: fromBilling.subscriptionCouponID, + subscription: fromBilling.subscription, paymentMethodID: fromBilling.paymentMethodID, paymentMethodLast4: fromBilling.paymentMethodLast4, paymentMethodType: fromBilling.paymentMethodType, diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index b3a104457ff..3dc5e7a968c 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -55,8 +55,9 @@ if (identifier.startsWith("wrk_")) { ), ) - // Get all payments for these workspaces - await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID))) + for (const user of users) { + await printWorkspace(user.workspaceID) + } } async function printWorkspace(workspaceID: string) { @@ -114,11 +115,11 @@ async function printWorkspace(workspaceID: string) { balance: BillingTable.balance, customerID: BillingTable.customerID, reload: BillingTable.reload, + subscriptionID: BillingTable.subscriptionID, subscription: { - id: BillingTable.subscriptionID, - couponID: BillingTable.subscriptionCouponID, plan: BillingTable.subscriptionPlan, booked: BillingTable.timeSubscriptionBooked, + enrichment: BillingTable.subscription, }, }) .from(BillingTable) @@ -128,8 +129,13 @@ async function printWorkspace(workspaceID: string) { rows.map((row) => ({ ...row, balance: `$${(row.balance / 100000000).toFixed(2)}`, - subscription: row.subscription.id - ? `Subscribed ${row.subscription.couponID ? `(coupon: ${row.subscription.couponID}) ` : ""}` + subscription: row.subscriptionID + ? [ + `Black ${row.subscription.enrichment!.plan}`, + row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "", + row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "", + `(ref: ${row.subscriptionID})`, + ].join(" ") : row.subscription.booked ? `Waitlist ${row.subscription.plan} plan` : undefined, diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index f052e6fc6fe..36e8a76b79d 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -218,6 +218,7 @@ export namespace Billing { customer: customer.customerID, customer_update: { name: "auto", + address: "auto", }, } : { diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index f1300f8498b..9f05919f240 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -21,8 +21,13 @@ export const BillingTable = mysqlTable( reloadError: varchar("reload_error", { length: 255 }), timeReloadError: utc("time_reload_error"), timeReloadLockedTill: utc("time_reload_locked_till"), + subscription: json("subscription").$type<{ + status: "subscribed" + coupon?: string + seats: number + plan: "20" | "100" | "200" + }>(), subscriptionID: varchar("subscription_id", { length: 28 }), - subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }), subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const), timeSubscriptionBooked: utc("time_subscription_booked"), }, diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 7a46ba8cde0..0d9e383790a 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -12,7 +12,7 @@ import { relaunch } from "@tauri-apps/plugin-process" import { AsyncStorage } from "@solid-primitives/storage" import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" -import { Logo } from "@opencode-ai/ui/logo" +import { Splash } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { UPDATER_ENABLED } from "./updater" @@ -26,6 +26,18 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { ) } +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 const createPlatform = (password: Accessor): Platform => ({ @@ -345,8 +357,7 @@ function ServerGate(props: { children: (data: Accessor) => JSX. when={serverData.state !== "pending" && serverData()} fallback={
- -
Initializing...
+
} > diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 757e6efde90..e1918193470 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -70,7 +70,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.1.1", + "@gitlab/gitlab-ai-provider": "3.1.2", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 61a665312f0..cb88db2c478 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -90,6 +90,11 @@ const targets = singleFlag return baselineFlag } + // also skip abi-specific builds for the same reason + if (item.abi !== undefined) { + return false + } + return true }) : allTargets diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index f8792393c60..6330fae97a2 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -20,7 +20,7 @@ import { } from "@agentclientprotocol/sdk" import { Log } from "../util/log" import { ACPSessionManager } from "./session" -import type { ACPConfig, ACPSessionState } from "./types" +import type { ACPConfig } from "./types" import { Provider } from "../provider/provider" import { Agent as AgentModule } from "../agent/agent" import { Installation } from "@/installation" @@ -29,7 +29,7 @@ import { Config } from "@/config/config" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" -import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" +import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" export namespace ACP { @@ -47,304 +47,354 @@ export namespace ACP { private connection: AgentSideConnection private config: ACPConfig private sdk: OpencodeClient - private sessionManager + private sessionManager: ACPSessionManager + private eventAbort = new AbortController() + private eventStarted = false + private permissionQueues = new Map>() + private permissionOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ] constructor(connection: AgentSideConnection, config: ACPConfig) { this.connection = connection this.config = config this.sdk = config.sdk this.sessionManager = new ACPSessionManager(this.sdk) + this.startEventSubscription() } - private setupEventSubscriptions(session: ACPSessionState) { - const sessionId = session.id - const directory = session.cwd + private startEventSubscription() { + if (this.eventStarted) return + this.eventStarted = true + this.runEventSubscription().catch((error) => { + if (this.eventAbort.signal.aborted) return + log.error("event subscription failed", { error }) + }) + } - const options: PermissionOption[] = [ - { optionId: "once", kind: "allow_once", name: "Allow once" }, - { optionId: "always", kind: "allow_always", name: "Always allow" }, - { optionId: "reject", kind: "reject_once", name: "Reject" }, - ] - this.config.sdk.event.subscribe({ directory }).then(async (events) => { + private async runEventSubscription() { + while (true) { + if (this.eventAbort.signal.aborted) return + const events = await this.sdk.global.event({ + signal: this.eventAbort.signal, + }) for await (const event of events.stream) { - switch (event.type) { - case "permission.asked": - try { - const permission = event.properties - const res = await this.connection - .requestPermission({ - sessionId, - toolCall: { - toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, - options, - }) - .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 (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, }) - if (!res) return - if (res.outcome.outcome !== "selected") { - await this.config.sdk.permission.reply({ + await this.sdk.permission.reply({ requestID: permission.id, reply: "reject", directory, }) - return - } - if (res.outcome.optionId !== "reject" && permission.permission == "edit") { - const metadata = permission.metadata || {} - const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" - const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" - - const content = await Bun.file(filepath).text() - const newContent = getNewContent(content, diff) - - if (newContent) { - this.connection.writeTextFile({ - sessionId: sessionId, - path: filepath, - content: newContent, - }) - } - } - await this.config.sdk.permission.reply({ + return undefined + }) + + if (!res) return + if (res.outcome.outcome !== "selected") { + await this.sdk.permission.reply({ requestID: permission.id, - reply: res.outcome.optionId as "once" | "always" | "reject", + reply: "reject", directory, }) - } catch (err) { - log.error("unexpected error when handling permission", { error: err }) - } finally { - break + return } - case "message.part.updated": - log.info("message part updated", { event: event.properties }) - try { - const props = event.properties - const { part } = props - - const message = await this.config.sdk.session - .message( - { - sessionID: part.sessionID, - messageID: part.messageID, - directory, + 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, + }) + } + } + + 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 + } + + 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: {}, }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined }) + .catch((error) => { + log.error("failed to send tool pending to ACP", { error }) + }) + return - 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.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((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) { + 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: "agent_message_chunk", - content: { - type: "text", - text: delta, - }, + 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 text to ACP", { error: err }) + .catch((error) => { + log.error("failed to send session update for todo", { error }) }) + } else { + log.error("failed to parse todo output", { error: parsedTodos.error }) } - } else if (part.type === "reasoning") { - const delta = props.delta - if (delta) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", + } + + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "completed", + kind, + content, + title: part.state.title, + rawInput: part.state.input, + rawOutput: { + output: part.state.output, + metadata: part.state.metadata, + }, + }, + }) + .catch((error) => { + log.error("failed to send tool completed to ACP", { error }) + }) + return + } + case "error": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "failed", + kind: toToolKind(part.tool), + title: part.tool, + rawInput: part.state.input, + content: [ + { + type: "content", content: { type: "text", - text: delta, + text: part.state.error, }, }, - }) - .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) - }) - } - } - } finally { - break - } + ], + rawOutput: { + error: part.state.error, + }, + }, + }) + .catch((error) => { + log.error("failed to send tool error to ACP", { error }) + }) + return + } + } + + if (part.type === "text") { + const delta = props.delta + if (delta && part.ignored !== true) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send text to ACP", { error }) + }) + } + return } + + if (part.type === "reasoning") { + const delta = props.delta + if (delta) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send reasoning to ACP", { error }) + }) + } + } + return } - }) + } } async initialize(params: InitializeRequest): Promise { @@ -409,8 +459,6 @@ export namespace ACP { sessionId, }) - this.setupEventSubscriptions(state) - return { sessionId, models: load.models, @@ -436,18 +484,16 @@ export namespace ACP { const model = await defaultModel(this.config, directory) // Store ACP session state - const state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) - const mode = await this.loadSessionMode({ + const result = await this.loadSessionMode({ cwd: directory, mcpServers: params.mcpServers, sessionId, }) - this.setupEventSubscriptions(state) - // Replay session history const messages = await this.sdk.session .messages( @@ -463,12 +509,20 @@ export namespace ACP { return undefined }) + const lastUser = messages?.findLast((m) => m.info.role === "user")?.info + if (lastUser?.role === "user") { + result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` + if (result.modes.availableModes.some((m) => m.id === lastUser.agent)) { + result.modes.currentModeId = lastUser.agent + } + } + for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) } - return mode + return result } catch (e) { const error = MessageV2.fromError(e, { providerID: this.config.defaultModel?.providerID ?? "unknown", @@ -633,7 +687,7 @@ export namespace ACP { break } } else if (part.type === "text") { - if (part.text) { + if (part.text && !part.ignored) { await this.connection .sessionUpdate({ sessionId, @@ -649,6 +703,83 @@ export namespace ACP { log.error("failed to send text to ACP", { error: err }) }) } + } else if (part.type === "file") { + // Replay file attachments as appropriate ACP content blocks. + // OpenCode stores files internally as { type: "file", url, filename, mime }. + // We convert these back to ACP blocks based on the URL scheme and MIME type: + // - file:// URLs → resource_link + // - data: URLs with image/* → image block + // - data: URLs with text/* or application/json → resource with text + // - data: URLs with other types → resource with blob + const url = part.url + const filename = part.filename ?? "file" + const mime = part.mime || "application/octet-stream" + const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" + + if (url.startsWith("file://")) { + // Local file reference - send as resource_link + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + content: { type: "resource_link", uri: url, name: filename, mimeType: mime }, + }, + }) + .catch((err) => { + log.error("failed to send resource_link to ACP", { error: err }) + }) + } else if (url.startsWith("data:")) { + // Embedded content - parse data URL and send as appropriate block type + const base64Match = url.match(/^data:([^;]+);base64,(.*)$/) + const dataMime = base64Match?.[1] + const base64Data = base64Match?.[2] ?? "" + + const effectiveMime = dataMime || mime + + if (effectiveMime.startsWith("image/")) { + // Image - send as image block + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + content: { + type: "image", + mimeType: effectiveMime, + data: base64Data, + uri: `file://${filename}`, + }, + }, + }) + .catch((err) => { + log.error("failed to send image to ACP", { error: err }) + }) + } else { + // Non-image: text types get decoded, binary types stay as blob + const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" + const resource = isText + ? { + uri: `file://${filename}`, + mimeType: effectiveMime, + text: Buffer.from(base64Data, "base64").toString("utf-8"), + } + : { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data } + + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + content: { type: "resource", resource }, + }, + }) + .catch((err) => { + log.error("failed to send resource to ACP", { error: err }) + }) + } + } + // URLs that don't match file:// or data: are skipped (unsupported) } else if (part.type === "reasoning") { if (part.text) { await this.connection @@ -847,39 +978,57 @@ export namespace ACP { text: part.text, }) break - case "image": + case "image": { + const parsed = parseUri(part.uri ?? "") + const filename = parsed.type === "file" ? parsed.filename : "image" if (part.data) { parts.push({ type: "file", url: `data:${part.mimeType};base64,${part.data}`, - filename: "image", + filename, mime: part.mimeType, }) } else if (part.uri && part.uri.startsWith("http:")) { parts.push({ type: "file", url: part.uri, - filename: "image", + filename, mime: part.mimeType, }) } break + } case "resource_link": const parsed = parseUri(part.uri) + // Use the name from resource_link if available + if (part.name && parsed.type === "file") { + parsed.filename = part.name + } parts.push(parsed) break - case "resource": + case "resource": { const resource = part.resource - if ("text" in resource) { + if ("text" in resource && resource.text) { parts.push({ type: "text", text: resource.text, }) + } else if ("blob" in resource && resource.blob && resource.mimeType) { + // Binary resource (PDFs, etc.): store as file part with data URL + const parsed = parseUri(resource.uri ?? "") + const filename = parsed.type === "file" ? parsed.filename : "file" + parts.push({ + type: "file", + url: `data:${resource.mimeType};base64,${resource.blob}`, + filename, + mime: resource.mimeType, + }) } break + } default: break diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 70b65834705..151fa5646ba 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -13,6 +13,10 @@ export class ACPSessionManager { this.sdk = sdk } + tryGet(sessionId: string): ACPSessionState | undefined { + return this.sessions.get(sessionId) + } + async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise { const session = await this.sdk.session .create( diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index ef6b0c4fc92..d1236ff40bc 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -70,8 +70,8 @@ export const AgentCommand = cmd({ }) async function getAvailableTools(agent: Agent.Info) { - const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID - return ToolRegistry.tools(providerID, agent) + const model = agent.model ?? (await Provider.defaultModel()) + return ToolRegistry.tools(model, agent) } async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index fedad92856f..95719215e32 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -6,7 +6,6 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" -import { McpOAuthCallback } from "../../mcp/oauth-callback" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "../../config/config" import { Instance } from "../../project/instance" @@ -683,10 +682,6 @@ export const McpDebugCommand = cmd({ // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - - // Start callback server - await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) - const authProvider = new McpOAuthProvider( serverName, serverConfig.url, @@ -694,7 +689,6 @@ export const McpDebugCommand = cmd({ clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async () => {}, diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1fea3f4b305..4b177e292cf 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -200,11 +200,6 @@ function App() { renderer.console.onCopySelection = async (text: string) => { if (!text || text.length === 0) return - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - // @ts-expect-error writeOut is not in type definitions - renderer.writeOut(finalOsc52) await Clipboard.copy(text) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) @@ -293,6 +288,10 @@ function App() { keybind: "session_list", category: "Session", suggested: sync.data.session.length > 0, + slash: { + name: "sessions", + aliases: ["resume", "continue"], + }, onSelect: () => { dialog.replace(() => ) }, @@ -303,6 +302,10 @@ function App() { value: "session.new", keybind: "session_new", category: "Session", + slash: { + name: "new", + aliases: ["clear"], + }, onSelect: () => { const current = promptRef.current // Don't require focus - if there's any text, preserve it @@ -320,26 +323,29 @@ function App() { keybind: "model_list", suggested: true, category: "Agent", + slash: { + name: "models", + }, onSelect: () => { dialog.replace(() => ) }, }, { 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) }, @@ -349,6 +355,7 @@ function App() { value: "model.cycle_favorite", keybind: "model_cycle_favorite", category: "Agent", + hidden: true, onSelect: () => { local.model.cycleFavorite(1) }, @@ -358,6 +365,7 @@ function App() { value: "model.cycle_favorite_reverse", keybind: "model_cycle_favorite_reverse", category: "Agent", + hidden: true, onSelect: () => { local.model.cycleFavorite(-1) }, @@ -367,6 +375,9 @@ function App() { value: "agent.list", keybind: "agent_list", category: "Agent", + slash: { + name: "agents", + }, onSelect: () => { dialog.replace(() => ) }, @@ -375,6 +386,9 @@ function App() { title: "Toggle MCPs", value: "mcp.list", category: "Agent", + slash: { + name: "mcps", + }, onSelect: () => { dialog.replace(() => ) }, @@ -384,7 +398,7 @@ function App() { value: "agent.cycle", keybind: "agent_cycle", category: "Agent", - disabled: true, + hidden: true, onSelect: () => { local.agent.move(1) }, @@ -394,6 +408,7 @@ function App() { value: "variant.cycle", keybind: "variant_cycle", category: "Agent", + hidden: true, onSelect: () => { local.model.variant.cycle() }, @@ -403,7 +418,7 @@ function App() { value: "agent.cycle.reverse", keybind: "agent_cycle_reverse", category: "Agent", - disabled: true, + hidden: true, onSelect: () => { local.agent.move(-1) }, @@ -412,6 +427,9 @@ function App() { title: "Connect provider", value: "provider.connect", suggested: !connected(), + slash: { + name: "connect", + }, onSelect: () => { dialog.replace(() => ) }, @@ -421,6 +439,9 @@ function App() { title: "View status", keybind: "status_view", value: "opencode.status", + slash: { + name: "status", + }, onSelect: () => { dialog.replace(() => ) }, @@ -430,6 +451,9 @@ function App() { title: "Switch theme", value: "theme.switch", keybind: "theme_list", + slash: { + name: "themes", + }, onSelect: () => { dialog.replace(() => ) }, @@ -447,6 +471,9 @@ function App() { { title: "Help", value: "help.show", + slash: { + name: "help", + }, onSelect: () => { dialog.replace(() => ) }, @@ -473,6 +500,10 @@ function App() { { title: "Exit the app", value: "app.exit", + slash: { + name: "exit", + aliases: ["quit", "q"], + }, onSelect: () => exit(), category: "System", }, @@ -513,6 +544,7 @@ function App() { value: "terminal.suspend", keybind: "terminal_suspend", category: "System", + hidden: true, onSelect: () => { process.once("SIGCONT", () => { renderer.resume() @@ -627,11 +659,6 @@ function App() { } const text = renderer.getSelection()?.getSelectedText() if (text && text.length > 0) { - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - /* @ts-expect-error */ - renderer.writeOut(finalOsc52) await Clipboard.copy(text) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index d19e93188b2..38dc402758b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -16,9 +16,17 @@ import type { KeybindsConfig } from "@opencode-ai/sdk/v2" type Context = ReturnType const ctx = createContext() -export type CommandOption = DialogSelectOption & { +export type Slash = { + name: string + aliases?: string[] +} + +export type CommandOption = DialogSelectOption & { keybind?: keyof KeybindsConfig suggested?: boolean + slash?: Slash + hidden?: boolean + enabled?: boolean } function init() { @@ -26,27 +34,35 @@ function init() { const [suspendCount, setSuspendCount] = createSignal(0) const dialog = useDialog() const keybind = useKeybind() - const options = createMemo(() => { + + const entries = createMemo(() => { const all = registrations().flatMap((x) => x()) - const suggested = all.filter((x) => x.suggested) - return [ - ...suggested.map((x) => ({ - ...x, - category: "Suggested", - value: "suggested." + x.value, - })), - ...all, - ].map((x) => ({ + return all.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined, })) }) + + const isEnabled = (option: CommandOption) => option.enabled !== false + const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden + + const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option))) + const suggestedOptions = createMemo(() => + visibleOptions() + .filter((option) => option.suggested) + .map((option) => ({ + ...option, + value: `suggested:${option.value}`, + category: "Suggested", + })), + ) const suspended = () => suspendCount() > 0 useKeyboard((evt) => { if (suspended()) return if (dialog.stack.length > 0) return - for (const option of options()) { + for (const option of entries()) { + if (!isEnabled(option)) continue if (option.keybind && keybind.match(option.keybind, evt)) { evt.preventDefault() option.onSelect?.(dialog) @@ -56,20 +72,33 @@ function init() { }) const result = { - trigger(name: string, source?: "prompt") { - for (const option of options()) { + trigger(name: string) { + for (const option of entries()) { if (option.value === name) { - option.onSelect?.(dialog, source) + if (!isEnabled(option)) return + option.onSelect?.(dialog) return } } }, + slashes() { + return visibleOptions().flatMap((option) => { + const slash = option.slash + if (!slash) return [] + return { + display: "/" + slash.name, + description: option.description ?? option.title, + aliases: slash.aliases?.map((alias) => "/" + alias), + onSelect: () => result.trigger(option.value), + } + }) + }, keybinds(enabled: boolean) { setSuspendCount((count) => count + (enabled ? -1 : 1)) }, suspended, show() { - dialog.replace(() => ) + dialog.replace(() => ) }, register(cb: () => CommandOption[]) { const results = createMemo(cb) @@ -78,9 +107,6 @@ function init() { setRegistrations((arr) => arr.filter((x) => x !== results)) }) }, - get options() { - return options() - }, } return result } @@ -104,7 +130,7 @@ export function CommandProvider(props: ParentProps) { if (evt.defaultPrevented) return if (keybind.match("command_list", evt)) { evt.preventDefault() - dialog.replace(() => ) + value.show() return } }) @@ -112,13 +138,11 @@ export function CommandProvider(props: ParentProps) { return {props.children} } -function DialogCommand(props: { options: CommandOption[] }) { +function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) { let ref: DialogSelectRef - return ( - (ref = r)} - title="Commands" - options={props.options.filter((x) => !ref?.filter || !x.value.startsWith("suggested."))} - /> - ) + const list = () => { + if (ref?.filter) return props.options + return [...props.suggestedOptions, ...props.options] + } + return (ref = r)} title="Commands" options={list()} /> } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 601eb82bc48..e27c32dfb2e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -332,16 +332,15 @@ export function Autocomplete(props: { ) }) - const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined)) const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [] - const s = session() - for (const command of sync.data.command) { + const results: AutocompleteOption[] = [...command.slashes()] + + for (const serverCommand of sync.data.command) { results.push({ - display: "/" + command.name + (command.mcp ? " (MCP)" : ""), - description: command.description, + display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""), + description: serverCommand.description, onSelect: () => { - const newText = "/" + command.name + " " + const newText = "/" + serverCommand.name + " " const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) @@ -349,138 +348,9 @@ export function Autocomplete(props: { }, }) } - if (s) { - results.push( - { - display: "/undo", - description: "undo the last message", - onSelect: () => { - command.trigger("session.undo") - }, - }, - { - display: "/redo", - description: "redo the last message", - onSelect: () => command.trigger("session.redo"), - }, - { - display: "/compact", - aliases: ["/summarize"], - description: "compact the session", - onSelect: () => command.trigger("session.compact"), - }, - { - display: "/unshare", - disabled: !s.share, - description: "unshare a session", - onSelect: () => command.trigger("session.unshare"), - }, - { - display: "/rename", - description: "rename session", - onSelect: () => command.trigger("session.rename"), - }, - { - display: "/copy", - description: "copy session transcript to clipboard", - onSelect: () => command.trigger("session.copy"), - }, - { - display: "/export", - description: "export session transcript to file", - onSelect: () => command.trigger("session.export"), - }, - { - display: "/timeline", - description: "jump to message", - onSelect: () => command.trigger("session.timeline"), - }, - { - display: "/fork", - description: "fork from message", - onSelect: () => command.trigger("session.fork"), - }, - { - display: "/thinking", - description: "toggle thinking visibility", - onSelect: () => command.trigger("session.toggle.thinking"), - }, - ) - if (sync.data.config.share !== "disabled") { - results.push({ - display: "/share", - disabled: !!s.share?.url, - description: "share a session", - onSelect: () => command.trigger("session.share"), - }) - } - } - results.push( - { - display: "/new", - aliases: ["/clear"], - description: "create a new session", - onSelect: () => command.trigger("session.new"), - }, - { - display: "/models", - description: "list models", - onSelect: () => command.trigger("model.list"), - }, - { - display: "/agents", - description: "list agents", - onSelect: () => command.trigger("agent.list"), - }, - { - display: "/session", - aliases: ["/resume", "/continue"], - description: "list sessions", - onSelect: () => command.trigger("session.list"), - }, - { - display: "/status", - description: "show status", - onSelect: () => command.trigger("opencode.status"), - }, - { - display: "/mcp", - description: "toggle MCPs", - onSelect: () => command.trigger("mcp.list"), - }, - { - display: "/theme", - description: "toggle theme", - onSelect: () => command.trigger("theme.switch"), - }, - { - display: "/editor", - description: "open editor", - onSelect: () => command.trigger("prompt.editor", "prompt"), - }, - { - display: "/connect", - description: "connect to a provider", - onSelect: () => command.trigger("provider.connect"), - }, - { - display: "/help", - description: "show help", - onSelect: () => command.trigger("help.show"), - }, - { - display: "/commands", - description: "show all commands", - onSelect: () => command.show(), - }, - { - display: "/exit", - aliases: ["/quit", "/q"], - description: "exit the app", - onSelect: () => command.trigger("app.exit"), - }, - ) + results.sort((a, b) => a.display.localeCompare(b.display)) + const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length if (!max) return results return results.map((item) => ({ @@ -494,9 +364,8 @@ export function Autocomplete(props: { const agentsValue = agents() const commandsValue = commands() - const mixed: AutocompleteOption[] = ( + const mixed: AutocompleteOption[] = store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] - ).filter((x) => x.disabled !== true) const currentFilter = filter() diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 96b9e8ffd57..cfd3b95dab6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -59,6 +59,20 @@ export function Prompt(props: PromptProps) { let anchor: BoxRenderable let autocomplete: AutocompleteRef + // Paste coalescing: buffer rapid consecutive paste events (e.g., from MobaXterm + // which fragments large pastes into multiple bracketed paste sequences) + const pasteBuffer: { chunks: string[]; timer: Timer | null } = { + chunks: [], + timer: null, + } + const [isPasting, setIsPasting] = createSignal(false) + const PASTE_DEBOUNCE_MS = 150 + + // Cleanup paste timer on unmount + onCleanup(() => { + if (pasteBuffer.timer) clearTimeout(pasteBuffer.timer) + }) + const keybind = useKeybind() const local = useLocal() const sdk = useSDK() @@ -145,9 +159,9 @@ export function Prompt(props: PromptProps) { const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) if (msg.agent && isPrimaryAgent) { local.agent.set(msg.agent) + if (msg.model) local.model.set(msg.model) + if (msg.variant) local.model.variant.set(msg.variant) } - if (msg.model) local.model.set(msg.model) - if (msg.variant) local.model.variant.set(msg.variant) } }) @@ -157,7 +171,7 @@ export function Prompt(props: PromptProps) { title: "Clear prompt", value: "prompt.clear", category: "Prompt", - disabled: true, + hidden: true, onSelect: (dialog) => { input.extmarks.clear() input.clear() @@ -167,9 +181,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 +193,9 @@ export function Prompt(props: PromptProps) { { title: "Paste", value: "prompt.paste", - disabled: true, keybind: "input_paste", category: "Prompt", + hidden: true, onSelect: async () => { const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { @@ -197,8 +211,9 @@ export function Prompt(props: PromptProps) { title: "Interrupt session", value: "session.interrupt", keybind: "session_interrupt", - disabled: status().type === "idle", category: "Session", + hidden: true, + enabled: status().type !== "idle", onSelect: (dialog) => { if (autocomplete.visible) return if (!input.focused) return @@ -229,7 +244,10 @@ export function Prompt(props: PromptProps) { category: "Session", keybind: "editor_open", value: "prompt.editor", - onSelect: async (dialog, trigger) => { + slash: { + name: "editor", + }, + onSelect: async (dialog) => { dialog.clear() // replace summarized text parts with the actual text @@ -242,7 +260,7 @@ export function Prompt(props: PromptProps) { const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") - const value = trigger === "prompt" ? "" : text + const value = text const content = await Editor.open({ value, renderer }) if (!content) return @@ -432,7 +450,7 @@ export function Prompt(props: PromptProps) { title: "Stash prompt", value: "prompt.stash", category: "Prompt", - disabled: !store.prompt.input, + enabled: !!store.prompt.input, onSelect: (dialog) => { if (!store.prompt.input) return stash.push({ @@ -450,7 +468,7 @@ export function Prompt(props: PromptProps) { title: "Stash pop", value: "prompt.stash.pop", category: "Prompt", - disabled: stash.list().length === 0, + enabled: stash.list().length > 0, onSelect: (dialog) => { const entry = stash.pop() if (entry) { @@ -466,7 +484,7 @@ export function Prompt(props: PromptProps) { title: "Stash list", value: "prompt.stash.list", category: "Prompt", - disabled: stash.list().length === 0, + enabled: stash.list().length > 0, onSelect: (dialog) => { dialog.replace(() => ( {}) + if (content) { + pasteText(content, `[SVG: ${file.name ?? "image"}]`) + return + } + } + if (file.type.startsWith("image/")) { + const content = await file + .arrayBuffer() + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => {}) + if (content) { + await pasteImage({ + filename: file.name, + mime: file.type, + content, + }) + return + } + } + } catch {} + } + + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + if ((lineCount >= 3 || pastedContent.length > 150) && !sync.data.config.experimental?.disable_paste_summary) { + pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + return + } + + input.insertText(pastedContent) + setTimeout(() => { + input.getLayoutNode().markDirty() + input.gotoBufferEnd() + renderer.requestRender() + }, 0) + } + const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary @@ -852,71 +922,34 @@ export function Prompt(props: PromptProps) { } }} onSubmit={submit} - onPaste={async (event: PasteEvent) => { - if (props.disabled) { - event.preventDefault() - return - } + onPaste={(event: PasteEvent) => { + event.preventDefault() + if (props.disabled) return // Normalize line endings at the boundary // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste // Replace CRLF first, then any remaining CR const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") - const pastedContent = normalizedText.trim() - if (!pastedContent) { - command.trigger("prompt.paste") - return - } - // trim ' from the beginning and end of the pasted content. just - // ' and nothing else - const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") - const isUrl = /^(https?):\/\//.test(filepath) - if (!isUrl) { + // Buffer the paste content for coalescing + // Some terminals (e.g., MobaXterm) fragment large pastes into multiple + // bracketed paste sequences, which would otherwise trigger premature submit + pasteBuffer.chunks.push(normalizedText) + setIsPasting(true) + + // Reset the debounce timer + if (pasteBuffer.timer) clearTimeout(pasteBuffer.timer) + pasteBuffer.timer = setTimeout(async () => { + const coalesced = pasteBuffer.chunks.join("").trim() + pasteBuffer.chunks = [] + pasteBuffer.timer = null try { - const file = Bun.file(filepath) - // Handle SVG as raw text content, not as base64 image - if (file.type === "image/svg+xml") { - event.preventDefault() - const content = await file.text().catch(() => {}) - if (content) { - pasteText(content, `[SVG: ${file.name ?? "image"}]`) - return - } - } - if (file.type.startsWith("image/")) { - event.preventDefault() - const content = await file - .arrayBuffer() - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteImage({ - filename: file.name, - mime: file.type, - content, - }) - return - } - } - } catch {} - } - - const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ( - (lineCount >= 3 || pastedContent.length > 150) && - !sync.data.config.experimental?.disable_paste_summary - ) { - event.preventDefault() - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) - return - } - - // Force layout update and render for the pasted content - setTimeout(() => { - input.getLayoutNode().markDirty() - renderer.requestRender() - }, 0) + await processCoalescedPaste(coalesced) + } finally { + // Only clear isPasting if no new paste arrived during processing + if (!pasteBuffer.timer) setIsPasting(false) + } + }, PASTE_DEBOUNCE_MS) }} ref={(r: TextareaRenderable) => { input = r @@ -1065,9 +1098,11 @@ export function Prompt(props: PromptProps) { - - {keybind.print("variant_cycle")} variants - + 0}> + + {keybind.print("variant_cycle")} variants + + {keybind.print("agent_cycle")} agents diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 63f1d9743bf..d058ce54fb3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -113,8 +113,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const file = Bun.file(path.join(Global.Path.state, "model.json")) + const state = { + pending: false, + } function save() { + if (!modelStore.ready) { + state.pending = true + return + } + state.pending = false Bun.write( file, JSON.stringify({ @@ -135,6 +143,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .catch(() => {}) .finally(() => { setModelStore("ready", true) + if (state.pending) save() }) const args = useArgs() diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index 7c75523c136..9466ae54f2d 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -16,6 +16,8 @@ export const TuiEvent = { "session.compact", "session.page.up", "session.page.down", + "session.line.up", + "session.line.down", "session.half.page.up", "session.half.page.down", "session.first", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d91363954a1..1294ab849e9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -39,7 +39,7 @@ import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" import type { ListTool } from "@/tool/ls" import type { EditTool } from "@/tool/edit" -import type { PatchTool } from "@/tool/patch" +import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" @@ -295,37 +295,39 @@ export function Session() { const command = useCommandDialog() command.register(() => [ - ...(sync.data.config.share !== "disabled" - ? [ - { - title: "Share session", - value: "session.share", - suggested: route.type === "session", - keybind: "session_share" as const, - disabled: !!session()?.share?.url, - category: "Session", - onSelect: async (dialog: any) => { - await sdk.client.session - .share({ - sessionID: route.sessionID, - }) - .then((res) => - Clipboard.copy(res.data!.share!.url).catch(() => - toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }), - ), - ) - .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) - .catch(() => toast.show({ message: "Failed to share session", variant: "error" })) - dialog.clear() - }, - }, - ] - : []), + { + title: "Share session", + value: "session.share", + suggested: route.type === "session", + keybind: "session_share", + category: "Session", + enabled: sync.data.config.share !== "disabled" && !session()?.share?.url, + slash: { + name: "share", + }, + onSelect: async (dialog) => { + await sdk.client.session + .share({ + sessionID: route.sessionID, + }) + .then((res) => + Clipboard.copy(res.data!.share!.url).catch(() => + toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }), + ), + ) + .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) + .catch(() => toast.show({ message: "Failed to share session", variant: "error" })) + dialog.clear() + }, + }, { title: "Rename session", value: "session.rename", keybind: "session_rename", category: "Session", + slash: { + name: "rename", + }, onSelect: (dialog) => { dialog.replace(() => ) }, @@ -335,6 +337,9 @@ export function Session() { value: "session.timeline", keybind: "session_timeline", category: "Session", + slash: { + name: "timeline", + }, onSelect: (dialog) => { dialog.replace(() => ( { dialog.replace(() => ( { const selectedModel = local.model.current() if (!selectedModel) { @@ -396,8 +408,11 @@ export function Session() { title: "Unshare session", value: "session.unshare", keybind: "session_unshare", - disabled: !session()?.share?.url, category: "Session", + enabled: !!session()?.share?.url, + slash: { + name: "unshare", + }, onSelect: async (dialog) => { await sdk.client.session .unshare({ @@ -413,6 +428,9 @@ export function Session() { value: "session.undo", keybind: "messages_undo", category: "Session", + slash: { + name: "undo", + }, onSelect: async (dialog) => { const status = sync.data.session_status?.[route.sessionID] if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) @@ -447,8 +465,11 @@ export function Session() { title: "Redo", value: "session.redo", keybind: "messages_redo", - disabled: !session()?.revert?.messageID, category: "Session", + enabled: !!session()?.revert?.messageID, + slash: { + name: "redo", + }, onSelect: (dialog) => { dialog.clear() const messageID = session()?.revert?.messageID @@ -495,6 +516,10 @@ export function Session() { title: showTimestamps() ? "Hide timestamps" : "Show timestamps", value: "session.toggle.timestamps", category: "Session", + slash: { + name: "timestamps", + aliases: ["toggle-timestamps"], + }, onSelect: (dialog) => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() @@ -504,6 +529,10 @@ export function Session() { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", category: "Session", + slash: { + name: "thinking", + aliases: ["toggle-thinking"], + }, onSelect: (dialog) => { setShowThinking((prev) => !prev) dialog.clear() @@ -513,6 +542,9 @@ export function Session() { title: "Toggle diff wrapping", value: "session.toggle.diffwrap", category: "Session", + slash: { + name: "diffwrap", + }, onSelect: (dialog) => { setDiffWrapMode((prev) => (prev === "word" ? "none" : "word")) dialog.clear() @@ -552,7 +584,7 @@ export function Session() { value: "session.page.up", keybind: "messages_page_up", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 2) dialog.clear() @@ -563,18 +595,40 @@ export function Session() { value: "session.page.down", keybind: "messages_page_down", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 2) dialog.clear() }, }, + { + title: "Line up", + value: "session.line.up", + keybind: "messages_line_up", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(-1) + dialog.clear() + }, + }, + { + title: "Line down", + value: "session.line.down", + keybind: "messages_line_down", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(1) + dialog.clear() + }, + }, { title: "Half page up", value: "session.half.page.up", keybind: "messages_half_page_up", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 4) dialog.clear() @@ -585,7 +639,7 @@ export function Session() { value: "session.half.page.down", keybind: "messages_half_page_down", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 4) dialog.clear() @@ -596,7 +650,7 @@ export function Session() { value: "session.first", keybind: "messages_first", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollTo(0) dialog.clear() @@ -607,7 +661,7 @@ export function Session() { value: "session.last", keybind: "messages_last", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollTo(scroll.scrollHeight) dialog.clear() @@ -618,6 +672,7 @@ export function Session() { value: "session.messages_last_user", keybind: "messages_last_user", category: "Session", + hidden: true, onSelect: () => { const messages = sync.data.message[route.sessionID] if (!messages || !messages.length) return @@ -649,7 +704,7 @@ export function Session() { value: "session.message.next", keybind: "messages_next", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => scrollToMessage("next", dialog), }, { @@ -657,7 +712,7 @@ export function Session() { value: "session.message.previous", keybind: "messages_previous", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => scrollToMessage("prev", dialog), }, { @@ -697,11 +752,6 @@ export function Session() { return } - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - /* @ts-expect-error */ - renderer.writeOut(finalOsc52) Clipboard.copy(text) .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" })) .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) @@ -711,8 +761,10 @@ export function Session() { { title: "Copy session transcript", value: "session.copy", - keybind: "session_copy", category: "Session", + slash: { + name: "copy", + }, onSelect: async (dialog) => { try { const sessionData = session() @@ -740,6 +792,9 @@ export function Session() { value: "session.export", keybind: "session_export", category: "Session", + slash: { + name: "export", + }, onSelect: async (dialog) => { try { const sessionData = session() @@ -798,7 +853,7 @@ export function Session() { value: "session.child.next", keybind: "session_child_cycle", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { moveChild(1) dialog.clear() @@ -809,7 +864,7 @@ export function Session() { value: "session.child.previous", keybind: "session_child_cycle_reverse", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { moveChild(-1) dialog.clear() @@ -820,7 +875,7 @@ export function Session() { value: "session.parent", keybind: "session_parent", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { const parentID = session()?.parentID if (parentID) { @@ -1390,8 +1445,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess - - + + @@ -1840,20 +1895,74 @@ function Edit(props: ToolProps) { ) } -function Patch(props: ToolProps) { - const { theme } = useTheme() +function ApplyPatch(props: ToolProps) { + const ctx = use() + const { theme, syntax } = useTheme() + + const files = createMemo(() => props.metadata.files ?? []) + + const view = createMemo(() => { + const diffStyle = ctx.sync.data.config.tui?.diff_style + if (diffStyle === "stacked") return "unified" + return ctx.width > 120 ? "split" : "unified" + }) + + function Diff(p: { diff: string; filePath: string }) { + return ( + + + + ) + } + + function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { + if (file.type === "delete") return "# Deleted " + file.relativePath + if (file.type === "add") return "# Created " + file.relativePath + if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath + return "← Patched " + file.relativePath + } + return ( - - - - {props.output?.trim()} - - + 0}> + + {(file) => ( + + + -{file.deletions} line{file.deletions !== 1 ? "s" : ""} + + } + > + + + + )} + - - Patch + + apply_patch diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index f7d7306d015..5c37a493dfa 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -38,7 +38,7 @@ export interface DialogSelectOption { disabled?: boolean bg?: RGBA gutter?: JSX.Element - onSelect?: (ctx: DialogContext, trigger?: "prompt") => void + onSelect?: (ctx: DialogContext) => void } export type DialogSelectRef = { @@ -161,6 +161,8 @@ export function DialogSelect(props: DialogSelectProps) { 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) { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 4477d301562..57375ba09db 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -141,11 +141,6 @@ export function DialogProvider(props: ParentProps) { onMouseUp={async () => { const text = renderer.getSelection()?.getSelectedText() if (text && text.length > 0) { - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - /* @ts-expect-error */ - renderer.writeOut(finalOsc52) await Clipboard.copy(text) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 9c91cf3055a..0e287fbc41a 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -5,6 +5,21 @@ import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" +/** + * Writes text to clipboard via OSC 52 escape sequence. + * This allows clipboard operations to work over SSH by having + * the terminal emulator handle the clipboard locally. + */ +function writeOsc52(text: string): void { + if (!process.stdout.isTTY) return + const base64 = Buffer.from(text).toString("base64") + const osc52 = `\x1b]52;c;${base64}\x07` + // tmux and screen require DCS passthrough wrapping + const passthrough = process.env["TMUX"] || process.env["STY"] + const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 + process.stdout.write(sequence) +} + export namespace Clipboard { export interface Content { data: string @@ -110,9 +125,25 @@ export namespace Clipboard { if (os === "win32") { console.log("clipboard: using powershell") return async (text: string) => { - // 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() + // 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(() => {}) } } @@ -123,6 +154,7 @@ export namespace Clipboard { }) export async function copy(text: string): Promise { + writeOsc52(text) await getCopyMethod()(text) } } diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 62210d57586..704d3572bbb 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -133,6 +133,8 @@ async function showRemovalSummary(targets: RemovalTargets, method: Installation. bun: "bun remove -g opencode-ai", yarn: "yarn global remove opencode-ai", brew: "brew uninstall opencode", + choco: "choco uninstall opencode", + scoop: "scoop uninstall opencode", } prompts.log.info(` ✓ Package: ${cmds[method] || method}`) } @@ -182,16 +184,27 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar bun: ["bun", "remove", "-g", "opencode-ai"], yarn: ["yarn", "global", "remove", "opencode-ai"], brew: ["brew", "uninstall", "opencode"], + choco: ["choco", "uninstall", "opencode"], + scoop: ["scoop", "uninstall", "opencode"], } const cmd = cmds[method] if (cmd) { spinner.start(`Running ${cmd.join(" ")}...`) - const result = await $`${cmd}`.quiet().nothrow() + const result = + method === "choco" + ? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow() + : await $`${cmd}`.quiet().nothrow() if (result.exitCode !== 0) { - spinner.stop(`Package manager uninstall failed`, 1) - prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`) - errors.push(`Package manager: exit code ${result.exitCode}`) + spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1) + if ( + method === "choco" && + result.stdout.toString("utf8").includes("not running from an elevated command shell") + ) { + prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`) + } else { + prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`) + } } else { spinner.stop("Package removed") } diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 2c207ecc2f2..5fa2bb42640 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -60,7 +60,11 @@ export const WebCommand = cmd({ } if (opts.mdns) { - UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") + UI.println( + UI.Style.TEXT_INFO_BOLD + " mDNS: ", + UI.Style.TEXT_NORMAL, + `opencode.local:${server.port}`, + ) } // Open localhost in browser diff --git a/packages/opencode/src/command/template/review.txt b/packages/opencode/src/command/template/review.txt index 1ffa0fca0b4..9f6fbfcc3a8 100644 --- a/packages/opencode/src/command/template/review.txt +++ b/packages/opencode/src/command/template/review.txt @@ -13,6 +13,7 @@ Based on the input provided, determine which type of review to perform: 1. **No arguments (default)**: Review all uncommitted changes - Run: `git diff` for unstaged changes - Run: `git diff --cached` for staged changes + - Run: `git status --short` to identify untracked (net new) files 2. **Commit hash** (40-char SHA or short hash): Review that specific commit - Run: `git show $ARGUMENTS` @@ -33,6 +34,7 @@ Use best judgement when processing input. **Diffs alone are not enough.** After getting the diff, read the entire file(s) being modified to understand the full context. Code that looks wrong in isolation may be correct given surrounding logic—and vice versa. - Use the diff to identify which files changed +- Use `git status --short` to identify untracked files, then read their full contents - Read the full file to understand existing patterns, control flow, and error handling - Check for existing style guide or conventions files (CONVENTIONS.md, AGENTS.md, .editorconfig, etc.) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 355b3ba0017..ddb3af4b0a8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -435,10 +435,6 @@ export namespace Config { .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), scope: z.string().optional().describe("OAuth scopes to request during authorization"), - redirectUri: z - .string() - .optional() - .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), }) .strict() .meta({ @@ -655,8 +651,14 @@ export namespace Config { session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_compact: z.string().optional().default("c").describe("Compact the session"), - messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"), - messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"), + messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), + messages_page_down: z + .string() + .optional() + .default("pagedown,ctrl+alt+f") + .describe("Scroll messages down by one page"), + messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), + messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), messages_half_page_down: z .string() @@ -1119,6 +1121,7 @@ export namespace Config { } async function load(text: string, configFilepath: string) { + const original = text text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) @@ -1188,7 +1191,9 @@ export namespace Config { if (parsed.success) { if (!parsed.data.$schema) { parsed.data.$schema = "https://opencode.ai/config.json" - await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)).catch(() => {}) + // Write the $schema to the original text to preserve variables like {env:VAR} + const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') + await Bun.write(configFilepath, updated).catch(() => {}) } const data = parsed.data if (data.plugin) { diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index dea312adb0c..d18c9e31a13 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -158,7 +158,7 @@ export namespace Installation { cmd = $`echo Y | choco upgrade opencode --version=${target}` break case "scoop": - cmd = $`scoop install extras/opencode@${target}` + cmd = $`scoop install opencode@${target}` break default: throw new Error(`Unknown method: ${method}`) @@ -226,7 +226,7 @@ export namespace Installation { } if (detectedMethod === "scoop") { - return fetch("https://raw.githubusercontent.com/ScoopInstaller/Extras/master/bucket/opencode.json", { + return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", { headers: { Accept: "application/json" }, }) .then((res) => { diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 24da77edcfe..e7efd99dcbd 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1157,10 +1157,24 @@ export namespace LSPServer { await fs.mkdir(distPath, { recursive: true }) const releaseURL = "https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz" - const archivePath = path.join(distPath, "release.tar.gz") - await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow() - await $`tar -xzf ${archivePath}`.cwd(distPath).quiet().nothrow() - await fs.rm(archivePath, { force: true }) + const archiveName = "release.tar.gz" + + log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath }) + const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow() + if (curlResult.exitCode !== 0) { + log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() }) + return + } + + log.info("Extracting JDTLS archive") + const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow() + if (tarResult.exitCode !== 0) { + log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() }) + return + } + + await fs.rm(path.join(distPath, archiveName), { force: true }) + log.info("JDTLS download and extraction completed") } const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar` .cwd(launcherDir) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 7b9a8c2076a..66843aedc11 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -308,8 +308,6 @@ export namespace MCP { let authProvider: McpOAuthProvider | undefined if (!oauthDisabled) { - await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) - authProvider = new McpOAuthProvider( key, mcp.url, @@ -317,7 +315,6 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -347,7 +344,6 @@ export namespace MCP { let lastError: Error | undefined const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT - for (const { name, transport } of transports) { try { const client = new Client({ @@ -574,8 +570,7 @@ export namespace MCP { for (const [clientName, client] of Object.entries(clientsSnapshot)) { // Only include tools from connected MCPs (skip disabled ones) - const clientStatus = s.status[clientName]?.status - if (clientStatus !== "connected") { + if (s.status[clientName]?.status !== "connected") { continue } @@ -725,10 +720,8 @@ export namespace MCP { throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) } - // OAuth config is optional - if not provided, we'll use auto-discovery - const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined - - await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) + // Start the callback server + await McpOAuthCallback.ensureRunning() // Generate and store a cryptographically secure state parameter BEFORE creating the provider // The SDK will call provider.state() to read this value @@ -738,6 +731,8 @@ export namespace MCP { await McpAuth.updateOAuthState(mcpName, oauthState) // Create a new auth provider for this flow + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined let capturedUrl: URL | undefined const authProvider = new McpOAuthProvider( mcpName, @@ -746,7 +741,6 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -775,7 +769,6 @@ export namespace MCP { pendingOAuthTransports.set(mcpName, transport) return { authorizationUrl: capturedUrl.toString() } } - throw error } } @@ -785,9 +778,9 @@ export namespace MCP { * Opens the browser and waits for callback. */ export async function authenticate(mcpName: string): Promise { - const result = await startAuth(mcpName) + const { authorizationUrl } = await startAuth(mcpName) - if (!result.authorizationUrl) { + if (!authorizationUrl) { // Already authenticated const s = await state() return s.status[mcpName] ?? { status: "connected" } @@ -801,9 +794,9 @@ export namespace MCP { // The SDK has already added the state parameter to the authorization URL // We just need to open the browser - log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: oauthState }) + log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState }) try { - const subprocess = await open(result.authorizationUrl) + const subprocess = await open(authorizationUrl) // The open package spawns a detached process and returns immediately. // We need to listen for errors which fire asynchronously: // - "error" event: command not found (ENOENT) @@ -826,7 +819,7 @@ export namespace MCP { // Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers) // Emit event so CLI can display the URL for manual opening log.warn("failed to open browser, user must open URL manually", { mcpName, error }) - Bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }) + Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }) } // Wait for callback using the OAuth state parameter diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index a690ab5e336..bb3b56f2e95 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,12 +1,8 @@ import { Log } from "../util/log" -import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" +import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) -// Current callback server configuration (may differ from defaults if custom redirectUri is used) -let currentPort = OAUTH_CALLBACK_PORT -let currentPath = OAUTH_CALLBACK_PATH - const HTML_SUCCESS = ` @@ -60,33 +56,21 @@ export namespace McpOAuthCallback { const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes - export async function ensureRunning(redirectUri?: string): Promise { - // Parse the redirect URI to get port and path (uses defaults if not provided) - const { port, path } = parseRedirectUri(redirectUri) - - // If server is running on a different port/path, stop it first - if (server && (currentPort !== port || currentPath !== path)) { - log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port }) - await stop() - } - + export async function ensureRunning(): Promise { if (server) return - const running = await isPortInUse(port) + const running = await isPortInUse() if (running) { - log.info("oauth callback server already running on another instance", { port }) + log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) return } - currentPort = port - currentPath = path - server = Bun.serve({ - port: currentPort, + port: OAUTH_CALLBACK_PORT, fetch(req) { const url = new URL(req.url) - if (url.pathname !== currentPath) { + if (url.pathname !== OAUTH_CALLBACK_PATH) { return new Response("Not found", { status: 404 }) } @@ -149,7 +133,7 @@ export namespace McpOAuthCallback { }, }) - log.info("oauth callback server started", { port: currentPort, path: currentPath }) + log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) } export function waitForCallback(oauthState: string): Promise { @@ -174,11 +158,11 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { + export async function isPortInUse(): Promise { return new Promise((resolve) => { Bun.connect({ hostname: "127.0.0.1", - port, + port: OAUTH_CALLBACK_PORT, socket: { open(socket) { socket.end() diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 82bad60da33..35ead25e8be 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -17,7 +17,6 @@ export interface McpOAuthConfig { clientId?: string clientSecret?: string scope?: string - redirectUri?: string } export interface McpOAuthCallbacks { @@ -33,10 +32,6 @@ export class McpOAuthProvider implements OAuthClientProvider { ) {} get redirectUrl(): string { - // Use configured redirectUri if provided, otherwise use OpenCode defaults - if (this.config.redirectUri) { - return this.config.redirectUri - } return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` } @@ -157,22 +152,3 @@ export class McpOAuthProvider implements OAuthClientProvider { } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } - -/** - * Parse a redirect URI to extract port and path for the callback server. - * Returns defaults if the URI can't be parsed. - */ -export function parseRedirectUri(redirectUri?: string): { port: number; path: string } { - if (!redirectUri) { - return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } - } - - try { - const url = new URL(redirectUri) - const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80 - const path = url.pathname || OAUTH_CALLBACK_PATH - return { port, path } - } catch { - return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } - } -} diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 91d52065f6f..0efeff544f6 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -1,6 +1,7 @@ import z from "zod" import * as path from "path" import * as fs from "fs/promises" +import { readFileSync } from "fs" import { Log } from "../util/log" export namespace Patch { @@ -177,8 +178,18 @@ export namespace Patch { return { content, nextIdx: i } } + function stripHeredoc(input: string): string { + // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or < 0 && pattern[pattern.length - 1] === "") { @@ -371,7 +382,7 @@ export namespace Patch { if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { newSlice = newSlice.slice(0, -1) } - found = seekSequence(originalLines, pattern, lineIndex) + found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) } if (found !== -1) { @@ -407,28 +418,75 @@ export namespace Patch { return result } - function seekSequence(lines: string[], pattern: string[], startIndex: number): number { - if (pattern.length === 0) return -1 + // Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode) + function normalizeUnicode(str: string): string { + return str + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes + .replace(/\u2026/g, "...") // ellipsis + .replace(/\u00A0/g, " ") // non-breaking space + } + + type Comparator = (a: string, b: string) => boolean + + function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number { + // If EOF anchor, try matching from end of file first + if (eof) { + const fromEnd = lines.length - pattern.length + if (fromEnd >= startIndex) { + let matches = true + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[fromEnd + j], pattern[j])) { + matches = false + break + } + } + if (matches) return fromEnd + } + } - // Simple substring search implementation + // Forward search from startIndex for (let i = startIndex; i <= lines.length - pattern.length; i++) { let matches = true - for (let j = 0; j < pattern.length; j++) { - if (lines[i + j] !== pattern[j]) { + if (!compare(lines[i + j], pattern[j])) { matches = false break } } - - if (matches) { - return i - } + if (matches) return i } return -1 } + function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number { + if (pattern.length === 0) return -1 + + // Pass 1: exact match + const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof) + if (exact !== -1) return exact + + // Pass 2: rstrip (trim trailing whitespace) + const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof) + if (rstrip !== -1) return rstrip + + // Pass 3: trim (both ends) + const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof) + if (trim !== -1) return trim + + // Pass 4: normalized (Unicode punctuation to ASCII) + const normalized = tryMatch( + lines, + pattern, + startIndex, + (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), + eof, + ) + return normalized + } + function generateUnifiedDiff(oldContent: string, newContent: string): string { const oldLines = oldContent.split("\n") const newLines = newContent.split("\n") diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 17ce9debc7d..932b3fd6aff 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -3,6 +3,9 @@ import { Installation } from "@/installation" import { iife } from "@/util/iife" const CLIENT_ID = "Ov23li8tweQw6odWQebz" +// Add a small safety buffer when polling to avoid hitting the server +// slightly too early due to clock skew / timer drift. +const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 // 3 seconds function normalizeDomain(url: string) { return url.replace(/^https?:\/\//, "").replace(/\/$/, "") @@ -204,6 +207,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const data = (await response.json()) as { access_token?: string error?: string + interval?: number } if (data.access_token) { @@ -230,13 +234,29 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { } if (data.error === "authorization_pending") { - await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000)) + await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) + continue + } + + if (data.error === "slow_down") { + // Based on the RFC spec, we must add 5 seconds to our current polling interval. + // (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5) + let newInterval = (deviceData.interval + 5) * 1000 + + // GitHub OAuth API may return the new interval in seconds in the response. + // We should try to use that if provided with safety margin. + const serverInterval = data.interval + if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) { + newInterval = serverInterval * 1000 + } + + await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS) continue } if (data.error) return { type: "failed" as const } - await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000)) + await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) continue } }, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9e2dd0ba0b5..bcb115edf41 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -999,6 +999,24 @@ export namespace Provider { opts.signal = combined } + // Strip openai itemId metadata following what codex does + // Codex uses #[serde(skip_serializing)] on id fields for all item types: + // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall + // IDs are only re-attached for Azure with store=true + if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + const isAzure = model.providerID.includes("azure") + const keepIds = isAzure && body.store === true + if (!keepIds && Array.isArray(body.input)) { + for (const item of body.input) { + if ("id" in item) { + delete item.id + } + } + opts.body = JSON.stringify(body) + } + } + return fetchFn(input, { ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 4566fc1de2b..b803bd66ce1 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -16,38 +16,33 @@ function mimeToModality(mime: string): Modality | undefined { } export namespace ProviderTransform { + // Maps npm package to the key the AI SDK expects for providerOptions + function sdkKey(npm: string): string | undefined { + switch (npm) { + case "@ai-sdk/github-copilot": + case "@ai-sdk/openai": + case "@ai-sdk/azure": + return "openai" + case "@ai-sdk/amazon-bedrock": + return "bedrock" + case "@ai-sdk/anthropic": + return "anthropic" + case "@ai-sdk/google-vertex": + case "@ai-sdk/google": + return "google" + case "@ai-sdk/gateway": + return "gateway" + case "@openrouter/ai-sdk-provider": + return "openrouter" + } + return undefined + } + function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, options: Record, ): ModelMessage[] { - // Strip openai itemId metadata following what codex does - if (model.api.npm === "@ai-sdk/openai" || options.store === false) { - msgs = msgs.map((msg) => { - if (msg.providerOptions) { - for (const options of Object.values(msg.providerOptions)) { - if (options && typeof options === "object") { - delete options["itemId"] - } - } - } - if (!Array.isArray(msg.content)) { - return msg - } - const content = msg.content.map((part) => { - if (part.providerOptions) { - for (const options of Object.values(part.providerOptions)) { - if (options && typeof options === "object") { - delete options["itemId"] - } - } - } - return part - }) - return { ...msg, content } as typeof msg - }) - } - // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content if (model.api.npm === "@ai-sdk/anthropic") { @@ -261,6 +256,28 @@ export namespace ProviderTransform { msgs = applyCaching(msgs, model.providerID) } + // Remap providerOptions keys from stored providerID to expected SDK key + const key = sdkKey(model.api.npm) + if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") { + const remap = (opts: Record | undefined) => { + if (!opts) return opts + if (!(model.providerID in opts)) return opts + const result = { ...opts } + result[key] = result[model.providerID] + delete result[model.providerID] + return result + } + + msgs = msgs.map((msg) => { + if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) } + return { + ...msg, + providerOptions: remap(msg.providerOptions), + content: msg.content.map((part) => ({ ...part, providerOptions: remap(part.providerOptions) })), + } as typeof msg + }) + } + return msgs } @@ -308,9 +325,24 @@ export namespace ProviderTransform { const id = model.id.toLowerCase() if (id.includes("deepseek") || id.includes("minimax") || id.includes("glm") || id.includes("mistral")) return {} + // see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks + if (id.includes("grok") && id.includes("grok-3-mini")) { + if (model.api.npm === "@openrouter/ai-sdk-provider") { + return { + low: { reasoning: { effort: "low" } }, + high: { reasoning: { effort: "high" } }, + } + } + return { + low: { reasoningEffort: "low" }, + high: { reasoningEffort: "high" }, + } + } + if (id.includes("grok")) return {} + switch (model.api.npm) { case "@openrouter/ai-sdk-provider": - if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("grok-4")) return {} + if (!model.id.includes("gpt") && !model.id.includes("gemini-3")) return {} return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }])) // TODO: YOU CANNOT SET max_tokens if this is set!!! @@ -578,39 +610,8 @@ export namespace ProviderTransform { } export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { - switch (model.api.npm) { - case "@ai-sdk/github-copilot": - case "@ai-sdk/openai": - case "@ai-sdk/azure": - return { - ["openai" as string]: options, - } - case "@ai-sdk/amazon-bedrock": - return { - ["bedrock" as string]: options, - } - case "@ai-sdk/anthropic": - return { - ["anthropic" as string]: options, - } - case "@ai-sdk/google-vertex": - case "@ai-sdk/google": - return { - ["google" as string]: options, - } - case "@ai-sdk/gateway": - return { - ["gateway" as string]: options, - } - case "@openrouter/ai-sdk-provider": - return { - ["openrouter" as string]: options, - } - default: - return { - [model.providerID]: options, - } - } + const key = sdkKey(model.api.npm) ?? model.providerID + return { [key]: options } } export function maxOutputTokens( diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index d18098a9c4f..41029ecbbdb 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -10,7 +10,7 @@ export namespace Question { export const Option = z .object({ - label: z.string().describe("Display text (1-5 words, concise)"), + label: z.string().max(30).describe("Display text (1-5 words, concise)"), description: z.string().describe("Explanation of choice"), }) .meta({ @@ -21,7 +21,7 @@ export namespace Question { export const Info = z .object({ question: z.string().describe("Complete question"), - header: z.string().max(12).describe("Very short label (max 12 chars)"), + header: z.string().max(30).describe("Very short label (max 30 chars)"), options: z.array(Option).describe("Available choices"), multiple: z.boolean().optional().describe("Allow selecting multiple choices"), custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 8bddb910503..953269de444 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -7,15 +7,17 @@ export namespace MDNS { let bonjour: Bonjour | undefined let currentPort: number | undefined - export function publish(port: number, name = "opencode") { + export function publish(port: number) { if (currentPort === port) return if (bonjour) unpublish() try { + const name = `opencode-${port}` bonjour = new Bonjour() const service = bonjour.publish({ name, type: "http", + host: "opencode.local", port, txt: { path: "/" }, }) diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index c6b1d42e8e5..0fb2a5e9d2e 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -74,8 +74,8 @@ export const ExperimentalRoutes = lazy(() => }), ), async (c) => { - const { provider } = c.req.valid("query") - const tools = await ToolRegistry.tools(provider) + const { provider, model } = c.req.valid("query") + const tools = await ToolRegistry.tools({ providerID: provider, modelID: model }) return c.json( tools.map((t) => ({ id: t.id, diff --git a/packages/opencode/src/server/routes/tui.ts b/packages/opencode/src/server/routes/tui.ts index be371c1e09e..8650a0cccf7 100644 --- a/packages/opencode/src/server/routes/tui.ts +++ b/packages/opencode/src/server/routes/tui.ts @@ -119,7 +119,9 @@ export const TuiRoutes = lazy(() => }, }), async (c) => { - // TODO: open dialog + await Bus.publish(TuiEvent.CommandExecute, { + command: "help.show", + }) return c.json(true) }, ) @@ -273,6 +275,8 @@ export const TuiRoutes = lazy(() => session_compact: "session.compact", messages_page_up: "session.page.up", messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", messages_half_page_up: "session.half.page.up", messages_half_page_down: "session.half.page.down", messages_first: "session.first", diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f0c64b49f81..28dec7f4043 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -562,7 +562,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!, `opencode-${server.port!}`) + MDNS.publish(server.port!) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8327698fd5f..f4793d1a798 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -685,7 +685,10 @@ export namespace SessionPrompt { }, }) - for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) { + for (const item of await ToolRegistry.tools( + { modelID: input.model.api.id, providerID: input.model.providerID }, + input.agent, + )) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, @@ -1699,6 +1702,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the : await lastModel(input.sessionID) : taskModel + await Plugin.trigger( + "command.execute.before", + { + command: input.command, + sessionID: input.sessionID, + arguments: input.arguments, + }, + { parts }, + ) + const result = (await prompt({ sessionID: input.sessionID, messageID: input.messageID, diff --git a/packages/opencode/src/session/prompt/codex.txt b/packages/opencode/src/session/prompt/codex.txt index d26e2e01aa7..daad8237758 100644 --- a/packages/opencode/src/session/prompt/codex.txt +++ b/packages/opencode/src/session/prompt/codex.txt @@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks ## Editing constraints - Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. - Only add comments if they are necessary to make a non-obvious block easier to understand. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). ## Tool usage - Prefer specialized tools over shell for file operations: diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts new file mode 100644 index 00000000000..d070eaefa97 --- /dev/null +++ b/packages/opencode/src/tool/apply_patch.ts @@ -0,0 +1,277 @@ +import z from "zod" +import * as path from "path" +import * as fs from "fs/promises" +import { Tool } from "./tool" +import { FileTime } from "../file/time" +import { Bus } from "../bus" +import { FileWatcher } from "../file/watcher" +import { Instance } from "../project/instance" +import { Patch } from "../patch" +import { createTwoFilesPatch, diffLines } from "diff" +import { assertExternalDirectory } from "./external-directory" +import { trimDiff } from "./edit" +import { LSP } from "../lsp" +import { Filesystem } from "../util/filesystem" + +const PatchParams = z.object({ + patchText: z.string().describe("The full patch text that describes all changes to be made"), +}) + +export const ApplyPatchTool = Tool.define("apply_patch", { + description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.", + parameters: PatchParams, + async execute(params, ctx) { + if (!params.patchText) { + throw new Error("patchText is required") + } + + // Parse the patch to get hunks + let hunks: Patch.Hunk[] + try { + const parseResult = Patch.parsePatch(params.patchText) + hunks = parseResult.hunks + } catch (error) { + throw new Error(`apply_patch verification failed: ${error}`) + } + + if (hunks.length === 0) { + const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim() + if (normalized === "*** Begin Patch\n*** End Patch") { + throw new Error("patch rejected: empty patch") + } + throw new Error("apply_patch verification failed: no hunks found") + } + + // Validate file paths and check permissions + const fileChanges: Array<{ + filePath: string + oldContent: string + newContent: string + type: "add" | "update" | "delete" | "move" + movePath?: string + diff: string + additions: number + deletions: number + }> = [] + + let totalDiff = "" + + for (const hunk of hunks) { + const filePath = path.resolve(Instance.directory, hunk.path) + await assertExternalDirectory(ctx, filePath) + + switch (hunk.type) { + case "add": { + const oldContent = "" + const newContent = + hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + fileChanges.push({ + filePath, + oldContent, + newContent, + type: "add", + diff, + additions, + deletions, + }) + + totalDiff += diff + "\n" + break + } + + case "update": { + // Check if file exists for update + const stats = await fs.stat(filePath).catch(() => null) + if (!stats || stats.isDirectory()) { + throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`) + } + + // Read file and update time tracking (like edit tool does) + await FileTime.assert(ctx.sessionID, filePath) + const oldContent = await fs.readFile(filePath, "utf-8") + let newContent = oldContent + + // Apply the update chunks to get new content + try { + const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) + newContent = fileUpdate.content + } catch (error) { + throw new Error(`apply_patch verification failed: ${error}`) + } + + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined + await assertExternalDirectory(ctx, movePath) + + fileChanges.push({ + filePath, + oldContent, + newContent, + type: hunk.move_path ? "move" : "update", + movePath, + diff, + additions, + deletions, + }) + + totalDiff += diff + "\n" + break + } + + case "delete": { + const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { + throw new Error(`apply_patch verification failed: ${error}`) + }) + const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) + + const deletions = contentToDelete.split("\n").length + + fileChanges.push({ + filePath, + oldContent: contentToDelete, + newContent: "", + type: "delete", + diff: deleteDiff, + additions: 0, + deletions, + }) + + totalDiff += deleteDiff + "\n" + break + } + } + } + + // Check permissions if needed + await ctx.ask({ + permission: "edit", + patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)), + always: ["*"], + metadata: { + diff: totalDiff, + }, + }) + + // Apply the changes + const changedFiles: string[] = [] + + for (const change of fileChanges) { + switch (change.type) { + case "add": + // Create parent directories (recursive: true is safe on existing/root dirs) + await fs.mkdir(path.dirname(change.filePath), { recursive: true }) + await fs.writeFile(change.filePath, change.newContent, "utf-8") + changedFiles.push(change.filePath) + break + + case "update": + await fs.writeFile(change.filePath, change.newContent, "utf-8") + changedFiles.push(change.filePath) + break + + case "move": + if (change.movePath) { + // Create parent directories (recursive: true is safe on existing/root dirs) + await fs.mkdir(path.dirname(change.movePath), { recursive: true }) + await fs.writeFile(change.movePath, change.newContent, "utf-8") + await fs.unlink(change.filePath) + changedFiles.push(change.movePath) + } + break + + case "delete": + await fs.unlink(change.filePath) + changedFiles.push(change.filePath) + break + } + + // Update file time tracking + FileTime.read(ctx.sessionID, change.filePath) + if (change.movePath) { + FileTime.read(ctx.sessionID, change.movePath) + } + } + + // Publish file change events + for (const filePath of changedFiles) { + await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" }) + } + + // Notify LSP of file changes and collect diagnostics + for (const change of fileChanges) { + if (change.type === "delete") continue + const target = change.movePath ?? change.filePath + await LSP.touchFile(target, true) + } + const diagnostics = await LSP.diagnostics() + + // Generate output summary + const summaryLines = fileChanges.map((change) => { + if (change.type === "add") { + return `A ${path.relative(Instance.worktree, change.filePath)}` + } + if (change.type === "delete") { + return `D ${path.relative(Instance.worktree, change.filePath)}` + } + const target = change.movePath ?? change.filePath + return `M ${path.relative(Instance.worktree, target)}` + }) + let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` + + // Report LSP errors for changed files + const MAX_DIAGNOSTICS_PER_FILE = 20 + for (const change of fileChanges) { + if (change.type === "delete") continue + const target = change.movePath ?? change.filePath + const normalized = Filesystem.normalizePath(target) + const issues = diagnostics[normalized] ?? [] + const errors = issues.filter((item) => item.severity === 1) + if (errors.length > 0) { + const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) + const suffix = + errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" + output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` + } + } + + // Build per-file metadata for UI rendering + const files = fileChanges.map((change) => ({ + filePath: change.filePath, + relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath), + type: change.type, + diff: change.diff, + before: change.oldContent, + after: change.newContent, + additions: change.additions, + deletions: change.deletions, + movePath: change.movePath, + })) + + return { + title: output, + metadata: { + diff: totalDiff, + files, + diagnostics, + }, + output, + } + }, +}) diff --git a/packages/opencode/src/tool/apply_patch.txt b/packages/opencode/src/tool/apply_patch.txt new file mode 100644 index 00000000000..1af0606109f --- /dev/null +++ b/packages/opencode/src/tool/apply_patch.txt @@ -0,0 +1 @@ +Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index ba1b94a3e60..8bffbd54a28 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -37,7 +37,7 @@ export const BatchTool = Tool.define("batch", async () => { const discardedCalls = params.tool_calls.slice(10) const { ToolRegistry } = await import("./registry") - const availableTools = await ToolRegistry.tools("") + const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" }) const toolMap = new Map(availableTools.map((t) => [t.id, t])) const executeCall = async (call: (typeof toolCalls)[0]) => { diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts deleted file mode 100644 index 08a58bfea9c..00000000000 --- a/packages/opencode/src/tool/patch.ts +++ /dev/null @@ -1,201 +0,0 @@ -import z from "zod" -import * as path from "path" -import * as fs from "fs/promises" -import { Tool } from "./tool" -import { FileTime } from "../file/time" -import { Bus } from "../bus" -import { FileWatcher } from "../file/watcher" -import { Instance } from "../project/instance" -import { Patch } from "../patch" -import { createTwoFilesPatch } from "diff" -import { assertExternalDirectory } from "./external-directory" - -const PatchParams = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), -}) - -export const PatchTool = Tool.define("patch", { - description: - "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.", - parameters: PatchParams, - async execute(params, ctx) { - if (!params.patchText) { - throw new Error("patchText is required") - } - - // Parse the patch to get hunks - let hunks: Patch.Hunk[] - try { - const parseResult = Patch.parsePatch(params.patchText) - hunks = parseResult.hunks - } catch (error) { - throw new Error(`Failed to parse patch: ${error}`) - } - - if (hunks.length === 0) { - throw new Error("No file changes found in patch") - } - - // Validate file paths and check permissions - const fileChanges: Array<{ - filePath: string - oldContent: string - newContent: string - type: "add" | "update" | "delete" | "move" - movePath?: string - }> = [] - - let totalDiff = "" - - for (const hunk of hunks) { - const filePath = path.resolve(Instance.directory, hunk.path) - await assertExternalDirectory(ctx, filePath) - - switch (hunk.type) { - case "add": - if (hunk.type === "add") { - const oldContent = "" - const newContent = hunk.contents - const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) - - fileChanges.push({ - filePath, - oldContent, - newContent, - type: "add", - }) - - totalDiff += diff + "\n" - } - break - - case "update": - // Check if file exists for update - const stats = await fs.stat(filePath).catch(() => null) - if (!stats || stats.isDirectory()) { - throw new Error(`File not found or is directory: ${filePath}`) - } - - // Read file and update time tracking (like edit tool does) - await FileTime.assert(ctx.sessionID, filePath) - const oldContent = await fs.readFile(filePath, "utf-8") - let newContent = oldContent - - // Apply the update chunks to get new content - try { - const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) - newContent = fileUpdate.content - } catch (error) { - throw new Error(`Failed to apply update to ${filePath}: ${error}`) - } - - const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) - - const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined - await assertExternalDirectory(ctx, movePath) - - fileChanges.push({ - filePath, - oldContent, - newContent, - type: hunk.move_path ? "move" : "update", - movePath, - }) - - totalDiff += diff + "\n" - break - - case "delete": - // Check if file exists for deletion - await FileTime.assert(ctx.sessionID, filePath) - const contentToDelete = await fs.readFile(filePath, "utf-8") - const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "") - - fileChanges.push({ - filePath, - oldContent: contentToDelete, - newContent: "", - type: "delete", - }) - - totalDiff += deleteDiff + "\n" - break - } - } - - // Check permissions if needed - await ctx.ask({ - permission: "edit", - patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)), - always: ["*"], - metadata: { - diff: totalDiff, - }, - }) - - // Apply the changes - const changedFiles: string[] = [] - - for (const change of fileChanges) { - switch (change.type) { - case "add": - // Create parent directories - const addDir = path.dirname(change.filePath) - if (addDir !== "." && addDir !== "/") { - await fs.mkdir(addDir, { recursive: true }) - } - await fs.writeFile(change.filePath, change.newContent, "utf-8") - changedFiles.push(change.filePath) - break - - case "update": - await fs.writeFile(change.filePath, change.newContent, "utf-8") - changedFiles.push(change.filePath) - break - - case "move": - if (change.movePath) { - // Create parent directories for destination - const moveDir = path.dirname(change.movePath) - if (moveDir !== "." && moveDir !== "/") { - await fs.mkdir(moveDir, { recursive: true }) - } - // Write to new location - await fs.writeFile(change.movePath, change.newContent, "utf-8") - // Remove original - await fs.unlink(change.filePath) - changedFiles.push(change.movePath) - } - break - - case "delete": - await fs.unlink(change.filePath) - changedFiles.push(change.filePath) - break - } - - // Update file time tracking - FileTime.read(ctx.sessionID, change.filePath) - if (change.movePath) { - FileTime.read(ctx.sessionID, change.movePath) - } - } - - // Publish file change events - for (const filePath of changedFiles) { - await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" }) - } - - // Generate output summary - const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath)) - const summary = `${fileChanges.length} files changed` - - return { - title: summary, - metadata: { - diff: totalDiff, - }, - output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`, - } - }, -}) diff --git a/packages/opencode/src/tool/patch.txt b/packages/opencode/src/tool/patch.txt deleted file mode 100644 index 88a50f6347a..00000000000 --- a/packages/opencode/src/tool/patch.txt +++ /dev/null @@ -1 +0,0 @@ -do not use diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index ce4ab28619d..3b1484cbc0f 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -59,7 +59,9 @@ export const ReadTool = Tool.define("read", { throw new Error(`File not found: ${filepath}`) } - const isImage = file.type.startsWith("image/") && file.type !== "image/svg+xml" + // Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files) + const isImage = + file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet" const isPdf = file.type === "application/pdf" if (isImage || isPdf) { const mime = file.type diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 35e378f080b..faa5f72bcce 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -26,6 +26,7 @@ import { Log } from "@/util/log" import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { PlanExitTool, PlanEnterTool } from "./plan" +import { ApplyPatchTool } from "./apply_patch" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -108,6 +109,7 @@ export namespace ToolRegistry { WebSearchTool, CodeSearchTool, SkillTool, + ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []), @@ -119,15 +121,28 @@ export namespace ToolRegistry { return all().then((x) => x.map((t) => t.id)) } - export async function tools(providerID: string, agent?: Agent.Info) { + export async function tools( + model: { + providerID: string + modelID: string + }, + agent?: Agent.Info, + ) { const tools = await all() const result = await Promise.all( tools .filter((t) => { // Enable websearch/codesearch for zen users OR via enable flag if (t.id === "codesearch" || t.id === "websearch") { - return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA + return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA } + + // use apply tool in same format as codex + const usePatch = + model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") + if (t.id === "apply_patch") return usePatch + if (t.id === "edit" || t.id === "write") return !usePatch + return true }) .map(async (t) => { diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts new file mode 100644 index 00000000000..8e139ff5973 --- /dev/null +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, test } from "bun:test" +import { ACP } from "../../src/acp/agent" +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import type { Event } from "@opencode-ai/sdk/v2" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +type SessionUpdateParams = Parameters[0] +type RequestPermissionParams = Parameters[0] +type RequestPermissionResult = Awaited> + +type GlobalEventEnvelope = { + directory?: string + payload?: Event +} + +type EventController = { + push: (event: GlobalEventEnvelope) => void + close: () => void +} + +function createEventStream() { + const queue: GlobalEventEnvelope[] = [] + const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] + const state = { closed: false } + + const push = (event: GlobalEventEnvelope) => { + const waiter = waiters.shift() + if (waiter) { + waiter(event) + return + } + queue.push(event) + } + + const close = () => { + state.closed = true + for (const waiter of waiters.splice(0)) { + waiter(undefined) + } + } + + const stream = async function* (signal?: AbortSignal) { + while (true) { + if (signal?.aborted) return + const next = queue.shift() + if (next) { + yield next + continue + } + if (state.closed) return + const value = await new Promise((resolve) => { + waiters.push(resolve) + if (!signal) return + signal.addEventListener("abort", () => resolve(undefined), { once: true }) + }) + if (!value) return + yield value + } + } + + return { controller: { push, close } satisfies EventController, stream } +} + +function createFakeAgent() { + const updates = new Map() + const chunks = new Map() + const record = (sessionId: string, type: string) => { + const list = updates.get(sessionId) ?? [] + list.push(type) + updates.set(sessionId, list) + } + + const connection = { + async sessionUpdate(params: SessionUpdateParams) { + const update = params.update + const type = update?.sessionUpdate ?? "unknown" + record(params.sessionId, type) + if (update?.sessionUpdate === "agent_message_chunk") { + const content = update.content + if (content?.type !== "text") return + if (typeof content.text !== "string") return + chunks.set(params.sessionId, (chunks.get(params.sessionId) ?? "") + content.text) + } + }, + async requestPermission(_params: RequestPermissionParams): Promise { + return { outcome: { outcome: "selected", optionId: "once" } } as RequestPermissionResult + }, + } as unknown as AgentSideConnection + + const { controller, stream } = createEventStream() + const calls = { + eventSubscribe: 0, + sessionCreate: 0, + } + + const sdk = { + global: { + event: async (opts?: { signal?: AbortSignal }) => { + calls.eventSubscribe++ + return { stream: stream(opts?.signal) } + }, + }, + session: { + create: async (_params?: any) => { + calls.sessionCreate++ + return { + data: { + id: `ses_${calls.sessionCreate}`, + time: { created: new Date().toISOString() }, + }, + } + }, + get: async (_params?: any) => { + return { + data: { + id: "ses_1", + time: { created: new Date().toISOString() }, + }, + } + }, + messages: async () => { + return { data: [] } + }, + message: async () => { + return { + data: { + info: { + role: "assistant", + }, + }, + } + }, + }, + permission: { + respond: async () => { + return { data: true } + }, + }, + config: { + providers: async () => { + return { + data: { + providers: [ + { + id: "opencode", + name: "opencode", + models: { + "big-pickle": { id: "big-pickle", name: "big-pickle" }, + }, + }, + ], + }, + } + }, + }, + app: { + agents: async () => { + return { + data: [ + { + name: "build", + description: "build", + mode: "agent", + }, + ], + } + }, + }, + command: { + list: async () => { + return { data: [] } + }, + }, + mcp: { + add: async () => { + return { data: true } + }, + }, + } as any + + const agent = new ACP.Agent(connection, { + sdk, + defaultModel: { providerID: "opencode", modelID: "big-pickle" }, + } as any) + + const stop = () => { + controller.close() + ;(agent as any).eventAbort.abort() + } + + return { agent, controller, calls, updates, chunks, stop, sdk, connection } +} + +describe("acp.agent event subscription", () => { + test("routes message.part.updated by the event sessionID (no cross-session pollution)", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, updates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + part: { + sessionID: sessionB, + messageID: "msg_1", + type: "text", + synthetic: false, + }, + delta: "hello", + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 10)) + + expect((updates.get(sessionA) ?? []).includes("agent_message_chunk")).toBe(false) + expect((updates.get(sessionB) ?? []).includes("agent_message_chunk")).toBe(true) + + stop() + }, + }) + }) + + test("keeps concurrent sessions isolated when message.part.updated events are interleaved", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, chunks, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + const tokenA = ["ALPHA_", "111", "_X"] + const tokenB = ["BETA_", "222", "_Y"] + + const push = (sessionId: string, messageID: string, delta: string) => { + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + part: { + sessionID: sessionId, + messageID, + type: "text", + synthetic: false, + }, + delta, + }, + }, + } as any) + } + + push(sessionA, "msg_a", tokenA[0]) + push(sessionB, "msg_b", tokenB[0]) + push(sessionA, "msg_a", tokenA[1]) + push(sessionB, "msg_b", tokenB[1]) + push(sessionA, "msg_a", tokenA[2]) + push(sessionB, "msg_b", tokenB[2]) + + await new Promise((r) => setTimeout(r, 20)) + + const a = chunks.get(sessionA) ?? "" + const b = chunks.get(sessionB) ?? "" + + expect(a).toContain(tokenA.join("")) + expect(b).toContain(tokenB.join("")) + for (const part of tokenB) expect(a).not.toContain(part) + for (const part of tokenA) expect(b).not.toContain(part) + + stop() + }, + }) + }) + + test("does not create additional event subscriptions on repeated loadSession()", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, calls, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + + expect(calls.eventSubscribe).toBe(1) + + stop() + }, + }) + }) + + test("permission.asked events are handled and replied", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const permissionReplies: string[] = [] + const { agent, controller, stop, sdk } = createFakeAgent() + sdk.permission.reply = async (params: any) => { + permissionReplies.push(params.requestID) + return { data: true } + } + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "permission.asked", + properties: { + id: "perm_1", + sessionID: sessionA, + permission: "bash", + patterns: ["*"], + metadata: {}, + always: [], + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 20)) + + expect(permissionReplies).toContain("perm_1") + + stop() + }, + }) + }) + + test("permission prompt on session A does not block message updates for session B", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const permissionReplies: string[] = [] + let resolvePermissionA: (() => void) | undefined + const permissionABlocking = new Promise((r) => { + resolvePermissionA = r + }) + + const { agent, controller, chunks, stop, sdk, connection } = createFakeAgent() + + // Make permission request for session A block until we release it + const originalRequestPermission = connection.requestPermission.bind(connection) + let permissionCalls = 0 + connection.requestPermission = async (params: RequestPermissionParams) => { + permissionCalls++ + if (params.sessionId.endsWith("1")) { + await permissionABlocking + } + return originalRequestPermission(params) + } + + sdk.permission.reply = async (params: any) => { + permissionReplies.push(params.requestID) + return { data: true } + } + + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + // Push permission.asked for session A (will block) + controller.push({ + directory: cwd, + payload: { + type: "permission.asked", + properties: { + id: "perm_a", + sessionID: sessionA, + permission: "bash", + patterns: ["*"], + metadata: {}, + always: [], + }, + }, + } as any) + + // Give time for permission handling to start + await new Promise((r) => setTimeout(r, 10)) + + // Push message for session B while A's permission is pending + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + part: { + sessionID: sessionB, + messageID: "msg_b", + type: "text", + synthetic: false, + }, + delta: "session_b_message", + }, + }, + } as any) + + // Wait for session B's message to be processed + await new Promise((r) => setTimeout(r, 20)) + + // Session B should have received message even though A's permission is still pending + expect(chunks.get(sessionB) ?? "").toContain("session_b_message") + expect(permissionReplies).not.toContain("perm_a") + + // Release session A's permission + resolvePermissionA!() + await new Promise((r) => setTimeout(r, 20)) + + // Now session A's permission should be replied + expect(permissionReplies).toContain("perm_a") + + stop() + }, + }) + }) +}) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 86cadca5d81..0463d29d7c5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -127,6 +127,44 @@ test("handles environment variable substitution", async () => { } }) +test("preserves env variables when adding $schema to config", async () => { + const originalEnv = process.env["PRESERVE_VAR"] + process.env["PRESERVE_VAR"] = "secret_value" + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + // Config without $schema - should trigger auto-add + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + theme: "{env:PRESERVE_VAR}", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("secret_value") + + // Read the file to verify the env variable was preserved + const content = await Bun.file(path.join(tmp.path, "opencode.json")).text() + expect(content).toContain("{env:PRESERVE_VAR}") + expect(content).not.toContain("secret_value") + expect(content).toContain("$schema") + }, + }) + } finally { + if (originalEnv !== undefined) { + process.env["PRESERVE_VAR"] = originalEnv + } else { + delete process.env["PRESERVE_VAR"] + } + } +}) + test("handles file inclusion substitution", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts deleted file mode 100644 index aa23f4dfb5d..00000000000 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { test, expect, describe, afterEach } from "bun:test" -import { McpOAuthCallback } from "../../src/mcp/oauth-callback" -import { parseRedirectUri } from "../../src/mcp/oauth-provider" - -describe("McpOAuthCallback.ensureRunning", () => { - afterEach(async () => { - await McpOAuthCallback.stop() - }) - - test("starts server with default config when no redirectUri provided", async () => { - await McpOAuthCallback.ensureRunning() - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("starts server with custom redirectUri", async () => { - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("is idempotent when called with same redirectUri", async () => { - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback") - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback") - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("restarts server when redirectUri changes", async () => { - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18002/path1") - expect(McpOAuthCallback.isRunning()).toBe(true) - - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18003/path2") - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("isRunning returns false when not started", async () => { - expect(McpOAuthCallback.isRunning()).toBe(false) - }) - - test("isRunning returns false after stop", async () => { - await McpOAuthCallback.ensureRunning() - await McpOAuthCallback.stop() - expect(McpOAuthCallback.isRunning()).toBe(false) - }) -}) - -describe("parseRedirectUri", () => { - test("returns defaults when no URI provided", () => { - const result = parseRedirectUri() - expect(result.port).toBe(19876) - expect(result.path).toBe("/mcp/oauth/callback") - }) - - test("parses port and path from URI", () => { - const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback") - expect(result.port).toBe(8080) - expect(result.path).toBe("/oauth/callback") - }) - - test("defaults to port 80 for http without explicit port", () => { - const result = parseRedirectUri("http://127.0.0.1/callback") - expect(result.port).toBe(80) - expect(result.path).toBe("/callback") - }) - - test("defaults to port 443 for https without explicit port", () => { - const result = parseRedirectUri("https://127.0.0.1/callback") - expect(result.port).toBe(443) - expect(result.path).toBe("/callback") - }) - - test("returns defaults for invalid URI", () => { - const result = parseRedirectUri("not-a-valid-url") - expect(result.port).toBe(19876) - expect(result.path).toBe("/mcp/oauth/callback") - }) -}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 33047b5bcb4..2b8f1872f56 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -649,7 +649,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( headers: {}, } as any - test("strips itemId and reasoningEncryptedContent when store=false", () => { + test("preserves itemId and reasoningEncryptedContent when store=false", () => { const msgs = [ { role: "assistant", @@ -680,11 +680,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") }) - test("strips itemId and reasoningEncryptedContent when store=false even when not openai", () => { + test("preserves itemId and reasoningEncryptedContent when store=false even when not openai", () => { const zenModel = { ...openaiModel, providerID: "zen", @@ -719,11 +719,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") }) - test("preserves other openai options when stripping itemId", () => { + test("preserves other openai options including itemId", () => { const msgs = [ { role: "assistant", @@ -744,11 +744,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value") }) - test("strips metadata for openai package even when store is true", () => { + test("preserves metadata for openai package when store is true", () => { const msgs = [ { role: "assistant", @@ -766,13 +766,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // openai package always strips itemId regardless of store value + // openai package preserves itemId regardless of store value const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) - test("strips metadata for non-openai packages when store is false", () => { + test("preserves metadata for non-openai packages when store is false", () => { const anthropicModel = { ...openaiModel, providerID: "anthropic", @@ -799,13 +799,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // store=false triggers stripping even for non-openai packages + // store=false preserves metadata for non-openai packages const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) - test("strips metadata using providerID key when store is false", () => { + test("preserves metadata using providerID key when store is false", () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -835,11 +835,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_123") expect(result[0].content[0].providerOptions?.opencode?.otherOption).toBe("value") }) - test("strips itemId across all providerOptions keys", () => { + test("preserves itemId across all providerOptions keys", () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -873,12 +873,12 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] - expect(result[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].providerOptions?.opencode?.itemId).toBeUndefined() - expect(result[0].providerOptions?.extra?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.extra?.itemId).toBeUndefined() + expect(result[0].providerOptions?.openai?.itemId).toBe("msg_root") + expect(result[0].providerOptions?.opencode?.itemId).toBe("msg_opencode") + expect(result[0].providerOptions?.extra?.itemId).toBe("msg_extra") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_openai_part") + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_opencode_part") + expect(result[0].content[0].providerOptions?.extra?.itemId).toBe("msg_extra_part") }) test("does not strip metadata for non-openai packages when store is not false", () => { @@ -914,6 +914,88 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }) }) +describe("ProviderTransform.message - providerOptions key remapping", () => { + const createModel = (providerID: string, npm: string) => + ({ + id: `${providerID}/test-model`, + providerID, + api: { + id: "test-model", + url: "https://api.test.com", + npm, + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + }) as any + + test("azure keeps 'azure' key and does not remap to 'openai'", () => { + const model = createModel("azure", "@ai-sdk/azure") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + azure: { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.azure).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.openai).toBeUndefined() + }) + + test("openai with github-copilot npm remaps providerID to 'openai'", () => { + const model = createModel("github-copilot", "@ai-sdk/github-copilot") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + "github-copilot": { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.openai).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.["github-copilot"]).toBeUndefined() + }) + + test("bedrock remaps providerID to 'bedrock' key", () => { + const model = createModel("my-bedrock", "@ai-sdk/amazon-bedrock") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + "my-bedrock": { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.bedrock).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.["my-bedrock"]).toBeUndefined() + }) +}) + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", @@ -1058,7 +1140,7 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) }) - test("grok-4 returns OPENAI_EFFORTS with reasoning", () => { + test("grok-4 returns empty object", () => { const model = createMockModel({ id: "openrouter/grok-4", providerID: "openrouter", @@ -1069,7 +1151,23 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) + expect(result).toEqual({}) + }) + + test("grok-3-mini returns low and high with reasoning", () => { + const model = createMockModel({ + id: "openrouter/grok-3-mini", + providerID: "openrouter", + api: { + id: "grok-3-mini", + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "high"]) + expect(result.low).toEqual({ reasoning: { effort: "low" } }) + expect(result.high).toEqual({ reasoning: { effort: "high" } }) }) }) @@ -1128,7 +1226,7 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/xai", () => { - test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => { + test("grok-3 returns empty object", () => { const model = createMockModel({ id: "xai/grok-3", providerID: "xai", @@ -1139,7 +1237,21 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result).toEqual({}) + }) + + test("grok-3-mini returns low and high with reasoningEffort", () => { + const model = createMockModel({ + id: "xai/grok-3-mini", + providerID: "xai", + api: { + id: "grok-3-mini", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "high"]) expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts new file mode 100644 index 00000000000..d8f05a9d911 --- /dev/null +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -0,0 +1,515 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import * as fs from "fs/promises" +import { ApplyPatchTool } from "../../src/tool/apply_patch" +import { Instance } from "../../src/project/instance" +import { FileTime } from "../../src/file/time" +import { tmpdir } from "../fixture/fixture" + +const baseCtx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + metadata: () => {}, +} + +type AskInput = { + permission: string + patterns: string[] + always: string[] + metadata: { diff: string } +} + +type ToolCtx = typeof baseCtx & { + ask: (input: AskInput) => Promise +} + +const execute = async (params: { patchText: string }, ctx: ToolCtx) => { + const tool = await ApplyPatchTool.init() + return tool.execute(params, ctx) +} + +const makeCtx = () => { + const calls: AskInput[] = [] + const ctx: ToolCtx = { + ...baseCtx, + ask: async (input) => { + calls.push(input) + }, + } + + return { ctx, calls } +} + +describe("tool.apply_patch freeform", () => { + test("requires patchText", async () => { + const { ctx } = makeCtx() + await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required") + }) + + test("rejects invalid patch format", async () => { + const { ctx } = makeCtx() + await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed") + }) + + test("rejects empty patch", async () => { + const { ctx } = makeCtx() + const emptyPatch = "*** Begin Patch\n*** End Patch" + await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch") + }) + + test("applies add/update/delete in one patch", async () => { + await using fixture = await tmpdir() + const { ctx, calls } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const modifyPath = path.join(fixture.path, "modify.txt") + const deletePath = path.join(fixture.path, "delete.txt") + await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8") + await fs.writeFile(deletePath, "obsolete\n", "utf-8") + FileTime.read(ctx.sessionID, modifyPath) + FileTime.read(ctx.sessionID, deletePath) + + const patchText = + "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch" + + const result = await execute({ patchText }, ctx) + + expect(result.title).toContain("Success. Updated the following files") + expect(result.output).toContain("Success. Updated the following files") + expect(result.metadata.diff).toContain("Index:") + expect(calls.length).toBe(1) + + const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8") + expect(added).toBe("created\n") + expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n") + await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow() + }, + }) + }) + + test("applies multiple hunks to one file", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "multi.txt") + await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = + "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n") + }, + }) + }) + + test("inserts lines with insert-only hunk", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "insert_only.txt") + await fs.writeFile(target, "alpha\nomega\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n") + }, + }) + }) + + test("appends trailing newline on update", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "no_newline.txt") + await fs.writeFile(target, "no newline at end", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = + "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch" + + await execute({ patchText }, ctx) + + const contents = await fs.readFile(target, "utf-8") + expect(contents.endsWith("\n")).toBe(true) + expect(contents).toBe("first line\nsecond line\n") + }, + }) + }) + + test("moves file to a new directory", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const original = path.join(fixture.path, "old", "name.txt") + await fs.mkdir(path.dirname(original), { recursive: true }) + await fs.writeFile(original, "old content\n", "utf-8") + FileTime.read(ctx.sessionID, original) + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch" + + await execute({ patchText }, ctx) + + const moved = path.join(fixture.path, "renamed", "dir", "name.txt") + await expect(fs.readFile(original, "utf-8")).rejects.toThrow() + expect(await fs.readFile(moved, "utf-8")).toBe("new content\n") + }, + }) + }) + + test("moves file overwriting existing destination", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const original = path.join(fixture.path, "old", "name.txt") + const destination = path.join(fixture.path, "renamed", "dir", "name.txt") + await fs.mkdir(path.dirname(original), { recursive: true }) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.writeFile(original, "from\n", "utf-8") + await fs.writeFile(destination, "existing\n", "utf-8") + FileTime.read(ctx.sessionID, original) + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch" + + await execute({ patchText }, ctx) + + await expect(fs.readFile(original, "utf-8")).rejects.toThrow() + expect(await fs.readFile(destination, "utf-8")).toBe("new\n") + }, + }) + }) + + test("adds file overwriting existing file", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "duplicate.txt") + await fs.writeFile(target, "old content\n", "utf-8") + + const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("new content\n") + }, + }) + }) + + test("rejects update when target file is missing", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow( + "apply_patch verification failed: Failed to read file to update", + ) + }, + }) + }) + + test("rejects delete when file is missing", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + }, + }) + }) + + test("rejects delete when target is a directory", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const dirPath = path.join(fixture.path, "dir") + await fs.mkdir(dirPath) + + const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + }, + }) + }) + + test("rejects invalid hunk header", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") + }, + }) + }) + + test("rejects update with missing context", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "modify.txt") + await fs.writeFile(target, "line1\nline2\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") + expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n") + }, + }) + }) + + test("verification failure leaves no side effects", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = + "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + + const createdPath = path.join(fixture.path, "created.txt") + await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow() + }, + }) + }) + + test("supports end of file anchor", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "tail.txt") + await fs.writeFile(target, "alpha\nlast\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n") + }, + }) + }) + + test("rejects missing second chunk context", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "two_chunks.txt") + await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n") + }, + }) + }) + + test("disambiguates change context with @@ header", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "multi_ctx.txt") + await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n") + }, + }) + }) + + test("EOF anchor matches from end of file first", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "eof_anchor.txt") + // File has duplicate "marker" lines - one in middle, one at end + await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // With EOF anchor, should match the LAST "marker" line, not the first + const patchText = + "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch" + + await execute({ patchText }, ctx) + // First marker unchanged, second marker changed + expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n") + }, + }) + }) + + test("parses heredoc-wrapped patch", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = `cat <<'EOF' +*** Begin Patch +*** Add File: heredoc_test.txt ++heredoc content +*** End Patch +EOF` + + await execute({ patchText }, ctx) + const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8") + expect(content).toBe("heredoc content\n") + }, + }) + }) + + test("parses heredoc-wrapped patch without cat", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = `< { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "trailing_ws.txt") + // File has trailing spaces on some lines + await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch doesn't have trailing spaces - should still match via rstrip pass + const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n") + }, + }) + }) + + test("matches with leading whitespace differences", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "leading_ws.txt") + // File has leading spaces + await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch without leading spaces - should match via trim pass + const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n") + }, + }) + }) + + test("matches with Unicode punctuation differences", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "unicode.txt") + // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014) + const leftQuote = "\u201C" + const rightQuote = "\u201D" + const emDash = "\u2014" + await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch uses ASCII equivalents - should match via normalized pass + // The replacement uses ASCII quotes from the patch (not preserving Unicode) + const patchText = + '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' + + await execute({ patchText }, ctx) + // Result has ASCII quotes because that's what the patch specifies + expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts deleted file mode 100644 index 3d3ec574e60..00000000000 --- a/packages/opencode/test/tool/patch.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, expect, test } from "bun:test" -import path from "path" -import { PatchTool } from "../../src/tool/patch" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" -import { PermissionNext } from "../../src/permission/next" -import * as fs from "fs/promises" - -const ctx = { - sessionID: "test", - messageID: "", - callID: "", - agent: "build", - abort: AbortSignal.any([]), - metadata: () => {}, - ask: async () => {}, -} - -const patchTool = await PatchTool.init() - -describe("tool.patch", () => { - test("should validate required parameters", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required") - }, - }) - }) - - test("should validate patch format", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch") - }, - }) - }) - - test("should handle empty patch", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - const emptyPatch = `*** Begin Patch -*** End Patch` - - expect(patchTool.execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("No file changes found in patch") - }, - }) - }) - - test.skip("should ask permission for files outside working directory", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - const maliciousPatch = `*** Begin Patch -*** Add File: /etc/passwd -+malicious content -*** End Patch` - patchTool.execute({ patchText: maliciousPatch }, ctx) - // TODO: this sucks - await new Promise((resolve) => setTimeout(resolve, 1000)) - const pending = await PermissionNext.list() - expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined() - }, - }) - }) - - test("should handle simple add file operation", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: test-file.txt -+Hello World -+This is a test file -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify file was created - const filePath = path.join(fixture.path, "test-file.txt") - const content = await fs.readFile(filePath, "utf-8") - expect(content).toBe("Hello World\nThis is a test file") - }, - }) - }) - - test("should handle file with context update", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: config.js -+const API_KEY = "test-key" -+const DEBUG = false -+const VERSION = "1.0" -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify file was created with correct content - const filePath = path.join(fixture.path, "config.js") - const content = await fs.readFile(filePath, "utf-8") - expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"') - }, - }) - }) - - test("should handle multiple file operations", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: file1.txt -+Content of file 1 -*** Add File: file2.txt -+Content of file 2 -*** Add File: file3.txt -+Content of file 3 -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("3 files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify all files were created - for (let i = 1; i <= 3; i++) { - const filePath = path.join(fixture.path, `file${i}.txt`) - const content = await fs.readFile(filePath, "utf-8") - expect(content).toBe(`Content of file ${i}`) - } - }, - }) - }) - - test("should create parent directories when adding nested files", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: deep/nested/file.txt -+Deep nested content -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("files changed") - expect(result.output).toContain("Patch applied successfully") - - // Verify nested file was created - const nestedPath = path.join(fixture.path, "deep", "nested", "file.txt") - const exists = await fs - .access(nestedPath) - .then(() => true) - .catch(() => false) - expect(exists).toBe(true) - - const content = await fs.readFile(nestedPath, "utf-8") - expect(content).toBe("Deep nested content") - }, - }) - }) - - test("should generate proper unified diff in metadata", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - // First create a file with simple content - const patchText1 = `*** Begin Patch -*** Add File: test.txt -+line 1 -+line 2 -+line 3 -*** End Patch` - - await patchTool.execute({ patchText: patchText1 }, ctx) - - // Now create an update patch - const patchText2 = `*** Begin Patch -*** Update File: test.txt -@@ - line 1 --line 2 -+line 2 updated - line 3 -*** End Patch` - - const result = await patchTool.execute({ patchText: patchText2 }, ctx) - - expect(result.metadata.diff).toBeDefined() - expect(result.metadata.diff).toContain("@@") - expect(result.metadata.diff).toContain("-line 2") - expect(result.metadata.diff).toContain("+line 2 updated") - }, - }) - }) - - test("should handle complex patch with multiple operations", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: new.txt -+This is a new file -+with multiple lines -*** Add File: existing.txt -+old content -+new line -+more content -*** Add File: config.json -+{ -+ "version": "1.0", -+ "debug": true -+} -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("3 files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify all files were created - const newPath = path.join(fixture.path, "new.txt") - const newContent = await fs.readFile(newPath, "utf-8") - expect(newContent).toBe("This is a new file\nwith multiple lines") - - const existingPath = path.join(fixture.path, "existing.txt") - const existingContent = await fs.readFile(existingPath, "utf-8") - expect(existingContent).toBe("old content\nnew line\nmore content") - - const configPath = path.join(fixture.path, "config.json") - const configContent = await fs.readFile(configPath, "utf-8") - expect(configContent).toBe('{\n "version": "1.0",\n "debug": true\n}') - }, - }) - }) -}) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts new file mode 100644 index 00000000000..fa95e9612b6 --- /dev/null +++ b/packages/opencode/test/tool/question.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" +import { z } from "zod" +import { QuestionTool } from "../../src/tool/question" +import * as QuestionModule from "../../src/question" + +const ctx = { + sessionID: "test-session", + messageID: "test-message", + callID: "test-call", + agent: "test-agent", + abort: AbortSignal.any([]), + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.question", () => { + let askSpy: any + + beforeEach(() => { + askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => { + return [] + }) + }) + + afterEach(() => { + askSpy.mockRestore() + }) + + test("should successfully execute with valid question parameters", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "What is your favorite color?", + header: "Color", + options: [ + { label: "Red", description: "The color of passion" }, + { label: "Blue", description: "The color of sky" }, + ], + multiple: false, + }, + ] + + askSpy.mockResolvedValueOnce([["Red"]]) + + const result = await tool.execute({ questions }, ctx) + expect(askSpy).toHaveBeenCalledTimes(1) + expect(result.title).toBe("Asked 1 question") + }) + + test("should now pass with a header longer than 12 but less than 30 chars", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Over 12", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] + + askSpy.mockResolvedValueOnce([["Dog"]]) + + const result = await tool.execute({ questions }, ctx) + expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) + }) + + test("should throw an Error for header exceeding 30 characters", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Definitely More Than Thirty Characters Long", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] + try { + await tool.execute({ questions }, ctx) + // If it reaches here, the test should fail + expect(true).toBe(false) + } catch (e: any) { + expect(e).toBeInstanceOf(Error) + expect(e.cause).toBeInstanceOf(z.ZodError) + } + }) + + test("should throw an Error for label exceeding 30 characters", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "A question with a very long label", + header: "Long Label", + options: [ + { label: "This is a very, very, very long label that will exceed the limit", description: "A description" }, + ], + }, + ] + try { + await tool.execute({ questions }, ctx) + // If it reaches here, the test should fail + expect(true).toBe(false) + } catch (e: any) { + expect(e).toBeInstanceOf(Error) + expect(e.cause).toBeInstanceOf(z.ZodError) + } + }) +}) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 04ffc80ea67..7250bd2fd1e 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -300,4 +300,33 @@ describe("tool.read truncation", () => { }, }) }) + + test(".fbs files (FlatBuffers schema) are read as text, not images", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // FlatBuffers schema content + const fbsContent = `namespace MyGame; + +table Monster { + pos:Vec3; + name:string; + inventory:[ubyte]; +} + +root_type Monster;` + await Bun.write(path.join(dir, "schema.fbs"), fbsContent) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "schema.fbs") }, ctx) + // Should be read as text, not as image + expect(result.attachments).toBeUndefined() + expect(result.output).toContain("namespace MyGame") + expect(result.output).toContain("table Monster") + }, + }) + }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 7ddfca3168a..27349018083 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index e57eff579e6..36a4657d74c 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -173,6 +173,10 @@ export interface Hooks { output: { temperature: number; topP: number; topK: number; options: Record }, ) => Promise "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise + "command.execute.before"?: ( + input: { command: string; sessionID: string; arguments: string }, + output: { parts: Part[] }, + ) => Promise "tool.execute.before"?: ( input: { tool: string; sessionID: string; callID: string }, output: { args: any }, diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index e5dde4633ba..f3b12aa8c9f 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 32f33f66219..ca13e5e93cf 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -842,6 +842,14 @@ export type KeybindsConfig = { * Scroll messages down by one page */ messages_page_down?: string + /** + * Scroll messages up by one line + */ + messages_line_up?: string + /** + * Scroll messages down by one line + */ + messages_line_down?: string /** * Scroll messages up by half page */ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 32321a7dfd8..04e7144eb72 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -545,7 +545,7 @@ export type QuestionInfo = { */ question: string /** - * Very short label (max 12 chars) + * Very short label (max 30 chars) */ header: string /** @@ -633,6 +633,14 @@ export type EventTodoUpdated = { } } +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type EventTuiPromptAppend = { type: "tui.prompt.append" properties: { @@ -651,6 +659,8 @@ export type EventTuiCommandExecute = { | "session.compact" | "session.page.up" | "session.page.down" + | "session.line.up" + | "session.line.down" | "session.half.page.up" | "session.half.page.down" | "session.first" @@ -789,14 +799,6 @@ export type EventSessionError = { } } -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type EventVcsBranchUpdated = { type: "vcs.branch.updated" properties: { @@ -878,6 +880,7 @@ export type Event = | EventQuestionRejected | EventSessionCompacted | EventTodoUpdated + | EventFileWatcherUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -890,7 +893,6 @@ export type Event = | EventSessionDeleted | EventSessionDiff | EventSessionError - | EventFileWatcherUpdated | EventVcsBranchUpdated | EventPtyCreated | EventPtyUpdated @@ -1019,6 +1021,14 @@ export type KeybindsConfig = { * Scroll messages down by one page */ messages_page_down?: string + /** + * Scroll messages up by one line + */ + messages_line_up?: string + /** + * Scroll messages down by one line + */ + messages_line_down?: string /** * Scroll messages up by half page */ @@ -1530,10 +1540,6 @@ export type McpOAuthConfig = { * OAuth scopes to request during authorization */ scope?: string - /** - * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). - */ - redirectUri?: string } export type McpRemoteConfig = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 904f3eeaefa..104cedce1e5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7163,7 +7163,8 @@ "properties": { "label": { "description": "Display text (1-5 words, concise)", - "type": "string" + "type": "string", + "maxLength": 30 }, "description": { "description": "Explanation of choice", @@ -7180,9 +7181,9 @@ "type": "string" }, "header": { - "description": "Very short label (max 12 chars)", + "description": "Very short label (max 30 chars)", "type": "string", - "maxLength": 12 + "maxLength": 30 }, "options": { "description": "Available choices", @@ -7370,6 +7371,41 @@ }, "required": ["type", "properties"] }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event.tui.prompt.append": { "type": "object", "properties": { @@ -7411,6 +7447,8 @@ "session.compact", "session.page.up", "session.page.down", + "session.line.up", + "session.line.down", "session.half.page.up", "session.half.page.down", "session.first", @@ -7796,41 +7834,6 @@ }, "required": ["type", "properties"] }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "Event.vcs.branch.updated": { "type": "object", "properties": { @@ -8052,6 +8055,9 @@ { "$ref": "#/components/schemas/Event.todo.updated" }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.tui.prompt.append" }, @@ -8088,9 +8094,6 @@ { "$ref": "#/components/schemas/Event.session.error" }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, @@ -8282,12 +8285,22 @@ }, "messages_page_up": { "description": "Scroll messages up by one page", - "default": "pageup", + "default": "pageup,ctrl+alt+b", "type": "string" }, "messages_page_down": { "description": "Scroll messages down by one page", - "default": "pagedown", + "default": "pagedown,ctrl+alt+f", + "type": "string" + }, + "messages_line_up": { + "description": "Scroll messages up by one line", + "default": "ctrl+alt+y", + "type": "string" + }, + "messages_line_down": { + "description": "Scroll messages down by one line", + "default": "ctrl+alt+e", "type": "string" }, "messages_half_page_up": { @@ -9122,10 +9135,6 @@ "scope": { "description": "OAuth scopes to request during authorization", "type": "string" - }, - "redirectUri": { - "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", - "type": "string" } }, "additionalProperties": false diff --git a/packages/ui/package.json b/packages/ui/package.json index ef6eec23ac8..0b490591c35 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -59,6 +59,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "strip-ansi": "7.1.2", "virtua": "catalog:" } } diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index a2f279f203a..5c7cdc233bf 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -24,7 +24,7 @@ [data-slot="dialog-container"] { position: relative; z-index: 50; - width: min(calc(100vw - 16px), 480px); + width: min(calc(100vw - 16px), 640px); height: min(calc(100vh - 16px), 512px); display: flex; flex-direction: column; diff --git a/packages/ui/src/components/keybind.css b/packages/ui/src/components/keybind.css new file mode 100644 index 00000000000..1a9e5dce43e --- /dev/null +++ b/packages/ui/src/components/keybind.css @@ -0,0 +1,18 @@ +[data-component="keybind"] { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + height: 20px; + padding: 0 8px; + border-radius: 2px; + background: var(--surface-base); + box-shadow: var(--shadow-xxs-border); + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-medium); + line-height: 1; + color: var(--text-weak); +} diff --git a/packages/ui/src/components/keybind.tsx b/packages/ui/src/components/keybind.tsx new file mode 100644 index 00000000000..a0fa0483fd9 --- /dev/null +++ b/packages/ui/src/components/keybind.tsx @@ -0,0 +1,20 @@ +import type { ComponentProps, ParentProps } from "solid-js" + +export interface KeybindProps extends ParentProps { + class?: string + classList?: ComponentProps<"span">["classList"] +} + +export function Keybind(props: KeybindProps) { + return ( + + {props.children} + + ) +} diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 651f5ef971e..ee9be422c97 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -56,11 +56,14 @@ width: 20px; height: 20px; background-color: transparent; + opacity: 0.5; + transition: opacity 0.15s ease; &:hover:not(:disabled), &:focus:not(:disabled), &:active:not(:disabled) { background-color: transparent; + opacity: 0.7; } &:hover:not(:disabled) [data-slot="icon-svg"] { @@ -91,7 +94,7 @@ [data-slot="list-empty-state"] { display: flex; - padding: 32px 0px; + padding: 32px 48px; flex-direction: column; justify-content: center; align-items: center; @@ -103,8 +106,9 @@ justify-content: center; align-items: center; gap: 2px; + max-width: 100%; color: var(--text-weak); - text-align: center; + white-space: nowrap; /* text-14-regular */ font-family: var(--font-family-sans); @@ -117,6 +121,8 @@ [data-slot="list-filter"] { color: var(--text-strong); + overflow: hidden; + text-overflow: ellipsis; } } @@ -125,10 +131,14 @@ display: flex; flex-direction: column; + &:last-child { + padding-bottom: 12px; + } + [data-slot="list-header"] { display: flex; z-index: 10; - padding: 0 12px 8px 8px; + padding: 8px 12px 8px 12px; justify-content: space-between; align-items: center; align-self: stretch; @@ -136,7 +146,7 @@ position: sticky; top: 0; - color: var(--text-base); + color: var(--text-weak); /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index dc83db53a47..631b3e33a29 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -8,6 +8,8 @@ import { TextField } from "./text-field" export interface ListSearchProps { placeholder?: string autofocus?: boolean + hideIcon?: boolean + class?: string } export interface ListProps extends FilteredListProps { @@ -67,7 +69,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (!props.current) return const key = props.key(props.current) requestAnimationFrame(() => { - const element = scrollRef()?.querySelector(`[data-key="${key}"]`) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`) element?.scrollIntoView({ block: "center" }) }) }) @@ -79,8 +81,8 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) scrollRef()?.scrollTo(0, 0) return } - const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) - element?.scrollIntoView({ block: "center", behavior: "smooth" }) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`) + element?.scrollIntoView({ block: "center" }) }) createEffect(() => { @@ -146,9 +148,11 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
-
+
- + + + { ) } +export const Splash = (props: { class?: string }) => { + return ( + + + + + ) +} + export const Logo = (props: { class?: string }) => { return ( 1 ? "s" : ""}` : undefined, + } case "todowrite": return { icon: "checklist", @@ -926,7 +933,7 @@ ToolRegistry.register({ >
@@ -1026,6 +1033,94 @@ ToolRegistry.register({ }, }) +interface ApplyPatchFile { + filePath: string + relativePath: string + type: "add" | "update" | "delete" | "move" + diff: string + before: string + after: string + additions: number + deletions: number + movePath?: string +} + +ToolRegistry.register({ + name: "apply_patch", + render(props) { + const diffComponent = useDiffComponent() + const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) + + const subtitle = createMemo(() => { + const count = files().length + if (count === 0) return "" + return `${count} file${count > 1 ? "s" : ""}` + }) + + return ( + + 0}> +
+ + {(file) => ( +
+
+ + + + Deleted + + + + + Created + + + + + Moved + + + + + Patched + + + + {file.relativePath} + + + + + -{file.deletions} + +
+ +
+ +
+
+
+ )} +
+
+
+
+ ) + }, +}) + ToolRegistry.register({ name: "todowrite", render(props) { diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index c38ee5847db..711047030cc 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -5,6 +5,8 @@ import type { ComponentProps } from "solid-js" export interface TooltipProps extends ComponentProps { value: JSX.Element class?: string + contentClass?: string + contentStyle?: JSX.CSSProperties inactive?: boolean } @@ -30,7 +32,7 @@ export function TooltipKeybind(props: TooltipKeybindProps) { export function Tooltip(props: TooltipProps) { const [open, setOpen] = createSignal(false) - const [local, others] = splitProps(props, ["children", "class", "inactive"]) + const [local, others] = splitProps(props, ["children", "class", "contentClass", "contentStyle", "inactive"]) const c = children(() => local.children) @@ -58,7 +60,12 @@ export function Tooltip(props: TooltipProps) { {c()} - + {others.value} {/* */} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 8ab4d6ca4d0..b4b0883aeb0 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -24,6 +24,7 @@ @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components); @import "../components/image-preview.css" layer(components); +@import "../components/keybind.css" layer(components); @import "../components/text-field.css" layer(components); @import "../components/inline-input.css" layer(components); @import "../components/list.css" layer(components); diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index b85bd2142fa..ea1f779cd37 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -21,7 +21,7 @@ There are two types of agents in OpenCode; primary agents and subagents. ### Primary agents -Primary agents are the main assistants you interact with directly. You can cycle through them using the **Tab** key, or your configured `switch_agent` keybind. These agents handle your main conversation and can access all configured tools. +Primary agents are the main assistants you interact with directly. You can cycle through them using the **Tab** key, or your configured `switch_agent` keybind. These agents handle your main conversation. Tool access is configured via permissions — for example, Build has all tools enabled while Plan is restricted. :::tip You can use the **Tab** key to switch between primary agents during a session. @@ -72,7 +72,7 @@ This agent is useful when you want the LLM to analyze code, suggest changes, or _Mode_: `subagent` -A general-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. Use when searching for keywords or files and you're not confident you'll find the right match in the first few tries. +A general-purpose agent for researching complex questions and executing multi-step tasks. Has full tool access (except todo), so it can make file changes when needed. Use this to run multiple units of work in parallel. --- @@ -80,7 +80,7 @@ A general-purpose agent for researching complex questions, searching for code, a _Mode_: `subagent` -A fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase. +A fast, read-only agent for exploring codebases. Cannot modify files. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase. --- @@ -157,10 +157,10 @@ Configure agents in your `opencode.json` config file: You can also define agents using markdown files. Place them in: -- Global: `~/.config/opencode/agent/` -- Per-project: `.opencode/agent/` +- Global: `~/.config/opencode/agents/` +- Per-project: `.opencode/agents/` -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Reviews code for quality and best practices mode: subagent @@ -419,7 +419,7 @@ You can override these permissions per agent. You can also set permissions in Markdown agents. -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Code review without edits mode: subagent @@ -637,7 +637,7 @@ Do you have an agent you'd like to share? [Submit a PR](https://github.com/anoma ### Documentation agent -```markdown title="~/.config/opencode/agent/docs-writer.md" +```markdown title="~/.config/opencode/agents/docs-writer.md" --- description: Writes and maintains project documentation mode: subagent @@ -659,7 +659,7 @@ Focus on: ### Security auditor -```markdown title="~/.config/opencode/agent/security-auditor.md" +```markdown title="~/.config/opencode/agents/security-auditor.md" --- description: Performs security audits and identifies vulnerabilities mode: subagent diff --git a/packages/web/src/content/docs/commands.mdx b/packages/web/src/content/docs/commands.mdx index 92ca08bd2e9..1d7e4f1c21a 100644 --- a/packages/web/src/content/docs/commands.mdx +++ b/packages/web/src/content/docs/commands.mdx @@ -15,11 +15,11 @@ Custom commands are in addition to the built-in commands like `/init`, `/undo`, ## Create command files -Create markdown files in the `command/` directory to define custom commands. +Create markdown files in the `commands/` directory to define custom commands. -Create `.opencode/command/test.md`: +Create `.opencode/commands/test.md`: -```md title=".opencode/command/test.md" +```md title=".opencode/commands/test.md" --- description: Run tests with coverage agent: build @@ -42,7 +42,7 @@ Use the command by typing `/` followed by the command name. ## Configure -You can add custom commands through the OpenCode config or by creating markdown files in the `command/` directory. +You can add custom commands through the OpenCode config or by creating markdown files in the `commands/` directory. --- @@ -79,10 +79,10 @@ Now you can run this command in the TUI: You can also define commands using markdown files. Place them in: -- Global: `~/.config/opencode/command/` -- Per-project: `.opencode/command/` +- Global: `~/.config/opencode/commands/` +- Per-project: `.opencode/commands/` -```markdown title="~/.config/opencode/command/test.md" +```markdown title="~/.config/opencode/commands/test.md" --- description: Run tests with coverage agent: build @@ -112,7 +112,7 @@ The prompts for the custom commands support several special placeholders and syn Pass arguments to commands using the `$ARGUMENTS` placeholder. -```md title=".opencode/command/component.md" +```md title=".opencode/commands/component.md" --- description: Create a new component --- @@ -138,7 +138,7 @@ You can also access individual arguments using positional parameters: For example: -```md title=".opencode/command/create-file.md" +```md title=".opencode/commands/create-file.md" --- description: Create a new file with content --- @@ -167,7 +167,7 @@ Use _!`command`_ to inject [bash command](/docs/tui#bash-commands) output into y For example, to create a custom command that analyzes test coverage: -```md title=".opencode/command/analyze-coverage.md" +```md title=".opencode/commands/analyze-coverage.md" --- description: Analyze test coverage --- @@ -180,7 +180,7 @@ Based on these results, suggest improvements to increase coverage. Or to review recent changes: -```md title=".opencode/command/review-changes.md" +```md title=".opencode/commands/review-changes.md" --- description: Review recent changes --- @@ -199,7 +199,7 @@ Commands run in your project's root directory and their output becomes part of t Include files in your command using `@` followed by the filename. -```md title=".opencode/command/review-component.md" +```md title=".opencode/commands/review-component.md" --- description: Review component --- diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 30edbbd2146..1474cb91558 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -51,6 +51,10 @@ Config sources are loaded in this order (later sources override earlier ones): This means project configs can override global defaults, and global configs can override remote organizational defaults. +:::note +The `.opencode` and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility. +::: + --- ### Remote @@ -330,7 +334,7 @@ You can configure specialized agents for specific tasks through the `agent` opti } ``` -You can also define agents using markdown files in `~/.config/opencode/agent/` or `.opencode/agent/`. [Learn more here](/docs/agents). +You can also define agents using markdown files in `~/.config/opencode/agents/` or `.opencode/agents/`. [Learn more here](/docs/agents). --- @@ -394,7 +398,7 @@ You can configure custom commands for repetitive tasks through the `command` opt } ``` -You can also define commands using markdown files in `~/.config/opencode/command/` or `.opencode/command/`. [Learn more here](/docs/commands). +You can also define commands using markdown files in `~/.config/opencode/commands/` or `.opencode/commands/`. [Learn more here](/docs/commands). --- @@ -425,6 +429,7 @@ OpenCode will automatically download any new updates when it starts up. You can ``` If you don't want updates but want to be notified when a new version is available, set `autoupdate` to `"notify"`. +Notice that this only works if it was not installed using a package manager such as Homebrew. --- @@ -529,7 +534,7 @@ You can configure MCP servers you want to use through the `mcp` option. [Plugins](/docs/plugins) extend OpenCode with custom tools, hooks, and integrations. -Place plugin files in `.opencode/plugin/` or `~/.config/opencode/plugin/`. You can also load plugins from npm through the `plugin` option. +Place plugin files in `.opencode/plugins/` or `~/.config/opencode/plugins/`. You can also load plugins from npm through the `plugin` option. ```json title="opencode.json" { diff --git a/packages/web/src/content/docs/custom-tools.mdx b/packages/web/src/content/docs/custom-tools.mdx index 2701be65086..e089a035b4b 100644 --- a/packages/web/src/content/docs/custom-tools.mdx +++ b/packages/web/src/content/docs/custom-tools.mdx @@ -17,8 +17,8 @@ Tools are defined as **TypeScript** or **JavaScript** files. However, the tool d They can be defined: -- Locally by placing them in the `.opencode/tool/` directory of your project. -- Or globally, by placing them in `~/.config/opencode/tool/`. +- Locally by placing them in the `.opencode/tools/` directory of your project. +- Or globally, by placing them in `~/.config/opencode/tools/`. --- @@ -26,7 +26,7 @@ They can be defined: The easiest way to create tools is using the `tool()` helper which provides type-safety and validation. -```ts title=".opencode/tool/database.ts" {1} +```ts title=".opencode/tools/database.ts" {1} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -49,7 +49,7 @@ The **filename** becomes the **tool name**. The above creates a `database` tool. You can also export multiple tools from a single file. Each export becomes **a separate tool** with the name **`_`**: -```ts title=".opencode/tool/math.ts" +```ts title=".opencode/tools/math.ts" import { tool } from "@opencode-ai/plugin" export const add = tool({ @@ -112,7 +112,7 @@ export default { Tools receive context about the current session: -```ts title=".opencode/tool/project.ts" {8} +```ts title=".opencode/tools/project.ts" {8} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -136,7 +136,7 @@ You can write your tools in any language you want. Here's an example that adds t First, create the tool as a Python script: -```python title=".opencode/tool/add.py" +```python title=".opencode/tools/add.py" import sys a = int(sys.argv[1]) @@ -146,7 +146,7 @@ print(a + b) Then create the tool definition that invokes it: -```ts title=".opencode/tool/python-add.ts" {10} +```ts title=".opencode/tools/python-add.ts" {10} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -156,7 +156,7 @@ export default tool({ b: tool.schema.number().describe("Second number"), }, async execute(args) { - const result = await Bun.$`python3 .opencode/tool/add.py ${args.a} ${args.b}`.text() + const result = await Bun.$`python3 .opencode/tools/add.py ${args.a} ${args.b}`.text() return result.trim() }, }) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 44a73de69e9..ce3e3deb86c 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -47,16 +47,17 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Projects -| Name | Description | -| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | -| [kimaki](https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | -| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | -| [portal](https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | -| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | -| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Neovim frontend for opencode - a terminal-based AI coding agent | -| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK provider for using OpenCode via @opencode-ai/sdk | -| [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | -| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | +| Name | Description | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| [kimaki](https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | +| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | +| [portal](https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | +| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | +| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Neovim frontend for opencode - a terminal-based AI coding agent | +| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK provider for using OpenCode via @opencode-ai/sdk | +| [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | +| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | +| [OpenWork](https://github.com/different-ai/openwork) | An open-source alternative to Claude Cowork, powered by OpenCode | --- diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index 6e8b9de4d79..a31fe1e7be8 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -180,8 +180,10 @@ jobs: - uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true prompt: | Review this pull request: - Check for code quality issues diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx index bee5bd3a38e..8b3d3a9c824 100644 --- a/packages/web/src/content/docs/index.mdx +++ b/packages/web/src/content/docs/index.mdx @@ -98,8 +98,7 @@ You can also install it with the following commands: - **Using Scoop** ```bash - scoop bucket add extras - scoop install extras/opencode + scoop install opencode ``` - **Using NPM** diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 267d194c099..51508a4f864 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -31,8 +31,10 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", - "messages_page_up": "pageup", - "messages_page_down": "pagedown", + "messages_page_up": "pageup,ctrl+alt+b", + "messages_page_down": "pagedown,ctrl+alt+f", + "messages_line_up": "ctrl+alt+y", + "messages_line_down": "ctrl+alt+e", "messages_half_page_up": "ctrl+alt+u", "messages_half_page_down": "ctrl+alt+d", "messages_first": "ctrl+g,home", diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx index a31a8223b07..57c1c54a956 100644 --- a/packages/web/src/content/docs/modes.mdx +++ b/packages/web/src/content/docs/modes.mdx @@ -87,10 +87,10 @@ Configure modes in your `opencode.json` config file: You can also define modes using markdown files. Place them in: -- Global: `~/.config/opencode/mode/` -- Project: `.opencode/mode/` +- Global: `~/.config/opencode/modes/` +- Project: `.opencode/modes/` -```markdown title="~/.config/opencode/mode/review.md" +```markdown title="~/.config/opencode/modes/review.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.1 @@ -268,9 +268,9 @@ You can create your own custom modes by adding them to the configuration. Here a ### Using markdown files -Create mode files in `.opencode/mode/` for project-specific modes or `~/.config/opencode/mode/` for global modes: +Create mode files in `.opencode/modes/` for project-specific modes or `~/.config/opencode/modes/` for global modes: -```markdown title=".opencode/mode/debug.md" +```markdown title=".opencode/modes/debug.md" --- temperature: 0.1 tools: @@ -294,7 +294,7 @@ Focus on: Do not make any changes to files. Only investigate and report. ``` -```markdown title="~/.config/opencode/mode/refactor.md" +```markdown title="~/.config/opencode/modes/refactor.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.2 diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index b4f0691ced7..4df3841e34a 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -174,7 +174,7 @@ Refer to the [Granular Rules (Object Syntax)](#granular-rules-object-syntax) sec You can also configure agent permissions in Markdown: -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Code review without edits mode: subagent diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index bf26744f6c4..66a1b3cad95 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -19,8 +19,8 @@ There are two ways to load plugins. Place JavaScript or TypeScript files in the plugin directory. -- `.opencode/plugin/` - Project-level plugins -- `~/.config/opencode/plugin/` - Global plugins +- `.opencode/plugins/` - Project-level plugins +- `~/.config/opencode/plugins/` - Global plugins Files in these directories are automatically loaded at startup. @@ -57,8 +57,8 @@ Plugins are loaded from all sources and all hooks run in sequence. The load orde 1. Global config (`~/.config/opencode/opencode.json`) 2. Project config (`opencode.json`) -3. Global plugin directory (`~/.config/opencode/plugin/`) -4. Project plugin directory (`.opencode/plugin/`) +3. Global plugin directory (`~/.config/opencode/plugins/`) +4. Project plugin directory (`.opencode/plugins/`) Duplicate npm packages with the same name and version are loaded once. However, a local plugin and an npm plugin with similar names are both loaded separately. @@ -85,7 +85,7 @@ Local plugins and custom tools can use external npm packages. Add a `package.jso OpenCode runs `bun install` at startup to install these. Your plugins and tools can then import them. -```ts title=".opencode/plugin/my-plugin.ts" +```ts title=".opencode/plugins/my-plugin.ts" import { escape } from "shescape" export const MyPlugin = async (ctx) => { @@ -103,7 +103,7 @@ export const MyPlugin = async (ctx) => { ### Basic structure -```js title=".opencode/plugin/example.js" +```js title=".opencode/plugins/example.js" export const MyPlugin = async ({ project, client, $, directory, worktree }) => { console.log("Plugin initialized!") @@ -215,7 +215,7 @@ Here are some examples of plugins you can use to extend opencode. Send notifications when certain events occur: -```js title=".opencode/plugin/notification.js" +```js title=".opencode/plugins/notification.js" export const NotificationPlugin = async ({ project, client, $, directory, worktree }) => { return { event: async ({ event }) => { @@ -240,7 +240,7 @@ If you’re using the OpenCode desktop app, it can send system notifications aut Prevent opencode from reading `.env` files: -```javascript title=".opencode/plugin/env-protection.js" +```javascript title=".opencode/plugins/env-protection.js" export const EnvProtection = async ({ project, client, $, directory, worktree }) => { return { "tool.execute.before": async (input, output) => { @@ -258,7 +258,7 @@ export const EnvProtection = async ({ project, client, $, directory, worktree }) Plugins can also add custom tools to opencode: -```ts title=".opencode/plugin/custom-tools.ts" +```ts title=".opencode/plugins/custom-tools.ts" import { type Plugin, tool } from "@opencode-ai/plugin" export const CustomToolsPlugin: Plugin = async (ctx) => { @@ -292,7 +292,7 @@ Your custom tools will be available to opencode alongside built-in tools. Use `client.app.log()` instead of `console.log` for structured logging: -```ts title=".opencode/plugin/my-plugin.ts" +```ts title=".opencode/plugins/my-plugin.ts" export const MyPlugin = async ({ client }) => { await client.app.log({ service: "my-plugin", @@ -311,7 +311,7 @@ Levels: `debug`, `info`, `warn`, `error`. See [SDK documentation](https://openco Customize the context included when a session is compacted: -```ts title=".opencode/plugin/compaction.ts" +```ts title=".opencode/plugins/compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CompactionPlugin: Plugin = async (ctx) => { @@ -335,7 +335,7 @@ The `experimental.session.compacting` hook fires before the LLM generates a cont You can also replace the compaction prompt entirely by setting `output.prompt`: -```ts title=".opencode/plugin/custom-compaction.ts" +```ts title=".opencode/plugins/custom-compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CustomCompactionPlugin: Plugin = async (ctx) => { diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index e1d684de00a..6022d174a7d 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -558,6 +558,33 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, --- +### Firmware + +1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key. + +2. Run the `/connect` command and search for **Firmware**. + + ```txt + /connect + ``` + +3. Enter your Firmware API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select a model. + + ```txt + /models + ``` + +--- + ### Fireworks AI 1. Head over to the [Fireworks AI console](https://app.fireworks.ai/), create an account, and click **Create API Key**. diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 54c2c9d06ef..553931eec49 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -13,8 +13,8 @@ Skills are loaded on-demand via the native `skill` tool—agents see available s Create one folder per skill name and put a `SKILL.md` inside it. OpenCode searches these locations: -- Project config: `.opencode/skill//SKILL.md` -- Global config: `~/.config/opencode/skill//SKILL.md` +- Project config: `.opencode/skills//SKILL.md` +- Global config: `~/.config/opencode/skills//SKILL.md` - Project Claude-compatible: `.claude/skills//SKILL.md` - Global Claude-compatible: `~/.claude/skills//SKILL.md` @@ -23,9 +23,9 @@ OpenCode searches these locations: ## Understand discovery For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree. -It loads any matching `skill/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. +It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. -Global definitions are also loaded from `~/.config/opencode/skill/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. +Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. --- @@ -71,7 +71,7 @@ Keep it specific enough for the agent to choose correctly. ## Use an example -Create `.opencode/skill/git-release/SKILL.md` like this: +Create `.opencode/skills/git-release/SKILL.md` like this: ```markdown ---