瀏覽代碼

#5 Initial code for "gui-only" plugin

paviko 6 天之前
父節點
當前提交
7d7f90abb5

+ 99 - 18
.github/workflows/release.yml

@@ -84,7 +84,7 @@ jobs:
             npm run compile:production
           fi
 
-      - name: Package VSCode plugin from staging (no prepublish)
+      - name: Package VSCode plugin (standard) from staging
         working-directory: hosts/vscode-plugin
         run: |
           STAGE_DIR="../tmp_opencode_vscode_stage"
@@ -95,6 +95,8 @@ jobs:
           cp package.json "$STAGE_DIR/"
           cp -R out "$STAGE_DIR/out"
           cp -R resources "$STAGE_DIR/resources"
+          # Exclude gui-only artifacts from standard build
+          rm -rf "$STAGE_DIR/resources/webgui-app"
           [ -f README.md ] && cp README.md "$STAGE_DIR/" || true
           [ -f CHANGELOG.md ] && cp CHANGELOG.md "$STAGE_DIR/" || true
           [ -f ../../LICENSE ] && cp ../../LICENSE "$STAGE_DIR/" || true
@@ -111,7 +113,72 @@ jobs:
           '
 
           # Package from staging directory without touching dependencies
-          ( cd "$STAGE_DIR" && npx -y @vscode/vsce package --no-dependencies --out "opencode-$(date +%Y%m%d-%H%M%S).vsix" )
+          ( cd "$STAGE_DIR" && npx -y @vscode/vsce package --no-dependencies --out "opencode-standard.vsix" )
+
+          # Move artifact back into plugin dir for later collection
+          mv "$STAGE_DIR"/*.vsix .
+
+          # Clean up staging
+          rm -rf "$STAGE_DIR"
+
+      - name: Build webgui for gui-only variant
+        run: |
+          cd packages/opencode/webgui
+          if [ -f pnpm-lock.yaml ]; then
+            pnpm install --frozen-lockfile
+            pnpm run build
+          elif [ -f bun.lock ] || [ -f bun.lockb ]; then
+            bun install --frozen-lockfile
+            bun run build
+          else
+            npm ci
+            npm run build
+          fi
+
+      - name: Package VSCode plugin (gui-only) from staging
+        working-directory: hosts/vscode-plugin
+        run: |
+          WEBGUI_DIST="../../packages/opencode/webgui-dist"
+          if [ ! -d "$WEBGUI_DIST" ]; then
+            echo "::error::webgui-dist not found at $WEBGUI_DIST"
+            exit 1
+          fi
+
+          STAGE_DIR="../tmp_opencode_vscode_gui_only_stage"
+          rm -rf "$STAGE_DIR"
+          mkdir -p "$STAGE_DIR"
+
+          # Copy minimal required files — NO binaries
+          cp package.json "$STAGE_DIR/"
+          cp -R out "$STAGE_DIR/out"
+          mkdir -p "$STAGE_DIR/resources"
+          # Copy resources except bin/
+          for item in resources/*; do
+            [ "$(basename "$item")" = "bin" ] && continue
+            cp -R "$item" "$STAGE_DIR/resources/"
+          done
+          # Embed webgui-dist
+          cp -R "$WEBGUI_DIST" "$STAGE_DIR/resources/webgui-app"
+          [ -f README.md ] && cp README.md "$STAGE_DIR/" || true
+          [ -f CHANGELOG.md ] && cp CHANGELOG.md "$STAGE_DIR/" || true
+          [ -f ../../LICENSE ] && cp ../../LICENSE "$STAGE_DIR/" || true
+
+          # Deep-merge gui-only overrides into package.json, remove scripts/devDependencies
+          node -e '
+            const fs=require("fs");
+            const path=require("path");
+            function deep(t,s){for(const k of Object.keys(s)){const sv=s[k],tv=t[k];if(Array.isArray(sv)&&Array.isArray(tv)){for(let i=0;i<sv.length;i++){if(i<tv.length&&typeof sv[i]==="object"&&typeof tv[i]==="object"){deep(tv[i],sv[i])}else{tv[i]=sv[i]}}}else if(sv&&typeof sv==="object"&&!Array.isArray(sv)&&tv&&typeof tv==="object"&&!Array.isArray(tv)){deep(tv,sv)}else{t[k]=sv}}return t}
+            const pkgPath=path.resolve(process.cwd(), "../tmp_opencode_vscode_gui_only_stage/package.json");
+            const pkg=JSON.parse(fs.readFileSync(pkgPath, "utf8"));
+            const overrides=JSON.parse(fs.readFileSync("package.gui-only.json", "utf8"));
+            deep(pkg, overrides);
+            delete pkg.scripts;
+            delete pkg.devDependencies;
+            fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
+          '
+
+          # Package gui-only variant
+          ( cd "$STAGE_DIR" && npx -y @vscode/vsce package --no-dependencies --out "opencode-gui-only.vsix" )
 
           # Move artifact back into plugin dir for later collection
           mv "$STAGE_DIR"/*.vsix .
@@ -130,20 +197,21 @@ jobs:
         run: |
           mkdir -p release-artifacts
 
-          # Find and copy VSCode extension
-          find hosts/vscode-plugin -name "*.vsix" -type f -exec cp {} release-artifacts/ \;
+          # Copy and rename VSCode standard extension
+          if [ -f hosts/vscode-plugin/opencode-standard.vsix ]; then
+            cp hosts/vscode-plugin/opencode-standard.vsix \
+              "release-artifacts/opencode-vscode-${{ steps.version.outputs.version }}.vsix"
+          fi
+
+          # Copy and rename VSCode gui-only extension
+          if [ -f hosts/vscode-plugin/opencode-gui-only.vsix ]; then
+            cp hosts/vscode-plugin/opencode-gui-only.vsix \
+              "release-artifacts/opencode-vscode-gui-only-${{ steps.version.outputs.version }}.vsix"
+          fi
 
           # Find and copy JetBrains plugin
           find hosts/jetbrains-plugin/build/distributions -name "*.zip" -type f -exec cp {} release-artifacts/ \;
-
-          # Rename artifacts with version
           cd release-artifacts
-          for file in *.vsix; do
-            if [ -f "$file" ]; then
-              mv "$file" "opencode-vscode-${{ steps.version.outputs.version }}.vsix"
-            fi
-          done
-
           for file in *.zip; do
             if [ -f "$file" ]; then
               mv "$file" "opencode-jetbrains-${{ steps.version.outputs.version }}.zip"
@@ -165,15 +233,21 @@ jobs:
             ## OpenCode Release ${{ steps.version.outputs.version }}
 
             This release includes:
-            - **VSCode Extension**: `opencode-vscode-${{ steps.version.outputs.version }}.vsix`
+            - **VSCode Extension (standard)**: `opencode-vscode-${{ steps.version.outputs.version }}.vsix` — bundles opencode binaries
+            - **VSCode Extension (gui-only)**: `opencode-vscode-gui-only-${{ steps.version.outputs.version }}.vsix` — lightweight, uses system `opencode` binary
             - **JetBrains Plugin**: `opencode-jetbrains-${{ steps.version.outputs.version }}.zip`
 
             ### Installation Instructions
 
-            #### VSCode Extension
+            #### VSCode Extension (standard — includes binaries)
             1. Download `opencode-vscode-${{ steps.version.outputs.version }}.vsix`
             2. Install using: `code --install-extension opencode-vscode-${{ steps.version.outputs.version }}.vsix`
 
+            #### VSCode Extension (gui-only — requires system opencode)
+            1. Ensure `opencode` is installed and available in your PATH
+            2. Download `opencode-vscode-gui-only-${{ steps.version.outputs.version }}.vsix`
+            3. Install using: `code --install-extension opencode-vscode-gui-only-${{ steps.version.outputs.version }}.vsix`
+
             #### JetBrains Plugin
             1. Download `opencode-jetbrains-${{ steps.version.outputs.version }}.zip`
             2. In your JetBrains IDE, go to Settings → Plugins → Install Plugin from Disk
@@ -200,11 +274,18 @@ jobs:
           echo "Checking release artifacts..."
           ls -la ./test-artifacts/ || echo "No artifacts found"
 
-          # Check if VSCode extension exists
-          if ls ./test-artifacts/*.vsix 1> /dev/null 2>&1; then
-            echo "✓ VSCode extension found"
+          # Check if VSCode standard extension exists
+          if ls ./test-artifacts/opencode-vscode-v*.vsix 1> /dev/null 2>&1; then
+            echo "✓ VSCode extension (standard) found"
+          else
+            echo "✗ VSCode extension (standard) missing"
+          fi
+
+          # Check if VSCode gui-only extension exists
+          if ls ./test-artifacts/opencode-vscode-gui-only-*.vsix 1> /dev/null 2>&1; then
+            echo "✓ VSCode extension (gui-only) found"
           else
-            echo "✗ VSCode extension missing"
+            echo "✗ VSCode extension (gui-only) missing"
           fi
 
           # Check if JetBrains plugin exists

+ 175 - 35
hosts/scripts/build_vscode.sh

@@ -1,7 +1,13 @@
 #!/bin/bash
 
 # Opencode VSCode Extension Build Script
-# This script handles the complete build process for the Opencode VSCode extension
+# This script handles the complete build process for the Opencode VSCode extension.
+# Supports building two variants:
+#   - Standard:  bundles opencode binaries (default)
+#   - GUI-only:  no binaries, uses system opencode, embeds webgui-dist
+#
+# By default both variants are built. Use --gui-only or --standard-only to
+# build a single variant.
 
 set -e
 
@@ -16,6 +22,8 @@ NC='\033[0m' # No Color
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
 PLUGIN_DIR="$ROOT_DIR/hosts/vscode-plugin"
+WEBGUI_DIR="$ROOT_DIR/packages/opencode/webgui"
+WEBGUI_DIST="$ROOT_DIR/packages/opencode/webgui-dist"
 
 echo -e "${BLUE}Opencode VSCode Extension Build Script${NC}"
 echo "Plugin directory: $PLUGIN_DIR"
@@ -23,13 +31,9 @@ echo "Root directory: $ROOT_DIR"
 
 # --- Package manager helpers ---
 PNPM_AVAILABLE=false
-RUN_PM="npm run"
-INSTALL_PM="npm ci || npm install"
 
 if command -v pnpm >/dev/null 2>&1; then
     PNPM_AVAILABLE=true
-    RUN_PM="pnpm run"
-    INSTALL_PM="pnpm install --frozen-lockfile"
 fi
 
 run_install() {
@@ -70,6 +74,8 @@ BUILD_TYPE="development"
 SKIP_BINARIES=false
 SKIP_TESTS=false
 PACKAGE_ONLY=false
+BUILD_STANDARD=true
+BUILD_GUI_ONLY=true
 
 while [[ $# -gt 0 ]]; do
     case $1 in
@@ -89,6 +95,16 @@ while [[ $# -gt 0 ]]; do
             PACKAGE_ONLY=true
             shift
             ;;
+        --gui-only)
+            BUILD_STANDARD=false
+            BUILD_GUI_ONLY=true
+            shift
+            ;;
+        --standard-only)
+            BUILD_STANDARD=true
+            BUILD_GUI_ONLY=false
+            shift
+            ;;
         --help)
             echo "Usage: $0 [OPTIONS]"
             echo "Options:"
@@ -96,6 +112,8 @@ while [[ $# -gt 0 ]]; do
             echo "  --skip-binaries   Skip building backend binaries"
             echo "  --skip-tests      Skip running tests"
             echo "  --package-only    Only create the .vsix package (skip compilation)"
+            echo "  --gui-only        Build only the gui-only variant (no binaries)"
+            echo "  --standard-only   Build only the standard variant (with binaries)"
             echo "  --help            Show this help message"
             exit 0
             ;;
@@ -107,9 +125,13 @@ while [[ $# -gt 0 ]]; do
 done
 
 print_status "Building VSCode extension in $BUILD_TYPE mode"
+[ "$BUILD_STANDARD" = true ] && print_status "  Variant: standard (with binaries)"
+[ "$BUILD_GUI_ONLY" = true ] && print_status "  Variant: gui-only (system opencode)"
 
 cd "$PLUGIN_DIR"
 
+# ─── Shared preparation (compile once, package per-variant) ───────────────
+
 if [ "$PACKAGE_ONLY" = false ]; then
     print_status "Cleaning previous build artifacts..."
     set +e
@@ -139,7 +161,7 @@ if [ "$PACKAGE_ONLY" = false ]; then
     run_install
 fi
 
-if [ "$SKIP_BINARIES" = false ] && [ "$PACKAGE_ONLY" = false ]; then
+if [ "$SKIP_BINARIES" = false ] && [ "$PACKAGE_ONLY" = false ] && [ "$BUILD_STANDARD" = true ]; then
     print_status "Building backend binaries..."
     "$SCRIPT_DIR/build_opencode.sh"
 fi
@@ -173,29 +195,8 @@ if [ "$SKIP_TESTS" = false ] && [ "$PACKAGE_ONLY" = false ]; then
     set -e
 fi
 
-print_status "Checking for required binaries..."
-BINARY_PATHS=(
-    "resources/bin/windows/amd64/opencode.exe"
-    "resources/bin/macos/amd64/opencode"
-    "resources/bin/macos/arm64/opencode"
-    "resources/bin/linux/amd64/opencode"
-    "resources/bin/linux/arm64/opencode"
-)
-
-MISSING_BINARIES=false
-for binary_path in "${BINARY_PATHS[@]}"; do
-    if [ ! -f "$binary_path" ]; then
-        print_warning "Missing binary: $binary_path"
-        MISSING_BINARIES=true
-    fi
-done
-
-if [ "$MISSING_BINARIES" = true ]; then
-    print_warning "Some binaries are missing. The extension may not work on all platforms."
-    print_warning "Run '$SCRIPT_DIR/build_opencode.sh' from the root directory to build all binaries."
-fi
+# ─── Resolve vsce command ────────────────────────────────────────────────
 
-print_status "Creating VSCode extension package..."
 VSCE_CMD="vsce"
 if ! command -v vsce >/dev/null 2>&1; then
     if command -v npx >/dev/null 2>&1; then
@@ -206,21 +207,160 @@ if ! command -v vsce >/dev/null 2>&1; then
     fi
 fi
 
-if [ "$BUILD_TYPE" = "production" ]; then
-    eval "$VSCE_CMD package --no-dependencies --out 'opencode-vscode-$(date +%Y%m%d-%H%M%S).vsix'"
-else
-    eval "$VSCE_CMD package --pre-release --no-dependencies --out 'opencode-vscode-dev-$(date +%Y%m%d-%H%M%S).vsix'"
+TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
+
+# ─── Build helper: package a single variant ──────────────────────────────
+
+build_variant() {
+    local variant="$1"   # "standard" or "gui-only"
+
+    cd "$PLUGIN_DIR"
+
+    if [ "$variant" = "standard" ]; then
+        print_status "=== Packaging STANDARD variant ==="
+
+        # Check for required binaries
+        BINARY_PATHS=(
+            "resources/bin/windows/amd64/opencode.exe"
+            "resources/bin/macos/amd64/opencode"
+            "resources/bin/macos/arm64/opencode"
+            "resources/bin/linux/amd64/opencode"
+            "resources/bin/linux/arm64/opencode"
+        )
+
+        MISSING_BINARIES=false
+        for binary_path in "${BINARY_PATHS[@]}"; do
+            if [ ! -f "$binary_path" ]; then
+                print_warning "Missing binary: $binary_path"
+                MISSING_BINARIES=true
+            fi
+        done
+
+        if [ "$MISSING_BINARIES" = true ]; then
+            print_warning "Some binaries are missing. The extension may not work on all platforms."
+            print_warning "Run '$SCRIPT_DIR/build_opencode.sh' from the root directory to build all binaries."
+        fi
+
+        # Package with original package.json and .vscodeignore
+        if [ "$BUILD_TYPE" = "production" ]; then
+            eval "$VSCE_CMD package --no-dependencies --out 'opencode-vscode-${TIMESTAMP}.vsix'"
+        else
+            eval "$VSCE_CMD package --pre-release --no-dependencies --out 'opencode-vscode-dev-${TIMESTAMP}.vsix'"
+        fi
+
+    elif [ "$variant" = "gui-only" ]; then
+        print_status "=== Packaging GUI-ONLY variant ==="
+
+        # Build webgui if webgui-dist doesn't exist yet
+        if [ ! -d "$WEBGUI_DIST" ] || [ -z "$(ls -A "$WEBGUI_DIST" 2>/dev/null)" ]; then
+            print_status "Building webgui..."
+            (
+                cd "$WEBGUI_DIR"
+                run_install
+                if $PNPM_AVAILABLE; then
+                    pnpm run build
+                else
+                    npm run build
+                fi
+            )
+        fi
+
+        if [ ! -d "$WEBGUI_DIST" ]; then
+            print_error "webgui-dist not found at $WEBGUI_DIST after build"
+            return 1
+        fi
+
+        # Copy webgui-dist into plugin resources for embedding
+        print_status "Embedding webgui-dist into plugin resources..."
+        rm -rf "$PLUGIN_DIR/resources/webgui-app"
+        cp -r "$WEBGUI_DIST" "$PLUGIN_DIR/resources/webgui-app"
+
+        # Move binaries completely outside the plugin tree so vsce cannot bundle them
+        BIN_STASH="$(mktemp -d)"
+        if [ -d "$PLUGIN_DIR/resources/bin" ]; then
+            mv "$PLUGIN_DIR/resources/bin" "$BIN_STASH/bin"
+        fi
+
+        # Temporarily swap package.json with gui-only overrides and .vscodeignore
+        cp "$PLUGIN_DIR/package.json" "$PLUGIN_DIR/package.json.bak"
+        cp "$PLUGIN_DIR/.vscodeignore" "$PLUGIN_DIR/.vscodeignore.bak"
+
+        # Ensure originals are restored even on failure
+        gui_only_cleanup() {
+            cd "$PLUGIN_DIR"
+            [ -f package.json.bak ] && mv package.json.bak package.json
+            [ -f .vscodeignore.bak ] && mv .vscodeignore.bak .vscodeignore
+            [ -d "$BIN_STASH/bin" ] && mv "$BIN_STASH/bin" resources/bin
+            rm -rf "$BIN_STASH"
+            rm -rf resources/webgui-app
+        }
+        trap gui_only_cleanup EXIT
+
+        # Deep-merge gui-only overrides into package.json
+        node -e "
+            const fs = require('fs');
+            function deep(target, src) {
+                for (const key of Object.keys(src)) {
+                    const s = src[key];
+                    const t = target[key];
+                    if (Array.isArray(s) && Array.isArray(t)) {
+                        for (let i = 0; i < s.length; i++) {
+                            if (i < t.length && typeof s[i] === 'object' && typeof t[i] === 'object') {
+                                deep(t[i], s[i]);
+                            } else {
+                                t[i] = s[i];
+                            }
+                        }
+                    } else if (s && typeof s === 'object' && !Array.isArray(s) && t && typeof t === 'object' && !Array.isArray(t)) {
+                        deep(t, s);
+                    } else {
+                        target[key] = s;
+                    }
+                }
+                return target;
+            }
+            const base = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+            const overrides = JSON.parse(fs.readFileSync('package.gui-only.json', 'utf8'));
+            fs.writeFileSync('package.json', JSON.stringify(deep(base, overrides), null, 2) + '\n');
+        "
+
+        # Swap .vscodeignore
+        cp "$PLUGIN_DIR/.vscodeignore.gui-only" "$PLUGIN_DIR/.vscodeignore"
+
+        # Package gui-only variant
+        if [ "$BUILD_TYPE" = "production" ]; then
+            eval "$VSCE_CMD package --no-dependencies --out 'opencode-vscode-gui-only-${TIMESTAMP}.vsix'"
+        else
+            eval "$VSCE_CMD package --pre-release --no-dependencies --out 'opencode-vscode-gui-only-dev-${TIMESTAMP}.vsix'"
+        fi
+
+        # Restore originals (also handled by trap on failure)
+        gui_only_cleanup
+        trap - EXIT
+
+        print_status "GUI-only variant packaged successfully"
+    fi
+}
+
+# ─── Build requested variants ────────────────────────────────────────────
+
+if [ "$BUILD_STANDARD" = true ]; then
+    build_variant "standard"
+fi
+
+if [ "$BUILD_GUI_ONLY" = true ]; then
+    build_variant "gui-only"
 fi
 
 print_status "Build completed successfully!"
-print_status "Extension package created in: $PLUGIN_DIR"
+print_status "Extension packages created in: $PLUGIN_DIR"
 
 shopt -s nullglob
-VSIX_FILES=( *.vsix )
+VSIX_FILES=( "$PLUGIN_DIR"/*.vsix )
 shopt -u nullglob
 if ((${#VSIX_FILES[@]} > 0)); then
     echo "Packages created:"
     for vsix in "${VSIX_FILES[@]}"; do
-        echo "  $vsix"
+        echo "  $(basename "$vsix")"
     done
 fi

+ 5 - 0
hosts/vscode-plugin/.vscodeignore

@@ -50,3 +50,8 @@ npm-debug.log*
 
 # Include resources (binaries)
 !resources/**
+
+# Exclude gui-only build artifacts and config
+resources/webgui-app/**
+package.gui-only.json
+.vscodeignore.gui-only

+ 57 - 0
hosts/vscode-plugin/.vscodeignore.gui-only

@@ -0,0 +1,57 @@
+# Development files
+.vscode/**
+.vscode-test/**
+src/**
+.gitignore
+.yarnrc
+vsc-extension-quickstart.md
+**/tsconfig.json
+**/.eslintrc.json
+**/*.map
+**/*.ts
+
+# Dependencies (exclude node_modules but keep compiled output)
+node_modules/**
+
+# Test files and fixtures
+test-fixtures/**
+scripts/test.sh
+
+# Build artifacts to exclude
+*.vsix
+
+# Documentation
+
+ERROR_HANDLING.md
+TESTING.md
+
+# Lock files (keep package.json but exclude lock files)
+pnpm-lock.yaml
+yarn.lock
+package-lock.json
+
+# IDE specific files
+.idea/**
+*.swp
+*.swo
+*~
+
+# OS specific files
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+npm-debug.log*
+
+# Include compiled JavaScript output
+!out/**/*.js
+!out/**/*.js.map
+
+# Include resources BUT exclude binaries (gui-only mode)
+!resources/**
+resources/bin/**
+
+# Gui-only build config files (not needed in VSIX)
+package.gui-only.json
+.vscodeignore.gui-only

+ 21 - 0
hosts/vscode-plugin/package.gui-only.json

@@ -0,0 +1,21 @@
+{
+  "name": "opencode-ux-plus-gui-only",
+  "displayName": "OpenCode UX+ GUI Only (unofficial)",
+  "description": "Unofficial OpenCode VSCode extension - GUI only, requires system opencode binary",
+  "contributes": {
+    "viewsContainers": {
+      "activitybar": [
+        {
+          "title": "OpenCode UX+ gui only"
+        }
+      ]
+    },
+    "views": {
+      "opencode": [
+        {
+          "name": "OpenCode UX+ gui only"
+        }
+      ]
+    }
+  }
+}

+ 31 - 6
hosts/vscode-plugin/src/backend/BackendLauncher.ts

@@ -18,6 +18,11 @@ export interface BackendConnection {
 export class BackendLauncher {
   private currentProcess?: ChildProcess
   private currentConnection?: Omit<BackendConnection, "process">
+  private extensionPath?: string
+
+  constructor(extensionPath?: string) {
+    this.extensionPath = extensionPath
+  }
 
   /**
    * Launch the opencode backend process
@@ -168,7 +173,8 @@ export class BackendLauncher {
   }
 
   /**
-   * Extract the appropriate binary for the current OS/architecture
+   * Extract the appropriate binary for the current OS/architecture.
+   * Tries bundled binary first, falls back to system PATH opencode.
    * @returns Promise resolving to the path of the extracted binary
    */
   private async extractBinary(): Promise<string> {
@@ -179,13 +185,32 @@ export class BackendLauncher {
       return override.trim()
     }
 
-    // Get extension path
-    const extension = vscode.extensions.getExtension("paviko.opencode-ux-plus")
-    if (!extension) {
-      throw new Error("Extension not found")
+    // Resolve extension path dynamically (works for any extension ID)
+    const extPath = this.extensionPath
+      || vscode.extensions.getExtension("paviko.opencode-ux-plus")?.extensionPath
+      || vscode.extensions.getExtension("paviko.opencode-ux-plus-gui-only")?.extensionPath
+
+    // Try bundled binary first
+    if (extPath) {
+      try {
+        return await ResourceExtractor.extractBinary(extPath)
+      } catch {
+        logger.appendLine("Bundled binary not found, falling back to system PATH")
+      }
     }
 
-    return ResourceExtractor.extractBinary(extension.extensionPath)
+    // Fall back to system opencode binary
+    return this.resolveSystemBinary()
+  }
+
+  /**
+   * Resolve opencode binary from system PATH
+   * @returns The binary name to be resolved via PATH
+   */
+  private resolveSystemBinary(): string {
+    const name = process.platform === "win32" ? "opencode.exe" : "opencode"
+    logger.appendLine(`Using system binary: ${name}`)
+    return name
   }
 
   /**

+ 2 - 2
hosts/vscode-plugin/src/extension.ts

@@ -83,8 +83,8 @@ class OpenCodeExtension {
     const settingsDisposable = this.settingsManager.initialize()
     this.context?.subscriptions.push(settingsDisposable)
 
-    // Initialize backend launcher
-    this.backendLauncher = new BackendLauncher()
+    // Initialize backend launcher (pass extension path for binary resolution)
+    this.backendLauncher = new BackendLauncher(this.context!.extensionUri.fsPath)
 
     // Initialize webview manager
     this.webviewManager = new WebviewManager()

+ 164 - 0
hosts/vscode-plugin/src/ui/WebguiStaticServer.ts

@@ -0,0 +1,164 @@
+import * as http from "http";
+import * as fs from "fs";
+import * as path from "path";
+import { logger } from "../globals";
+
+const MIME: Record<string, string> = {
+  ".html": "text/html; charset=utf-8",
+  ".js": "application/javascript; charset=utf-8",
+  ".mjs": "application/javascript; charset=utf-8",
+  ".css": "text/css; charset=utf-8",
+  ".json": "application/json; charset=utf-8",
+  ".svg": "image/svg+xml",
+  ".png": "image/png",
+  ".jpg": "image/jpeg",
+  ".jpeg": "image/jpeg",
+  ".gif": "image/gif",
+  ".ico": "image/x-icon",
+  ".woff": "font/woff",
+  ".woff2": "font/woff2",
+  ".ttf": "font/ttf",
+  ".wasm": "application/wasm",
+  ".map": "application/json",
+};
+
+/**
+ * Lightweight HTTP server that serves the embedded webgui-dist files
+ * under the `/app/` prefix and injects `window.__OPENCODE_SERVER_URL__`
+ * into index.html so the webgui can reach the opencode REST API.
+ */
+class WebguiStaticServer {
+  private server: http.Server | null = null;
+  private port = 0;
+  private rootDir = "";
+  private serverUrl = "";
+
+  async start(rootDir: string, opencodeServerUrl: string): Promise<string> {
+    if (this.server) {
+      return `http://127.0.0.1:${this.port}`;
+    }
+
+    this.rootDir = rootDir;
+    this.serverUrl = opencodeServerUrl.replace(/\/$/, "");
+
+    return new Promise((resolve, reject) => {
+      this.server = http.createServer((req, res) => this.handle(req, res));
+      this.server.listen(0, "127.0.0.1", () => {
+        const addr = this.server!.address();
+        if (addr && typeof addr !== "string") {
+          this.port = addr.port;
+          const base = `http://127.0.0.1:${this.port}`;
+          logger.appendLine(`WebguiStaticServer started on ${base} serving ${rootDir}`);
+          resolve(base);
+        } else {
+          reject(new Error("Failed to get server port"));
+        }
+      });
+      this.server.on("error", (e) => {
+        logger.appendLine(`WebguiStaticServer error: ${e}`);
+        reject(e);
+      });
+    });
+  }
+
+  stop(): void {
+    this.server?.close();
+    this.server = null;
+    this.port = 0;
+  }
+
+  getBaseUrl(): string {
+    if (!this.server) {
+      return "";
+    }
+    return `http://127.0.0.1:${this.port}`;
+  }
+
+  private handle(req: http.IncomingMessage, res: http.ServerResponse): void {
+    res.setHeader("Access-Control-Allow-Origin", "*");
+    res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
+    res.setHeader("Access-Control-Allow-Headers", "Content-Type");
+
+    if (req.method === "OPTIONS") {
+      res.writeHead(204);
+      res.end();
+      return;
+    }
+
+    const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`);
+    let pathname = decodeURIComponent(url.pathname);
+
+    // Must start with /app/
+    if (!pathname.startsWith("/app")) {
+      // Redirect root to /app/
+      if (pathname === "/") {
+        res.writeHead(302, { Location: "/app/" });
+        res.end();
+        return;
+      }
+      res.writeHead(404);
+      res.end("Not Found");
+      return;
+    }
+
+    // Strip /app prefix to get the relative file path within webgui-dist
+    let relative = pathname.slice("/app".length);
+    if (!relative || relative === "/") {
+      relative = "/index.html";
+    }
+
+    const file = path.join(this.rootDir, relative);
+
+    // Prevent directory traversal
+    if (!file.startsWith(this.rootDir)) {
+      res.writeHead(403);
+      res.end("Forbidden");
+      return;
+    }
+
+    // Check if file exists
+    if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
+      // SPA fallback: serve index.html for non-asset paths
+      const index = path.join(this.rootDir, "index.html");
+      if (fs.existsSync(index)) {
+        this.serveFile(res, index, true);
+        return;
+      }
+      res.writeHead(404);
+      res.end("Not Found");
+      return;
+    }
+
+    this.serveFile(res, file, path.basename(file) === "index.html");
+  }
+
+  private serveFile(res: http.ServerResponse, file: string, inject: boolean): void {
+    const ext = path.extname(file).toLowerCase();
+    const mime = MIME[ext] || "application/octet-stream";
+
+    if (inject && ext === ".html") {
+      // Read, inject global, serve
+      let html = fs.readFileSync(file, "utf-8");
+      const script = `<script>window.__OPENCODE_SERVER_URL__=${JSON.stringify(this.serverUrl)};</script>`;
+      // Insert before the first <script tag so the global is available when app code runs
+      const idx = html.indexOf("<script");
+      if (idx !== -1) {
+        html = html.slice(0, idx) + script + "\n    " + html.slice(idx);
+      } else {
+        html = html.replace("</head>", `${script}\n</head>`);
+      }
+      res.writeHead(200, { "Content-Type": mime, "Cache-Control": "no-cache" });
+      res.end(html);
+      return;
+    }
+
+    // Stream the file
+    res.writeHead(200, {
+      "Content-Type": mime,
+      "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
+    });
+    fs.createReadStream(file).pipe(res);
+  }
+}
+
+export const webguiServer = new WebguiStaticServer();

+ 35 - 3
hosts/vscode-plugin/src/ui/WebviewController.ts

@@ -1,4 +1,6 @@
 import * as vscode from "vscode"
+import * as fs from "fs"
+import * as path from "path"
 import { BackendConnection } from "../backend/BackendLauncher"
 import { SettingsManager } from "../settings/SettingsManager"
 import { CommunicationBridge } from "./CommunicationBridge"
@@ -7,6 +9,7 @@ import { errorHandler } from "../utils/ErrorHandler"
 import { PathInserter } from "../utils/PathInserter"
 import { logger } from "../globals"
 import { bridgeServer } from "./IdeBridgeServer"
+import { webguiServer } from "./WebguiStaticServer"
 
 /**
  * Shared webview controller to manage common UI lifecycle and messaging
@@ -66,9 +69,9 @@ export class WebviewController {
       // Create bridge session with handlers from CommunicationBridge
       const session = await bridgeServer.createSession(
         {
-          openFile: (path) => this.communicationBridge!.handleOpenFile(path),
+          openFile: (p) => this.communicationBridge!.handleOpenFile(p),
           openUrl: (url) => this.communicationBridge!.handleOpenUrl(url),
-          reloadPath: (path) => this.communicationBridge!.handleReloadPath(path),
+          reloadPath: (p) => this.communicationBridge!.handleReloadPath(p),
           clipboardWrite: async (text) => {
             await vscode.env.clipboard.writeText(text)
           },
@@ -103,8 +106,11 @@ export class WebviewController {
         logger.appendLine(`FileMonitor init failed: ${e}`)
       }
 
+      // Determine UI source: embedded webgui (gui-only) or remote server (standard)
+      const uiBaseUrl = await this.resolveUiBaseUrl(connection)
+
       // Use asExternalUri for Remote-SSH compatibility
-      const externalUi = await vscode.env.asExternalUri(vscode.Uri.parse(connection.uiBase))
+      const externalUi = await vscode.env.asExternalUri(vscode.Uri.parse(uiBaseUrl))
       const externalBridge = await vscode.env.asExternalUri(vscode.Uri.parse(session.baseUrl))
 
       // Build iframe src with bridge params
@@ -227,6 +233,32 @@ export class WebviewController {
     }
   }
 
+  /**
+   * Resolve the base URL for the webgui iframe.
+   *
+   * gui-only mode: embedded webgui lives in resources/webgui-app/.  We start a
+   * local static HTTP server that serves those files under /app/ and injects
+   * `window.__OPENCODE_SERVER_URL__` so the webgui can reach the REST API on
+   * the opencode server.
+   *
+   * Standard mode: the opencode server serves both REST API and webgui, so we
+   * use connection.uiBase directly.
+   */
+  private async resolveUiBaseUrl(connection: BackendConnection): Promise<string> {
+    const webguiDir = path.join(this.context.extensionUri.fsPath, "resources", "webgui-app")
+    if (fs.existsSync(path.join(webguiDir, "index.html"))) {
+      // gui-only: derive REST API root from uiBase using URL parsing
+      // (uiBase may carry query params like ?v=26.2.8 from cache-busting)
+      const parsed = new URL(connection.uiBase)
+      const serverRoot = parsed.origin
+      logger.appendLine(`gui-only mode: serving embedded webgui, REST API at ${serverRoot}`)
+      const base = await webguiServer.start(webguiDir, serverRoot)
+      return `${base}/app`
+    }
+    // Standard mode: opencode server serves the webgui
+    return connection.uiBase
+  }
+
   private buildUiUrlWithMode(base: string): string {
     let uiMode = "Terminal"
     try {

+ 2 - 1
packages/opencode/webgui/src/lib/api/events.ts

@@ -1,5 +1,6 @@
 import { useEffect, useRef, useCallback, useState } from "react"
 import type { FileDiff } from "@opencode-ai/sdk/client"
+import { serverBase } from "./sdkClient"
 
 // Event type definitions based on server Bus events
 export type ServerEvent =
@@ -147,7 +148,7 @@ export interface EventStreamOptions {
  * @returns Object with connection state, event emitter, and control functions
  */
 export function useEventStream(options: EventStreamOptions = {}) {
-  const { url = "/event", onConnectionStateChange, debug = false } = options
+  const { url = `${serverBase}/event`, onConnectionStateChange, debug = false } = options
 
   const [connectionState, setConnectionState] = useState<ConnectionState>("connecting")
   const emitterRef = useRef<EventEmitter>(new EventEmitter({ debug }))

+ 26 - 21
packages/opencode/webgui/src/lib/api/sdkClient.ts

@@ -1,13 +1,18 @@
 /**
  * OpenCode SDK client instance
- * Configured to connect to the OpenCode server at the default location
+ * Configured to connect to the OpenCode server at the default location.
+ *
+ * When `window.__OPENCODE_SERVER_URL__` is set (e.g. injected by the VS Code
+ * gui-only plugin), all API requests target that absolute URL.  When absent,
+ * relative URLs are used — identical to the original behaviour.
  */
 
 import { createOpencodeClient, type Provider } from "@opencode-ai/sdk/client"
 
-// Create a single SDK client instance with relative baseUrl
-// The server runs on the same origin, so we use '/' for relative requests
-const baseClient = createOpencodeClient({ baseUrl: "/" })
+export const serverBase: string =
+  ((globalThis as any).__OPENCODE_SERVER_URL__ as string | undefined)?.replace(/\/$/, "") || ""
+
+const baseClient = createOpencodeClient({ baseUrl: serverBase || "/" })
 
 interface ProvidersResponse {
   providers: Provider[]
@@ -41,7 +46,7 @@ export const sdk = {
   session: Object.assign(baseClient.session, {
     retry: async (options: { path: { sessionID: string } }) => {
       try {
-        const response = await fetch(`/app/api/session/${options.path.sessionID}/retry`, {
+        const response = await fetch(`${serverBase}/app/api/session/${options.path.sessionID}/retry`, {
           method: "POST",
           headers: { "Content-Type": "application/json" },
         })
@@ -68,7 +73,7 @@ export const sdk = {
     providers: baseClient.config.providers.bind(baseClient.config),
     allProviders: async () => {
       try {
-        const response = await fetch("/app/api/config/providers", {
+        const response = await fetch(`${serverBase}/app/api/config/providers`, {
           method: "GET",
           headers: { "Content-Type": "application/json" },
         })
@@ -90,7 +95,7 @@ export const sdk = {
   path: {
     get: async () => {
       try {
-        const response = await fetch("/path", {
+        const response = await fetch(`${serverBase}/path`, {
           method: "GET",
           headers: { "Content-Type": "application/json" },
         })
@@ -114,7 +119,7 @@ export const sdk = {
   },
   auth: {
     set: async (provider: string, value: any) => {
-      const res = await fetch("/app/api/auth/set", {
+      const res = await fetch(`${serverBase}/app/api/auth/set`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ provider, value }),
@@ -122,18 +127,18 @@ export const sdk = {
       if (!res.ok) throw new Error(await res.text())
     },
     list: async () => {
-      const res = await fetch("/app/api/auth/list")
+      const res = await fetch(`${serverBase}/app/api/auth/list`)
       return res.json() as Promise<Record<string, any>>
     },
     remove: async (provider: string) => {
-      await fetch("/app/api/auth/remove", {
+      await fetch(`${serverBase}/app/api/auth/remove`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ provider }),
       })
     },
     methods: async (provider: string) => {
-      const res = await fetch(`/app/api/auth/methods?provider=${provider}`)
+      const res = await fetch(`${serverBase}/app/api/auth/methods?provider=${provider}`)
       return res.json() as Promise<
         Array<{
           label: string
@@ -143,7 +148,7 @@ export const sdk = {
       >
     },
     start: async (provider: string, methodIndex: number, inputs: any) => {
-      const res = await fetch("/app/api/auth/login/start", {
+      const res = await fetch(`${serverBase}/app/api/auth/login/start`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ provider, methodIndex, inputs }),
@@ -152,7 +157,7 @@ export const sdk = {
       return res.json() as Promise<{ id: string; url?: string; method: "auto" | "code"; instructions?: string }>
     },
     submit: async (id: string, code: string) => {
-      const res = await fetch("/app/api/auth/login/submit", {
+      const res = await fetch(`${serverBase}/app/api/auth/login/submit`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ id, code }),
@@ -161,7 +166,7 @@ export const sdk = {
       return res.json() as Promise<boolean>
     },
     status: async (id: string) => {
-      const res = await fetch(`/app/api/auth/login/status/${id}`)
+      const res = await fetch(`${serverBase}/app/api/auth/login/status/${id}`)
       return res.json() as Promise<{ status: "pending" | "success" | "failed"; result?: any }>
     },
   },
@@ -170,7 +175,7 @@ export const sdk = {
       path: { requestID: string }
       body: { reply: "once" | "always" | "reject"; message?: string }
     }) => {
-      const response = await fetch(`/permission/${options.path.requestID}/reply`, {
+      const response = await fetch(`${serverBase}/permission/${options.path.requestID}/reply`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify(options.body),
@@ -184,7 +189,7 @@ export const sdk = {
   },
   question: {
     reply: async (options: { requestID: string; answers: Array<Array<string>> }) => {
-      const response = await fetch(`/question/${options.requestID}/reply`, {
+      const response = await fetch(`${serverBase}/question/${options.requestID}/reply`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ answers: options.answers }),
@@ -196,7 +201,7 @@ export const sdk = {
       return { data, error: null }
     },
     reject: async (options: { requestID: string }) => {
-      const response = await fetch(`/question/${options.requestID}/reject`, {
+      const response = await fetch(`${serverBase}/question/${options.requestID}/reject`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
       })
@@ -210,7 +215,7 @@ export const sdk = {
   model: {
     get: async () => {
       try {
-        const response = await fetch("/app/api/model", {
+        const response = await fetch(`${serverBase}/app/api/model`, {
           method: "GET",
           headers: { "Content-Type": "application/json" },
         })
@@ -225,7 +230,7 @@ export const sdk = {
     },
     update: async (options: { body: Partial<ModelPreferences> }) => {
       try {
-        const response = await fetch("/app/api/model", {
+        const response = await fetch(`${serverBase}/app/api/model`, {
           method: "PATCH",
           headers: { "Content-Type": "application/json" },
           body: JSON.stringify(options.body),
@@ -243,7 +248,7 @@ export const sdk = {
   kv: {
     get: async () => {
       try {
-        const response = await fetch("/app/api/kv", {
+        const response = await fetch(`${serverBase}/app/api/kv`, {
           method: "GET",
           headers: { "Content-Type": "application/json" },
         })
@@ -258,7 +263,7 @@ export const sdk = {
     },
     update: async (options: { body: Record<string, any> }) => {
       try {
-        const response = await fetch("/app/api/kv", {
+        const response = await fetch(`${serverBase}/app/api/kv`, {
           method: "PATCH",
           headers: { "Content-Type": "application/json" },
           body: JSON.stringify(options.body),