Ver Fonte

#5 Add support for GUI-only variant of JetBrains plugin with embedded webgui

paviko há 3 dias atrás
pai
commit
a39dbb71a8

+ 41 - 13
.github/workflows/release.yml

@@ -191,13 +191,25 @@ jobs:
           # Clean up staging
           rm -rf "$STAGE_DIR"
 
-      - name: Build JetBrains plugin distribution
+      - name: Build JetBrains plugin distribution (standard)
         uses: gradle/gradle-build-action@v2
         with:
           gradle-version: 8.7
           arguments: buildPlugin
           build-root-directory: hosts/jetbrains-plugin
 
+      - name: Stash JetBrains standard artifact
+        run: |
+          mkdir -p /tmp/jetbrains-standard
+          find hosts/jetbrains-plugin/build/distributions -name "*.zip" -type f -exec cp {} /tmp/jetbrains-standard/ \;
+
+      - name: Build JetBrains plugin distribution (gui-only)
+        uses: gradle/gradle-build-action@v2
+        with:
+          gradle-version: 8.7
+          arguments: clean buildPlugin -PguiOnly=true -PwebguiDist=../../packages/opencode/webgui-dist
+          build-root-directory: hosts/jetbrains-plugin
+
       - name: Prepare release artifacts
         run: |
           mkdir -p release-artifacts
@@ -214,16 +226,18 @@ jobs:
               "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/ \;
-          cd release-artifacts
-          for file in *.zip; do
+          # Copy JetBrains standard plugin (stashed before gui-only build)
+          for file in /tmp/jetbrains-standard/*.zip; do
             if [ -f "$file" ]; then
-              mv "$file" "opencode-jetbrains-${{ steps.version.outputs.version }}.zip"
+              cp "$file" "release-artifacts/opencode-jetbrains-${{ steps.version.outputs.version }}.zip"
             fi
           done
 
-          ls -la
+          # Copy JetBrains gui-only plugin
+          find hosts/jetbrains-plugin/build/distributions -name "*gui-only*" -type f -exec cp {} \
+            "release-artifacts/opencode-jetbrains-gui-only-${{ steps.version.outputs.version }}.zip" \;
+
+          ls -la release-artifacts/
 
       - name: Create Release
         uses: softprops/action-gh-release@v1
@@ -240,7 +254,8 @@ jobs:
             This release includes:
             - **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`
+            - **JetBrains Plugin (standard)**: `opencode-jetbrains-${{ steps.version.outputs.version }}.zip` — bundles opencode binaries
+            - **JetBrains Plugin (gui-only)**: `opencode-jetbrains-gui-only-${{ steps.version.outputs.version }}.zip` — lightweight, uses system `opencode` binary
 
             ### Installation Instructions
 
@@ -253,11 +268,17 @@ jobs:
             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
+            #### JetBrains Plugin (standard — includes binaries)
             1. Download `opencode-jetbrains-${{ steps.version.outputs.version }}.zip`
             2. In your JetBrains IDE, go to Settings → Plugins → Install Plugin from Disk
             3. Select the downloaded zip file
 
+            #### JetBrains Plugin (gui-only — requires system opencode)
+            1. Ensure `opencode` is installed and available in your PATH
+            2. Download `opencode-jetbrains-gui-only-${{ steps.version.outputs.version }}.zip`
+            3. In your JetBrains IDE, go to Settings → Plugins → Install Plugin from Disk
+            4. Select the downloaded zip file
+
             ### What's Changed
             See the auto-generated release notes below for detailed changes.
 
@@ -293,9 +314,16 @@ jobs:
             echo "✗ VSCode extension (gui-only) missing"
           fi
 
-          # Check if JetBrains plugin exists
-          if ls ./test-artifacts/*.zip 1> /dev/null 2>&1; then
-            echo "✓ JetBrains plugin found"
+          # Check if JetBrains standard plugin exists
+          if ls ./test-artifacts/opencode-jetbrains-v*.zip 1> /dev/null 2>&1; then
+            echo "✓ JetBrains plugin (standard) found"
+          else
+            echo "✗ JetBrains plugin (standard) missing"
+          fi
+
+          # Check if JetBrains gui-only plugin exists
+          if ls ./test-artifacts/opencode-jetbrains-gui-only-*.zip 1> /dev/null 2>&1; then
+            echo "✓ JetBrains plugin (gui-only) found"
           else
-            echo "✗ JetBrains plugin missing"
+            echo "✗ JetBrains plugin (gui-only) missing"
           fi

+ 6 - 2
hosts/jetbrains-plugin/README.md

@@ -1,4 +1,4 @@
-# OpenCode JetBrains Plugin (unofficial)
+# OpenCode UX+ (unofficial) JetBrains Plugin
 
 Unofficial OpenCode JetBrain
 
@@ -8,4 +8,8 @@ Unofficial OpenCode JetBrain
 - Add selected line ranges to context via command/shortcut
 - Easier prompt editing in a dedicated text area
 
-This plugin bundles the OpenCode backend executable for supported platforms and runs it locally. The binaries are stored under `src/main/resources/bin` inside the plugin and are used to provide the chat and analysis features.
+## GUI only variant
+**OpenCode UX+ GUI only (unofficial)** plugin does not bundle the OpenCode backend executable and **requires it to be installed on the system**.
+
+## Standard variant
+**OpenCode UX+ (unofficial)** plugin bundles the OpenCode backend executable for supported platforms and runs it locally. The binaries are stored under `src/main/resources/bin` inside the plugin and are used to provide the chat and analysis features.

+ 47 - 0
hosts/jetbrains-plugin/build.gradle.kts

@@ -7,6 +7,9 @@ plugins {
 group = "paviko.opencode"
 version = "26.2.8"
 
+val guiOnly = project.findProperty("guiOnly")?.toString()?.toBoolean() ?: false
+val webguiDist = project.findProperty("webguiDist")?.toString()
+
 repositories {
     mavenCentral()
     intellijPlatform {
@@ -111,12 +114,49 @@ tasks {
         filesMatching("opencode-build.properties") {
             expand("opencodeMinVersion" to minVersion)
         }
+
+        if (guiOnly) {
+            // Exclude bundled binaries for gui-only variant
+            exclude("bin/**")
+        }
+    }
+
+    // Copy webgui-dist into resources and generate file-list.txt for gui-only variant
+    if (guiOnly) {
+        val copyWebgui = register<Copy>("copyWebguiDist") {
+            val srcDir = if (webguiDist != null) file(webguiDist!!) else rootProject.rootDir.resolve("packages/opencode/webgui-dist")
+            from(srcDir)
+            into(layout.buildDirectory.dir("resources/main/webgui-app"))
+        }
+
+        val generateFileList = register("generateWebguiFileList") {
+            dependsOn(copyWebgui)
+            doLast {
+                val webguiDir = layout.buildDirectory.dir("resources/main/webgui-app").get().asFile
+                val files = webguiDir.walkTopDown()
+                    .filter { it.isFile && it.name != "file-list.txt" }
+                    .map { it.relativeTo(webguiDir).path }
+                    .sorted()
+                    .toList()
+                File(webguiDir, "file-list.txt").writeText(files.joinToString("\n") + "\n")
+                logger.lifecycle("Generated webgui-app/file-list.txt with ${files.size} entries")
+            }
+        }
+
+        named("processResources") {
+            dependsOn(generateFileList)
+        }
     }
 
     // Ensure no upper build bound is set in plugin.xml so the plugin stays compatible with newer IDEs
     patchPluginXml {
         // keep sinceBuild from pluginConfiguration, but expand upper bound to newer IDE builds
         untilBuild.set("261.*")
+
+        if (guiOnly) {
+            pluginId.set("paviko.opencode-ux-plus-gui-only")
+            pluginName.set("OpenCode UX+ GUI Only (unofficial)")
+        }
     }
 
     prepareSandbox {
@@ -125,6 +165,13 @@ tasks {
         }
     }
 
+    // Rename output archive for gui-only variant
+    if (guiOnly) {
+        named<Zip>("buildPlugin") {
+            archiveBaseName.set("opencode-plugin-gui-only")
+        }
+    }
+
     
     // Configure test task for IntelliJ integration tests
     test {

+ 2 - 1
hosts/jetbrains-plugin/description.html

@@ -9,7 +9,8 @@
 </ul>
 
 <p>
-  This plugin bundles the OpenCode backend executable for supported platforms and runs it locally. The binaries are
+  <b>OpenCode UX+ GUI only (unofficial)</b> plugin does not bundle the OpenCode backend executable and <b>requires it to be installed on the system</b>.
+  <b>OpenCode UX+ (unofficial)</b> plugin bundles the OpenCode backend executable for supported platforms and runs it locally. The binaries are
   stored under <code>src/main/resources/bin</code>
   inside the plugin and are used to provide the chat and analysis features.
 </p>

+ 37 - 2
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/ChatToolWindowFactory.kt

@@ -18,6 +18,7 @@ import paviko.opencode.backendprocess.BackendLauncher
 import java.awt.BorderLayout
 import java.awt.Font
 import java.io.BufferedReader
+import java.io.File
 import java.io.InputStreamReader
 import java.net.URI
 import java.net.URLEncoder
@@ -182,6 +183,18 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
                                     timeoutFuture.cancel(false)
                                     logger.info("Backend connection established at $appUrl")
 
+                                    // Detect gui-only mode: check if webgui-app is bundled as a resource
+                                    val isGuiOnly = javaClass.classLoader.getResource("webgui-app/index.html") != null
+                                    val uiBaseUrl = if (isGuiOnly) {
+                                        val webguiDir = extractWebguiResources()
+                                        val serverRoot = serverUri.let { "${it.scheme}://${it.host}:${it.port}" }
+                                        logger.info("gui-only mode: serving embedded webgui, REST API at $serverRoot")
+                                        val base = WebguiStaticServer.start(webguiDir, serverRoot)
+                                        "$base/app"
+                                    } else {
+                                        appUrl
+                                    }
+
                                     SwingUtilities.invokeLater {
                                         try {
                                             val client = JBCefApp.getInstance().createClient()
@@ -204,8 +217,8 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
                                             mainPanel.repaint()
 
                                             // Create bridge session and build URL with bridge params
-                                            val session = IdeBridge.createSession(project)
-                                            val baseUrl = withCacheBuster(appUrl, pluginVersion())
+                                            val session = IdeBridge.createSession(project, isGuiOnly)
+                                            val baseUrl = withCacheBuster(uiBaseUrl, pluginVersion())
                                             val urlWithBridge = buildString {
                                                 append(baseUrl)
                                                 append(if ('?' in baseUrl) '&' else '?')
@@ -255,5 +268,27 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
         }
     }
 
+    /**
+     * Extract bundled webgui-app resources from the JAR/classpath to a temp directory.
+     * Uses webgui-app/file-list.txt (generated at build time) to enumerate files.
+     */
+    private fun extractWebguiResources(): String {
+        val dest = File(System.getProperty("java.io.tmpdir"), "opencode-webgui")
+        dest.deleteRecursively()
+        dest.mkdirs()
+
+        val listing = javaClass.classLoader.getResourceAsStream("webgui-app/file-list.txt")
+            ?.bufferedReader()?.readLines()?.filter { it.isNotBlank() }
+            ?: throw RuntimeException("webgui-app/file-list.txt not found in classpath")
 
+        for (rel in listing) {
+            val input = javaClass.classLoader.getResourceAsStream("webgui-app/$rel") ?: continue
+            val target = File(dest, rel)
+            target.parentFile.mkdirs()
+            input.use { src -> target.outputStream().use { out -> src.copyTo(out) } }
+        }
+
+        logger.info("Extracted ${listing.size} webgui files to ${dest.absolutePath}")
+        return dest.absolutePath
+    }
 }

+ 4 - 2
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/IdeBridge.kt

@@ -26,6 +26,7 @@ data class Session(
     val id: String,
     val token: String,
     val project: Project,
+    val guiOnly: Boolean = false,
     val sseClients: MutableSet<HttpExchange> = Collections.synchronizedSet(mutableSetOf())
 )
 
@@ -77,7 +78,7 @@ object IdeBridge {
         try { executor.shutdownNow() } catch (_: Throwable) {}
     }
 
-    fun createSession(project: Project): SessionInfo {
+    fun createSession(project: Project, guiOnly: Boolean = false): SessionInfo {
         start() // ensure server is running
         
         // Remove any existing session for this project
@@ -87,7 +88,7 @@ object IdeBridge {
         
         val sessionId = UUID.randomUUID().toString()
         val token = UUID.randomUUID().toString()
-        sessions[sessionId] = Session(sessionId, token, project)
+        sessions[sessionId] = Session(sessionId, token, project, guiOnly)
         projectToSession[project] = sessionId
         
         // Start keepalive timer if not running
@@ -232,6 +233,7 @@ object IdeBridge {
         try {
             val data = JsonObject().apply {
                 addProperty("minVersion", minVersion)
+                if (session.guiOnly) addProperty("customApi", false)
             }
             val writer = OutputStreamWriter(exchange.responseBody)
             writer.write("event: connected\ndata: ${gson.toJson(data)}\n\n")

+ 163 - 0
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/WebguiStaticServer.kt

@@ -0,0 +1,163 @@
+package paviko.opencode.ui
+
+import com.intellij.openapi.diagnostic.Logger
+import com.sun.net.httpserver.HttpExchange
+import com.sun.net.httpserver.HttpServer
+import java.io.File
+import java.net.InetSocketAddress
+import java.net.URLDecoder
+import java.util.concurrent.Executors
+
+/**
+ * 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.
+ *
+ * Mirrors hosts/vscode-plugin/src/ui/WebguiStaticServer.ts
+ */
+object WebguiStaticServer {
+    private val LOG = Logger.getInstance(WebguiStaticServer::class.java)
+
+    private var server: HttpServer? = null
+    private var port = 0
+    private var rootDir = ""
+    private var serverUrl = ""
+
+    private val MIME = mapOf(
+        ".html" to "text/html; charset=utf-8",
+        ".js" to "application/javascript; charset=utf-8",
+        ".mjs" to "application/javascript; charset=utf-8",
+        ".css" to "text/css; charset=utf-8",
+        ".json" to "application/json; charset=utf-8",
+        ".svg" to "image/svg+xml",
+        ".png" to "image/png",
+        ".jpg" to "image/jpeg",
+        ".jpeg" to "image/jpeg",
+        ".gif" to "image/gif",
+        ".ico" to "image/x-icon",
+        ".woff" to "font/woff",
+        ".woff2" to "font/woff2",
+        ".ttf" to "font/ttf",
+        ".wasm" to "application/wasm",
+        ".map" to "application/json",
+    )
+
+    @Synchronized
+    fun start(root: String, opencodeServerUrl: String): String {
+        if (server != null) return "http://127.0.0.1:$port"
+
+        rootDir = root
+        serverUrl = opencodeServerUrl.trimEnd('/')
+
+        server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
+            executor = Executors.newCachedThreadPool()
+            createContext("/") { exchange -> handle(exchange) }
+            start()
+        }
+        port = server!!.address.port
+        val base = "http://127.0.0.1:$port"
+        LOG.info("WebguiStaticServer started on $base serving $root")
+        return base
+    }
+
+    @Synchronized
+    fun stop() {
+        server?.stop(0)
+        server = null
+        port = 0
+    }
+
+    private fun handle(exchange: HttpExchange) {
+        exchange.responseHeaders.apply {
+            add("Access-Control-Allow-Origin", "*")
+            add("Access-Control-Allow-Methods", "GET, OPTIONS")
+            add("Access-Control-Allow-Headers", "Content-Type")
+        }
+
+        if (exchange.requestMethod == "OPTIONS") {
+            exchange.sendResponseHeaders(204, -1)
+            exchange.close()
+            return
+        }
+
+        val pathname = try {
+            URLDecoder.decode(exchange.requestURI.path ?: "/", "UTF-8")
+        } catch (_: Throwable) {
+            exchange.requestURI.path ?: "/"
+        }
+
+        // Must start with /app
+        if (!pathname.startsWith("/app")) {
+            if (pathname == "/") {
+                exchange.responseHeaders.add("Location", "/app/")
+                exchange.sendResponseHeaders(302, -1)
+                exchange.close()
+                return
+            }
+            exchange.sendResponseHeaders(404, -1)
+            exchange.close()
+            return
+        }
+
+        // Strip /app prefix to get relative file path within webgui-dist
+        var relative = pathname.removePrefix("/app")
+        if (relative.isEmpty() || relative == "/") relative = "/index.html"
+
+        val file = File(rootDir, relative).canonicalFile
+
+        // Prevent directory traversal
+        if (!file.path.startsWith(File(rootDir).canonicalPath)) {
+            exchange.sendResponseHeaders(403, -1)
+            exchange.close()
+            return
+        }
+
+        // Check if file exists
+        if (!file.exists() || file.isDirectory) {
+            // SPA fallback: serve index.html for non-asset paths
+            val index = File(rootDir, "index.html")
+            if (index.exists()) {
+                serveFile(exchange, index, true)
+                return
+            }
+            exchange.sendResponseHeaders(404, -1)
+            exchange.close()
+            return
+        }
+
+        serveFile(exchange, file, file.name == "index.html")
+    }
+
+    private fun serveFile(exchange: HttpExchange, file: File, inject: Boolean) {
+        val ext = file.extension.let { if (it.isNotEmpty()) ".$it" else "" }.lowercase()
+        val mime = MIME[ext] ?: "application/octet-stream"
+
+        if (inject && ext == ".html") {
+            var html = file.readText(Charsets.UTF_8)
+            val escaped = serverUrl.replace("\"", "\\\"")
+            val script = "<script>window.__OPENCODE_SERVER_URL__=\"$escaped\";</script>"
+            val idx = html.indexOf("<script")
+            html = if (idx != -1) {
+                html.substring(0, idx) + script + "\n    " + html.substring(idx)
+            } else {
+                html.replace("</head>", "$script\n</head>")
+            }
+            val bytes = html.toByteArray(Charsets.UTF_8)
+            exchange.responseHeaders.apply {
+                add("Content-Type", mime)
+                add("Cache-Control", "no-cache")
+            }
+            exchange.sendResponseHeaders(200, bytes.size.toLong())
+            exchange.responseBody.use { it.write(bytes) }
+            return
+        }
+
+        val bytes = file.readBytes()
+        exchange.responseHeaders.apply {
+            add("Content-Type", mime)
+            add("Cache-Control", if (ext == ".html") "no-cache" else "public, max-age=31536000, immutable")
+        }
+        exchange.sendResponseHeaders(200, bytes.size.toLong())
+        exchange.responseBody.use { it.write(bytes) }
+    }
+}

+ 96 - 3
hosts/scripts/build_jetbrains.bat

@@ -1,11 +1,57 @@
 @echo off
 setlocal EnableExtensions EnableDelayedExpansion
 
+REM Opencode JetBrains Plugin Build Script
+REM Supports building two variants:
+REM   - Standard:  bundles opencode binaries (default)
+REM   - GUI-only:  no binaries, uses system opencode, embeds webgui-dist
+REM
+REM By default both variants are built. Use --gui-only or --standard-only to
+REM build a single variant.
+
 set "SCRIPT_DIR=%~dp0"
 set "ROOT_DIR=%SCRIPT_DIR%..\.."
 for %%I in ("%ROOT_DIR%") do set "ROOT_DIR=%%~fI"
 set "PLUGIN_DIR=%ROOT_DIR%\hosts\jetbrains-plugin"
 set "GRADLEW=%PLUGIN_DIR%\gradlew.bat"
+set "WEBGUI_DIR=%ROOT_DIR%\packages\opencode\webgui"
+set "WEBGUI_DIST=%ROOT_DIR%\packages\opencode\webgui-dist"
+
+set "BUILD_STANDARD=true"
+set "BUILD_GUI_ONLY=true"
+set "SKIP_BINARIES=false"
+
+:parse_args
+if "%~1"=="" goto args_done
+if "%~1"=="--gui-only" (
+  set "BUILD_STANDARD=false"
+  set "BUILD_GUI_ONLY=true"
+  shift
+  goto parse_args
+)
+if "%~1"=="--standard-only" (
+  set "BUILD_STANDARD=true"
+  set "BUILD_GUI_ONLY=false"
+  shift
+  goto parse_args
+)
+if "%~1"=="--skip-binaries" (
+  set "SKIP_BINARIES=true"
+  shift
+  goto parse_args
+)
+if "%~1"=="--help" (
+  echo Usage: %~nx0 [OPTIONS]
+  echo Options:
+  echo   --gui-only        Build only the gui-only variant (no binaries)
+  echo   --standard-only   Build only the standard variant (with binaries)
+  echo   --skip-binaries   Skip building backend binaries
+  echo   --help            Show this help message
+  exit /b 0
+)
+shift
+goto parse_args
+:args_done
 
 if not exist "%PLUGIN_DIR%" (
   echo [ERROR] JetBrains plugin directory not found at %PLUGIN_DIR%
@@ -19,7 +65,16 @@ if not exist "%GRADLEW%" (
 
 echo Opencode JetBrains Plugin Build Script
 echo Plugin directory: %PLUGIN_DIR%
+if "%BUILD_STANDARD%"=="true" echo   Variant: standard (with binaries)
+if "%BUILD_GUI_ONLY%"=="true" echo   Variant: gui-only (system opencode)
 
+REM ─── Standard variant ──────────────────────────────────────────────────
+
+if not "%BUILD_STANDARD%"=="true" goto skip_standard
+
+echo [INFO] Building standard variant
+
+if "%SKIP_BINARIES%"=="true" goto skip_std_binaries
 echo [INFO] Building opencode binaries
 pushd "%ROOT_DIR%" >nul
 call hosts\scripts\build_opencode.bat
@@ -29,16 +84,54 @@ if not "%BIN_STATUS%"=="0" (
   echo [ERROR] Failed to build opencode binaries
   exit /b %BIN_STATUS%
 )
+:skip_std_binaries
 
-echo [INFO] Building JetBrains plugin
 pushd "%PLUGIN_DIR%" >nul
-call "%GRADLEW%" buildPlugin %*
+call "%GRADLEW%" clean buildPlugin
 set "GRADLE_STATUS=%ERRORLEVEL%"
 popd >nul
 if not "%GRADLE_STATUS%"=="0" (
-  echo [ERROR] JetBrains plugin build failed
+  echo [ERROR] Standard JetBrains plugin build failed
   exit /b %GRADLE_STATUS%
 )
+echo [INFO] Standard variant built
+
+:skip_standard
+
+REM ─── GUI-only variant ──────────────────────────────────────────────────
+
+if not "%BUILD_GUI_ONLY%"=="true" goto skip_gui_only
+
+echo [INFO] Building gui-only variant
+
+if not exist "%WEBGUI_DIST%" (
+  echo [INFO] Building webgui...
+  pushd "%WEBGUI_DIR%" >nul
+  if exist "node_modules\.bin\vite" (
+    call npm run build
+  ) else (
+    call npm install
+    call npm run build
+  )
+  popd >nul
+)
+
+if not exist "%WEBGUI_DIST%" (
+  echo [ERROR] webgui-dist not found at %WEBGUI_DIST% after build
+  exit /b 1
+)
+
+pushd "%PLUGIN_DIR%" >nul
+call "%GRADLEW%" buildPlugin -PguiOnly=true "-PwebguiDist=%WEBGUI_DIST%"
+set "GRADLE_STATUS=%ERRORLEVEL%"
+popd >nul
+if not "%GRADLE_STATUS%"=="0" (
+  echo [ERROR] GUI-only JetBrains plugin build failed
+  exit /b %GRADLE_STATUS%
+)
+echo [INFO] GUI-only variant built
+
+:skip_gui_only
 
 echo [INFO] Build completed successfully
 exit /b 0

+ 100 - 5
hosts/scripts/build_jetbrains.sh

@@ -1,13 +1,62 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
+# Opencode JetBrains Plugin Build Script
+# 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.
+
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
 PLUGIN_DIR="$ROOT_DIR/hosts/jetbrains-plugin"
 GRADLEW="$PLUGIN_DIR/gradlew"
+WEBGUI_DIR="$ROOT_DIR/packages/opencode/webgui"
+WEBGUI_DIST="$ROOT_DIR/packages/opencode/webgui-dist"
+
+BUILD_STANDARD=true
+BUILD_GUI_ONLY=true
+SKIP_BINARIES=false
+EXTRA_ARGS=()
+
+while [[ $# -gt 0 ]]; do
+  case $1 in
+    --gui-only)
+      BUILD_STANDARD=false
+      BUILD_GUI_ONLY=true
+      shift
+      ;;
+    --standard-only)
+      BUILD_STANDARD=true
+      BUILD_GUI_ONLY=false
+      shift
+      ;;
+    --skip-binaries)
+      SKIP_BINARIES=true
+      shift
+      ;;
+    --help)
+      echo "Usage: $0 [OPTIONS]"
+      echo "Options:"
+      echo "  --gui-only        Build only the gui-only variant (no binaries)"
+      echo "  --standard-only   Build only the standard variant (with binaries)"
+      echo "  --skip-binaries   Skip building backend binaries"
+      echo "  --help            Show this help message"
+      exit 0
+      ;;
+    *)
+      EXTRA_ARGS+=("$1")
+      shift
+      ;;
+  esac
+done
 
 echo "Opencode JetBrains Plugin Build Script"
 echo "Plugin directory: $PLUGIN_DIR"
+[ "$BUILD_STANDARD" = true ] && echo "  Variant: standard (with binaries)"
+[ "$BUILD_GUI_ONLY" = true ] && echo "  Variant: gui-only (system opencode)"
 
 echo "=> Verifying JetBrains plugin workspace"
 if [ ! -d "$PLUGIN_DIR" ]; then
@@ -24,12 +73,58 @@ if [ ! -f "$GRADLEW" ]; then
   exit 1
 fi
 
-echo "=> Building opencode binaries"
-"$SCRIPT_DIR/build_opencode.sh"
+# ─── Standard variant ────────────────────────────────────────────────────
+
+if [ "$BUILD_STANDARD" = true ]; then
+  echo "=> Building standard variant"
+
+  if [ "$SKIP_BINARIES" = false ]; then
+    echo "=> Building opencode binaries"
+    "$SCRIPT_DIR/build_opencode.sh"
+  fi
+
+  cd "$PLUGIN_DIR"
+  "$GRADLEW" clean buildPlugin "${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}"
+  echo "=> Standard variant built"
+fi
 
-cd "$PLUGIN_DIR"
+# ─── GUI-only variant ────────────────────────────────────────────────────
 
-echo "=> Building JetBrains plugin"
-"$GRADLEW" buildPlugin "$@"
+if [ "$BUILD_GUI_ONLY" = true ]; then
+  echo "=> Building 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
+    echo "=> Building webgui..."
+    cd "$WEBGUI_DIR"
+    if command -v bun >/dev/null 2>&1; then
+      bun run build
+    elif command -v pnpm >/dev/null 2>&1; then
+      pnpm run build
+    else
+      npm run build
+    fi
+  fi
+
+  if [ ! -d "$WEBGUI_DIST" ]; then
+    echo "Error: webgui-dist not found at $WEBGUI_DIST after build" >&2
+    exit 1
+  fi
+
+  cd "$PLUGIN_DIR"
+  "$GRADLEW" buildPlugin -PguiOnly=true "-PwebguiDist=$WEBGUI_DIST" "${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}"
+  echo "=> GUI-only variant built"
+fi
 
 echo "=> Build completed"
+
+# List output artifacts
+shopt -s nullglob
+ARTIFACTS=( "$PLUGIN_DIR"/build/distributions/*.zip )
+shopt -u nullglob
+if ((${#ARTIFACTS[@]} > 0)); then
+  echo "Artifacts:"
+  for a in "${ARTIFACTS[@]}"; do
+    echo "  $(basename "$a")"
+  done
+fi

+ 5 - 1
hosts/vscode-plugin/README.md

@@ -8,6 +8,10 @@ Unofficial OpenCode VSCode plugin
 - Add selected line ranges to context via command/shortcut
 - Easier prompt editing in a dedicated text area
 
-This extension bundles the OpenCode backend executable for supported platforms and runs it locally. The binaries are stored under `resources/bin` inside the extension and are used to provide the chat and analysis features.
+## GUI only variant
+**OpenCode UX+ GUI only (unofficial)** plugin does not bundle the OpenCode backend executable and **requires it to be installed on the system**.
+
+## Standard variant
+**OpenCode UX+ (unofficial)** plugin bundles the OpenCode backend executable for supported platforms and runs it locally. The binaries are stored under `resources/bin` inside the extension and are used to provide the chat and analysis features.
 
 ![OpenCode VSCode extension screenshot](https://raw.githubusercontent.com/paviko/opencode-ide-plugin/ide-plugin/hosts/screenshot.png)