diff --git a/.github/last-synced-tag b/.github/last-synced-tag index 6540a286fdf..0fcdf660062 100644 --- a/.github/last-synced-tag +++ b/.github/last-synced-tag @@ -1 +1 @@ -v1.0.209 +v1.0.218 diff --git a/CONTEXT/dev-lan-access-issue-2025-12-30.md b/CONTEXT/dev-lan-access-issue-2025-12-30.md new file mode 100644 index 00000000000..d097ce7d0dc --- /dev/null +++ b/CONTEXT/dev-lan-access-issue-2025-12-30.md @@ -0,0 +1,170 @@ +# Dev Environment LAN Access Issue + +**Date:** 2025-12-30 +**Status:** Unresolved +**Affects:** Development environment only (not production) + +## Problem Summary + +When accessing the Vite dev server from a LAN IP address (e.g., `http://10.0.2.100:3000/`), the web app fails to connect to the backend opencode server, even though both servers are bound to `0.0.0.0`. + +## Environment + +- **Vite dev server:** `bun run dev` → listening on `0.0.0.0:3000` +- **OpenCode server:** `bun run dev serve --port 4096 --hostname 0.0.0.0 --print-logs` +- **Access method:** Browser on same machine or LAN device via IP address + +## Error Messages + +### Original (before attempted fix): +``` +Error: Could not connect to server. Is there a server running at `http://localhost:4096`? + at bootstrap (http://10.0.2.100:3000/src/context/global-sync.tsx:317:31) +``` + +### After attempted fix (using `location.hostname`): +``` +Error: Could not connect to server. Is there a server running at `http://10.0.2.100:4096`? + at bootstrap (http://10.0.2.100:3000/src/context/global-sync.tsx:317:31) +``` + +## Analysis + +### What's happening: + +1. When accessing via `http://10.0.2.100:3000/`, the browser correctly loads the Vite dev server +2. The app tries to connect to the OpenCode API server +3. With original code: tries `http://localhost:4096` (wrong host from browser's perspective on LAN) +4. With fix attempt: tries `http://10.0.2.100:4096` (correct host, but still fails) + +### Why the fix didn't work: + +The issue is **NOT** the URL resolution logic. The URL `http://10.0.2.100:4096` is correct. The problem is one of: + +1. **CORS (Cross-Origin Resource Sharing)** + - Browser origin: `http://10.0.2.100:3000` + - API request to: `http://10.0.2.100:4096` + - These are different origins (different ports) + - The OpenCode server may not be sending proper CORS headers for this origin + +2. **Vite Proxy Not Being Used** + - In dev mode, Vite is configured with a proxy to forward `/api/*` requests to the backend + - But if the app is constructing absolute URLs like `http://10.0.2.100:4096`, it bypasses the Vite proxy entirely + - The proxy only works for relative URLs or same-origin requests + +3. **Network/Firewall** + - Less likely since both servers are on same machine, but port 4096 could be blocked for non-localhost + +## Current Server URL Resolution Logic + +```typescript +const defaultServerUrl = iife(() => { + // 1. Query parameter (highest priority) + const param = new URLSearchParams(document.location.search).get("url") + if (param) return param + + // 2. Known production hosts -> localhost + if (location.hostname.includes("opencode.ai") || location.hostname.includes("shuv.ai")) + return "http://localhost:4096" + + // 3. Desktop app (Tauri) with injected port + if (window.__SHUVCODE__?.port) return `http://127.0.0.1:${window.__SHUVCODE__.port}` + if (window.__OPENCODE__?.port) return `http://127.0.0.1:${window.__OPENCODE__.port}` + + // 4. Dev mode -> explicit host:port from env + if (import.meta.env.DEV) + return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + + // 5. Default -> same origin (production web command) + return window.location.origin +}) +``` + +## Potential Solutions + +### Option 1: Use Vite Proxy in Dev Mode (Recommended) + +Instead of returning an absolute URL in dev mode, return a relative URL so requests go through Vite's proxy: + +```typescript +// 4. Dev mode -> use relative URL to go through Vite proxy +if (import.meta.env.DEV) return "/" +``` + +This requires the Vite proxy to be properly configured in `vite.config.ts` to forward API requests to `localhost:4096`. + +**Pros:** +- Works regardless of how you access the dev server (localhost, IP, hostname) +- No CORS issues since requests are same-origin +- Already partially configured in vite.config.ts + +**Cons:** +- Need to ensure all API routes are proxied +- Slightly different behavior than production + +### Option 2: Configure CORS on OpenCode Server + +Add CORS headers to the OpenCode server to allow requests from any origin in dev mode: + +```typescript +// In packages/opencode/src/server/server.ts +if (isDev) { + app.use('*', (c, next) => { + c.header('Access-Control-Allow-Origin', '*') + c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + c.header('Access-Control-Allow-Headers', '*') + return next() + }) +} +``` + +**Pros:** +- Allows direct access to API from any origin +- Useful for debugging API directly + +**Cons:** +- Security consideration (dev only) +- Need to modify server code + +### Option 3: Environment Variable Override + +Set `VITE_OPENCODE_SERVER_HOST=0.0.0.0` or the specific IP when starting dev server. + +**Pros:** +- Simple, no code changes +- Explicit control + +**Cons:** +- Manual configuration required +- Still has CORS issues + +### Option 4: Use Same-Origin Detection (Our Previous Implementation) + +Our previous (more complex) implementation had logic to detect when to use same-origin requests: + +```typescript +const isLoopback = ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) +const isWebCommand = !import.meta.env.DEV +const useSameOrigin = isSecure || isKnownHost || (isLoopback && !import.meta.env.DEV) || isWebCommand + +if (useSameOrigin) return "/" +``` + +This was more complex but handled the case of non-loopback access in dev mode. + +## Recommended Next Steps + +1. **Verify the Vite proxy configuration** in `packages/app/vite.config.ts` +2. **Test Option 1** - Return `/` in dev mode and ensure Vite proxy forwards correctly +3. **If proxy approach doesn't work**, investigate CORS headers on the OpenCode server + +## Files Involved + +- `packages/app/src/app.tsx` - Server URL resolution +- `packages/app/vite.config.ts` - Vite proxy configuration +- `packages/app/src/context/global-sync.tsx` - Where the connection error originates +- `packages/opencode/src/server/server.ts` - Backend server (if CORS fix needed) + +## Workaround + +For now, access the dev server via `http://localhost:3000/` instead of IP address. diff --git a/LOCAL_TAURI_PUBLISH.md b/LOCAL_TAURI_PUBLISH.md new file mode 100644 index 00000000000..b182fe4d61a --- /dev/null +++ b/LOCAL_TAURI_PUBLISH.md @@ -0,0 +1,74 @@ +# Local Tauri Publish (Shuvcode) + +This guide is local-only: build, sign, and publish the desktop app without GitHub Actions. + +## 1) Prerequisites + +- Bun and Rust installed (host triple in `rustc -vV`). +- Tauri CLI available via `bun run tauri` in `packages/desktop`. +- Linux-only bundling dependencies for AppImage: + - Install `fuse2` (or set `APPIMAGE_EXTRACT_AND_RUN=1` to avoid FUSE). + - Ensure `glibc`, `gtk3`, `webkit2gtk`, and related system libs are installed. + +## 2) Branding + updater config you must own + +- Set the updater public key to your Shuvcode key in `packages/desktop/src-tauri/tauri.prod.conf.json`. +- Confirm updater endpoint uses your repo: `https://github.com/Latitudes-Dev/shuvcode/releases/latest/download/latest.json`. +- Ensure bundle identifiers are correct: + - Dev: `dev.shuvcode.desktop.dev` + - Prod: `dev.shuvcode.desktop` + +## 3) Generate signing keys (one-time) + +Run locally: + +```bash +bun run --cwd packages/desktop tauri signer generate -w ./shuvcode-private.key +``` + +- The command prints a public key; copy that into `plugins.updater.pubkey` in `packages/desktop/src-tauri/tauri.prod.conf.json`. +- Store the private key securely. If you set a password, also store it. + +## 4) Local build workflow (per release) + +```bash +export RUST_TARGET=x86_64-unknown-linux-gnu +bun run --cwd packages/desktop predev +bun run --cwd packages/desktop build +TAURI_SIGNING_PRIVATE_KEY="$(cat ./shuvcode-private.key)" \ +TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" \ +bun run --cwd packages/desktop tauri build +``` + +Outputs appear in: + +- Bundles: `packages/desktop/src-tauri/target/release/bundle/` +- App binary: `packages/desktop/src-tauri/target/release/Shuvcode` + +## 5) Publish locally (no CI) + +You have two viable local publish paths: + +### Option A: GitHub Releases (local upload) + +- Create a release and upload bundle artifacts + `latest.json` (updater manifest). +- Use `gh release create --repo Latitudes-Dev/shuvcode` from your machine. + +### Option B: Self-hosted updater + +- Host the full contents of `bundle/` plus `latest.json` on your own server. +- Update `plugins.updater.endpoints` to your hosting URL. + +## 6) Known local issues + +- AppImage bundling failed locally with `failed to run linuxdeploy`. + - linuxdeploy’s embedded `strip` fails on `.relr.dyn` sections; try `NO_STRIP=1` or use a newer linuxdeploy build that understands RELR. + - Install `fuse2` (or set `APPIMAGE_EXTRACT_AND_RUN=1`) plus `squashfs-tools` and `patchelf`. + - Re-run `bun run --cwd packages/desktop tauri build` after adjusting linuxdeploy/strip. + +## 7) Validation checklist + +- Launch `Shuvcode` binary, verify UI loads. +- Confirm sidecar starts (CLI server is reachable on the injected port). +- Run in-app update check; ensure it hits your Shuvcode release endpoint. +- Verify installed bundle name and identifier for each OS. diff --git a/STATS.md b/STATS.md index 757a03db5e3..a5174e72e3b 100644 --- a/STATS.md +++ b/STATS.md @@ -185,3 +185,4 @@ | 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) | | 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) | | 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) | +| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) | diff --git a/TAURI_DESKTOP_FOLLOWUPS.md b/TAURI_DESKTOP_FOLLOWUPS.md new file mode 100644 index 00000000000..65ecd6b8799 --- /dev/null +++ b/TAURI_DESKTOP_FOLLOWUPS.md @@ -0,0 +1,10 @@ +# Shuvcode Desktop (Tauri) Follow-ups + +- Update the Tauri updater public key to the Shuvcode signing key in `packages/desktop/src-tauri/tauri.prod.conf.json`. +- Confirm the fork’s release workflow uploads `latest.json` and uses the Shuvcode repo endpoint for updater artifacts. +- Ensure the CI artifact name for the sidecar is `shuvcode-cli` (matches `packages/desktop/scripts/prepare.ts`). +- Verify all sidecar binaries exist for targets in `packages/desktop/scripts/utils.ts` (especially Linux arm64). +- Validate bundle naming in CI now that the product name is Shuvcode (script expects `Shuvcode*` in `packages/desktop/scripts/copy-bundles.ts`). +- Resolve AppImage bundling on Linux: linuxdeploy’s embedded `strip` fails on `.relr.dyn` sections; try `NO_STRIP=1` or a newer linuxdeploy build, or wrap linuxdeploy to use `/usr/bin/strip`. +- Check macOS/Windows signing identities and entitlements to match the new bundle identifiers (`dev.shuvcode.desktop`). +- Run a full desktop smoke test: `bun run predev` then `bun run tauri dev`, confirm sidecar launch + update flow. diff --git a/bun.lock b/bun.lock index 9ae748b0f39..6c566d0f754 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -71,7 +71,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -99,7 +99,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -126,7 +126,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -150,7 +150,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -173,14 +173,15 @@ }, }, "packages/desktop": { - "name": "@opencode-ai/desktop", - "version": "1.0.209-1", + "name": "@shuvcode/desktop", + "version": "1.0.218", "dependencies": { "@opencode-ai/app": "workspace:*", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-http": "~2", + "@tauri-apps/plugin-notification": "~2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "~2", "@tauri-apps/plugin-process": "~2", @@ -201,7 +202,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -230,7 +231,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -246,7 +247,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.209-1", + "version": "1.0.218", "bin": { "opencode": "./bin/opencode", }, @@ -256,18 +257,17 @@ "@agentclientprotocol/sdk": "0.5.1", "@ai-sdk/amazon-bedrock": "3.0.57", "@ai-sdk/anthropic": "2.0.56", - "@ai-sdk/azure": "2.0.73", + "@ai-sdk/azure": "2.0.82", "@ai-sdk/cerebras": "1.0.33", "@ai-sdk/cohere": "2.0.21", "@ai-sdk/deepinfra": "1.0.30", "@ai-sdk/gateway": "2.0.23", - "@ai-sdk/google": "2.0.44", + "@ai-sdk/google": "2.0.49", "@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/groq": "2.0.33", - "@ai-sdk/mcp": "0.0.8", "@ai-sdk/mistral": "2.0.26", "@ai-sdk/openai": "2.0.71", - "@ai-sdk/openai-compatible": "1.0.27", + "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/perplexity": "2.0.22", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", @@ -285,8 +285,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.2", - "@opentui/core": "0.1.63", - "@opentui/solid": "0.1.63", + "@opentui/core": "0.1.67", + "@opentui/solid": "0.1.67", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -349,7 +349,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -369,7 +369,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.209-1", + "version": "1.0.218", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -380,7 +380,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -393,7 +393,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -431,7 +431,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "zod": "catalog:", }, @@ -442,7 +442,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -544,7 +544,7 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="], - "@ai-sdk/azure": ["@ai-sdk/azure@2.0.73", "", { "dependencies": { "@ai-sdk/openai": "2.0.71", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LpAg3Ak/V3WOemBu35Qbx9jfQfApsHNXX9p3bXVsnRu3XXi1QQUt5gMOCIb4znPonz+XnHenIDZMBwdsb1TfRQ=="], + "@ai-sdk/azure": ["@ai-sdk/azure@2.0.82", "", { "dependencies": { "@ai-sdk/openai": "2.0.80", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Bpab51ETBB4adZC1xGMYsryL/CB8j1sA+t5aDqhRv3t3WRLTxhaBDcFKtQTIuxiEQTFosz9Q2xQqdfBvQm5jHw=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2gSSS/7kunIwMdC4td5oWsUAzoLw84ccGpz6wQbxVnrb1iWnrEnKa5tRBduaP6IXpzLWsu8wME3+dQhZy+gT7w=="], @@ -554,14 +554,12 @@ "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg=="], - "@ai-sdk/google": ["@ai-sdk/google@2.0.44", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-c5dck36FjqiVoeeMJQLTEmUheoURcGTU/nBT6iJu8/nZiKFT/y8pD85KMDRB7RerRYaaQOtslR2d6/5PditiRw=="], + "@ai-sdk/google": ["@ai-sdk/google@2.0.49", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-efwKk4mOV0SpumUaQskeYABk37FJPmEYwoDJQEjyLRmGSjtHRe9P5Cwof5ffLvaFav2IaJpBGEz98pyTs7oNWA=="], "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.81", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/google": "2.0.44", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "google-auth-library": "^9.15.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yrl5Ug0Mqwo9ya45oxczgy2RWgpEA/XQQCSFYP+3NZMQ4yA3Iim1vkOjVCsGaZZ8rjVk395abi1ZMZV0/6rqVA=="], "@ai-sdk/groq": ["@ai-sdk/groq@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FWGl7xNr88NBveao3y9EcVWYUt9ABPrwLFY7pIutSNgaTf32vgvyhREobaMrLU4Scr5G/2tlNqOPZ5wkYMaZig=="], - "@ai-sdk/mcp": ["@ai-sdk/mcp@0.0.8", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9y9GuGcZ9/+pMIHfpOCJgZVp+AZMv6TkjX2NVT17SQZvTF2N8LXuCXyoUPyi1PxIxzxl0n463LxxaB2O6olC+Q=="], - "@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.26", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jxDB++4WI1wEx5ONNBI+VbkmYJOYIuS8UQY13/83UGRaiW7oB/WHiH4ETe6KzbKpQPB3XruwTJQjUMsMfKyTXA=="], "@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="], @@ -1304,8 +1302,6 @@ "@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"], - "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], - "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], @@ -1330,21 +1326,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.63", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.63", "@opentui/core-darwin-x64": "0.1.63", "@opentui/core-linux-arm64": "0.1.63", "@opentui/core-linux-x64": "0.1.63", "@opentui/core-win32-arm64": "0.1.63", "@opentui/core-win32-x64": "0.1.63", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-m4xZQTNCnHXWUWCnGvacJ3Gts1H2aMwP5V/puAG77SDb51jm4W/QOyqAAdgeSakkb9II+8FfUpApX7sfwRXPUg=="], + "@opentui/core": ["@opentui/core@0.1.67", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.67", "@opentui/core-darwin-x64": "0.1.67", "@opentui/core-linux-arm64": "0.1.67", "@opentui/core-linux-x64": "0.1.67", "@opentui/core-win32-arm64": "0.1.67", "@opentui/core-win32-x64": "0.1.67", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-zmfyA10QUbzT6ohacPoHmGiYzuJrDSCfQWRWrKtao0BrHj9bii73qWy3V/eR4ibVueoRREwxJs5GlBOSvK6IoA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.63", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jKCThZGiiublKkP/hMtDtl1MLCw5NU0hMNJdEYvz1WLT9bzliWf6Kb7MIDAmk32XlbQW8/RHdp+hGyGDXK62OQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.67", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LtOcTlFD+kO7neItmkiF77H8cnjTYzBOZe8JQGwRSt9aaCke3UzMvLxmQnj4BP/kPC3hi9V6NRnFdptz0sJZIQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.63", "", { "os": "darwin", "cpu": "x64" }, "sha512-rfNxynHzJpxN9i+SAMnn1NToEc8rYj64BsOxY78JNsm4Gg1Js1uyMaawwh2WbdGknFy4cDXS9QwkUMdMcfnjiw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.67", "", { "os": "darwin", "cpu": "x64" }, "sha512-9i+awVWgpEVqZhFLaLq8usNGyCiyT5QxMLy6eH7JmRic79S34u23HfxiniGRtdYh3aqpm9SbLzo60v0nRIUkCA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.63", "", { "os": "linux", "cpu": "arm64" }, "sha512-wG9d6mHWWKZGrzxYS4c+BrcEGXBv/MYBUPSyjP/lD0CxT+X3h6CYhI317JkRyMNfh3vI9CpAKGFTOFvrTTHimQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.67", "", { "os": "linux", "cpu": "arm64" }, "sha512-WLjnTM3Ig//SRo0FUZYZJ5TITVbR6dKDVg6axU2D+sMoUzJMBP/Xo04q/TvZ3wP764Yca9l7oVMKWDxHlygyjQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.63", "", { "os": "linux", "cpu": "x64" }, "sha512-TKSzFv4BgWW3RB/iZmq5qxTR4/tRaXo8IZNnVR+LFzShbPOqhUi466AByy9SUmCxD8uYjmMDFYfKtkCy0AnAwA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.67", "", { "os": "linux", "cpu": "x64" }, "sha512-5UbZ/TqWi/DAmHIZL4NvhdpgTwglszRiddkRiQ8cT0IbnE4lutd4XxWUWcLKwsNT1YJv32TtcGWkuthluLiriQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.63", "", { "os": "win32", "cpu": "arm64" }, "sha512-CBWPyPognERP0Mq4eC1q01Ado2C2WU+BLTgMdhyt+E2P4w8rPhJ2kCt2MNxO66vQUiynspmZkgjQr0II/VjxWA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.67", "", { "os": "win32", "cpu": "arm64" }, "sha512-KNam5rObhN8/U9+GVVuvtAlGXp3MfdMHnw4W2P6YH7xp8HTsLvABUT91SJEyJ/ktVe9e1itLDG2fDHSoA5NbUg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.63", "", { "os": "win32", "cpu": "x64" }, "sha512-qEp6h//FrT+TQiiHm87wZWUwqTPTqIy1ZD+8R+VCUK+usoQiOAD2SqrYnM7W8JkCMGn5/TKm/GaKLyx/qlK4VA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.67", "", { "os": "win32", "cpu": "x64" }, "sha512-740lkOw42zLNh9YfahXjCwV2DS/amH2uMDh3tCADDCLckrMhemIhqArXDiMlalDxDqYspoaZCpBsFVsG9dMS6A=="], - "@opentui/solid": ["@opentui/solid@0.1.63", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.63", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-Gccln4qRucAoaoQEZ4NPAHvGmVYzU/8aKCLG8EPgwCKTcpUzlqYt4357cDHq4cnCNOcXOC06hTz/0pK9r0dqXA=="], + "@opentui/solid": ["@opentui/solid@0.1.67", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.67", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-dVNq0+PJIdNb63D0T7vcbyVF/ZvLCihGvivTU50zDOzd0Sk5prbrIfpG8+DjMErFubXfdZQvdy/PqFdtw0rjtQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -1618,6 +1614,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@shuvcode/desktop": ["@shuvcode/desktop@workspace:packages/desktop"], + "@sindresorhus/is": ["@sindresorhus/is@7.1.1", "", {}, "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ=="], "@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="], @@ -1854,6 +1852,8 @@ "@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg=="], + "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="], + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="], "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], @@ -4172,22 +4172,16 @@ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], - "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="], - - "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], + "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.80", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tNHuraF11db+8xJEDBoU9E3vMcpnHFKRhnLQ3DQX2LnEzfPB9DksZ8rE+yVuDN1WRW9cm2OWAhgHFgVKs7ICuw=="], "@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="], "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="], - "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="], - "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="], "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="], - "@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], - "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], @@ -4396,10 +4390,6 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], - - "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], - "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], @@ -4440,6 +4430,10 @@ "@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shuvcode/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], + + "@shuvcode/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "@slack/bolt/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], @@ -4610,7 +4604,7 @@ "opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="], - "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bpYruxVLhrTbVH6CCq48zMJNeHu6FmHtEedl9FXckEgcIEAi036idFhJlcRwC1jNCwlacbzb8dPD7OAH1EKJaQ=="], + "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="], "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], @@ -5022,8 +5016,6 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], - "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], @@ -5060,6 +5052,8 @@ "@rollup/plugin-replace/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "@shuvcode/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -5240,8 +5234,6 @@ "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], - "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], - "opencontrol/@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="], @@ -5392,7 +5384,7 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@shuvcode/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/flake.lock b/flake.lock index 81a005ec360..5dae5a88cfa 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1766996594, - "narHash": "sha256-SosfgQSqVmOkqVgNYJnxW5FvoIQX4grOcpIKNrIwz4o=", + "lastModified": 1767026758, + "narHash": "sha256-7fsac/f7nh/VaKJ/qm3I338+wAJa/3J57cOGpXi0Sbg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "0744ef1b047f07d31d9962d757ffe38ec14a4d41", + "rev": "346dd96ad74dc4457a9db9de4f4f57dab2e5731d", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index a6614a5dc9c..a578da9c1f7 100644 --- a/flake.nix +++ b/flake.nix @@ -17,7 +17,7 @@ "aarch64-darwin" "x86_64-darwin" ]; - lib = nixpkgs.lib; + inherit (nixpkgs) lib; forEachSystem = lib.genAttrs systems; pkgsFor = system: nixpkgs.legacyPackages.${system}; packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json); @@ -70,12 +70,12 @@ in { default = mkPackage { - version = packageJson.version; + inherit (packageJson) version; src = ./.; scripts = ./nix/scripts; target = bunTarget.${system}; modelsDev = "${modelsDev.${system}}/dist/_api.json"; - mkNodeModules = mkNodeModules; + inherit mkNodeModules; }; } ); diff --git a/nix/hashes.json b/nix/hashes.json index d801630a580..2f0a3df12bf 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-2i/QMBzp9MalOXur36mXaDDU8R9G/0dODCODEQOnaCU=" + "nodeModules": "sha256-2Wbnxy9SPcZkO03Sis3uiypPXa87jc5TzKbo6PvMlxY=" } diff --git a/nix/node-modules.nix b/nix/node-modules.nix index 7b22ef8e7da..be7edd9c7e7 100644 --- a/nix/node-modules.nix +++ b/nix/node-modules.nix @@ -1,18 +1,26 @@ -{ hash, lib, stdenvNoCC, bun, cacert, curl }: +{ + hash, + lib, + stdenvNoCC, + bun, + cacert, + curl, +}: args: stdenvNoCC.mkDerivation { pname = "opencode-node_modules"; - version = args.version; - src = args.src; + inherit (args) version src; - impureEnvVars = - lib.fetchers.proxyImpureEnvVars - ++ [ - "GIT_PROXY_COMMAND" - "SOCKS_SERVER" - ]; + impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [ + "GIT_PROXY_COMMAND" + "SOCKS_SERVER" + ]; - nativeBuildInputs = [ bun cacert curl ]; + nativeBuildInputs = [ + bun + cacert + curl + ]; dontConfigure = true; diff --git a/nix/opencode.nix b/nix/opencode.nix index 87b3f17ba99..4f10e749822 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -1,7 +1,13 @@ -{ lib, stdenvNoCC, bun, ripgrep, makeBinaryWrapper }: +{ + lib, + stdenvNoCC, + bun, + ripgrep, + makeBinaryWrapper, +}: args: let - scripts = args.scripts; + inherit (args) scripts; mkModules = attrs: args.mkNodeModules ( @@ -14,13 +20,10 @@ let in stdenvNoCC.mkDerivation (finalAttrs: { pname = "opencode"; - version = args.version; - - src = args.src; + inherit (args) version src; node_modules = mkModules { - version = finalAttrs.version; - src = finalAttrs.src; + inherit (finalAttrs) version src; }; nativeBuildInputs = [ diff --git a/packages/app/package.json b/packages/app/package.json index 5a58e895926..ff1033c4a66 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.0.209-1", + "version": "1.0.218", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d883cdd383b..5969be327ee 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,5 +1,5 @@ import "@/index.css" -import { ErrorBoundary, Show } from "solid-js" +import { ErrorBoundary, Show, type ParentProps } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" @@ -12,6 +12,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme" import { GlobalSyncProvider } from "@/context/global-sync" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" +import { ServerProvider, useServer } from "@/context/server" import { TerminalProvider } from "@/context/terminal" import { PromptProvider } from "@/context/prompt" import { NotificationProvider } from "@/context/notification" @@ -23,90 +24,43 @@ import DirectoryLayout from "@/pages/directory-layout" import Session from "@/pages/session" import { ErrorPage } from "./pages/error" import { iife } from "@opencode-ai/util/iife" -import { getStoredServerUrl, setStoredServerUrl, isValidServerUrl, addToServerUrlHistory } from "@/lib/server-url" declare global { interface Window { + __SHUVCODE__?: { updaterEnabled?: boolean; port?: number } __OPENCODE__?: { updaterEnabled?: boolean; port?: number } } } -// URL priority: -// 1. ?url= query parameter (explicit override) - persist if valid -// 2. Stored URL override from localStorage -// 3. Tauri injected port (desktop app with local server) -// 4. Same-origin mode uses relative "/" to hit the proxy -// 5. Other cases fall back to explicit host:port (dev mode) -const OPENCODE_THEME_STORAGE_KEYS = [ - "opencode-theme-id", - "opencode-color-scheme", - "opencode-theme-css-light", - "opencode-theme-css-dark", -] +const defaultServerUrl = iife(() => { + // 1. Query parameter (highest priority) + const param = new URLSearchParams(document.location.search).get("url") + if (param) return param -if (typeof window !== "undefined") { - for (const key of OPENCODE_THEME_STORAGE_KEYS) { - localStorage.removeItem(key) - } - document.getElementById("oc-theme")?.remove() - document.getElementById("oc-theme-preload")?.remove() - document.documentElement.removeAttribute("data-color-scheme") -} - -const url = iife(() => { - // 1. Query parameter (highest priority) - persist if valid - const queryUrl = new URLSearchParams(document.location.search).get("url") - if (queryUrl && isValidServerUrl(queryUrl)) { - setStoredServerUrl(queryUrl) - addToServerUrlHistory(queryUrl) - return queryUrl - } - - // 2. Stored URL override - const storedUrl = getStoredServerUrl() - if (storedUrl) return storedUrl - - // 3-5. Existing logic (preserved exactly) - const host = import.meta.env.VITE_OPENCODE_SERVER_HOST || location.hostname || "127.0.0.1" - const port = window.__OPENCODE__?.port ?? import.meta.env.VITE_OPENCODE_SERVER_PORT ?? location.port ?? "4096" - - // Check if we should use same-origin requests (relative "/" URL) - // This is needed when: - // - Running behind a reverse proxy (HTTPS) that proxies API requests - // - Running on known production hosts - // - Running the web command (API and frontend served from same server) - // In local dev mode with HTTP, we can hit the API server directly - const isSecure = location.protocol === "https:" - const isKnownHost = - location.hostname.includes("opencode.ai") || - location.hostname.includes("shuv.ai") || - location.hostname.endsWith(".local") - const isLoopback = ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) - // When accessed via non-loopback IP (e.g., LAN IP), we're still on the same server - // so we should use same-origin mode. Dev mode with Vite needs explicit host:port. - const isWebCommand = !import.meta.env.DEV + // 2. Known production hosts -> localhost (same as upstream + shuv.ai) + if (location.hostname.includes("opencode.ai") || location.hostname.includes("shuv.ai")) + return "http://localhost:4096" - // Use same-origin when: - // - On HTTPS (must use same-origin to avoid mixed content) - // - On known production hosts - // - On loopback in non-dev mode (production build) - // - On any host in non-dev mode (web command serves API and frontend together) - const useSameOrigin = isSecure || isKnownHost || (isLoopback && !import.meta.env.DEV) || isWebCommand + // 3. Desktop app (Tauri) with injected port + if (window.__SHUVCODE__?.port) return `http://127.0.0.1:${window.__SHUVCODE__.port}` + if (window.__OPENCODE__?.port) return `http://127.0.0.1:${window.__OPENCODE__.port}` - // 3. Tauri desktop - if (window.__OPENCODE__?.port) { - return `http://${host}:${window.__OPENCODE__.port}` - } - - // 4. Same-origin mode - if (useSameOrigin) { - return "/" - } + // 4. Dev mode -> same-origin so Vite proxy handles LAN access + CORS + if (import.meta.env.DEV) return window.location.origin - // 5. Explicit host:port (dev mode) - return `http://${host}:${port}` + // 5. Default -> same origin (production web command) + return window.location.origin }) +function ServerKey(props: ParentProps) { + const server = useServer() + return ( + + {props.children} + + ) +} + export function App() { return ( @@ -117,38 +71,42 @@ export function App() { - - - - - ( - - {props.children} - - )} - > - - - } /> - ( - - - - - - - + + + + + + + ( + + {props.children} + )} - /> - - - - - - + > + + + } /> + ( + + + + + + + + )} + /> + + + + + + + + diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx new file mode 100644 index 00000000000..6d224c6c3f3 --- /dev/null +++ b/packages/app/src/components/dialog-select-server.tsx @@ -0,0 +1,179 @@ +import { createEffect, createMemo, onCleanup } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { TextField } from "@opencode-ai/ui/text-field" +import { Button } from "@opencode-ai/ui/button" +import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { usePlatform } from "@/context/platform" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { useNavigate } from "@solidjs/router" + +type ServerStatus = { healthy: boolean; version?: string } + +async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch, + signal: AbortSignal.timeout(3000), + }) + return sdk.global + .health() + .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) + .catch(() => ({ healthy: false })) +} + +export function DialogSelectServer() { + const navigate = useNavigate() + const dialog = useDialog() + const server = useServer() + const platform = usePlatform() + const [store, setStore] = createStore({ + url: "", + adding: false, + error: "", + status: {} as Record, + }) + + const items = createMemo(() => { + const current = server.url + const list = server.list + if (!current) return list + if (!list.includes(current)) return [current, ...list] + return [current, ...list.filter((x) => x !== current)] + }) + + const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0]) + + const sortedItems = createMemo(() => { + const list = items() + if (!list.length) return list + const active = current() + const order = new Map(list.map((url, index) => [url, index] as const)) + const rank = (value?: ServerStatus) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + return list.slice().sort((a, b) => { + if (a === active) return -1 + if (b === active) return 1 + const diff = rank(store.status[a]) - rank(store.status[b]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) + }) + + async function refreshHealth() { + const results: Record = {} + await Promise.all( + items().map(async (url) => { + results[url] = await checkHealth(url, platform.fetch) + }), + ) + setStore("status", reconcile(results)) + } + + createEffect(() => { + items() + refreshHealth() + const interval = setInterval(refreshHealth, 10_000) + onCleanup(() => clearInterval(interval)) + }) + + function select(value: string, persist?: boolean) { + if (!persist && store.status[value]?.healthy === false) return + dialog.close() + if (persist) { + server.add(value) + navigate("/") + return + } + server.setActive(value) + navigate("/") + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + const value = normalizeServerUrl(store.url) + if (!value) return + + setStore("adding", true) + setStore("error", "") + + const result = await checkHealth(value, platform.fetch) + setStore("adding", false) + + if (!result.healthy) { + setStore("error", "Could not connect to server") + return + } + + setStore("url", "") + select(value, true) + } + + return ( + +
+ x} + current={current()} + onSelect={(x) => { + if (x) select(x) + }} + > + {(i) => ( +
+
+ {serverDisplayName(i)} + {store.status[i]?.version} +
+ )} + + +
+
+

Add a server

+
+
+
+
+ { + setStore("url", v) + setStore("error", "") + }} + validationState={store.error ? "invalid" : "valid"} + error={store.error} + /> +
+ +
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/dialog-server-settings.tsx b/packages/app/src/components/dialog-server-settings.tsx deleted file mode 100644 index a8e0d569513..00000000000 --- a/packages/app/src/components/dialog-server-settings.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { Component, createMemo, createSignal, For, Show } from "solid-js" -import { Dialog } from "@opencode-ai/ui/dialog" -import { TextField } from "@opencode-ai/ui/text-field" -import { Button } from "@opencode-ai/ui/button" -import { Icon } from "@opencode-ai/ui/icon" -import { showToast } from "@opencode-ai/ui/toast" -import { usePlatform } from "@/context/platform" -import { - getStoredServerUrl, - setStoredServerUrl, - clearStoredServerUrl, - getServerUrlHistory, - addToServerUrlHistory, - isValidServerUrl, - hasMixedContentRisk, -} from "@/lib/server-url" - -export const DialogServerSettings: Component = () => { - const platform = usePlatform() - - // Get current stored URL (if any) - const storedUrl = getStoredServerUrl() - const history = getServerUrlHistory() - - // Track the input value - const [inputUrl, setInputUrl] = createSignal("") - const [isSubmitting, setIsSubmitting] = createSignal(false) - - // Current effective URL display - const currentUrl = createMemo(() => { - if (storedUrl) return storedUrl - // Show what the default URL would be - return window.location.origin === "file://" ? "http://localhost:4096" : window.location.origin - }) - - const hasOverride = createMemo(() => !!storedUrl) - - // Validation - const inputValid = createMemo(() => { - const url = inputUrl().trim() - if (!url) return false - return isValidServerUrl(url) - }) - - const showMixedContentWarning = createMemo(() => { - const url = inputUrl().trim() - if (!url) return false - return hasMixedContentRisk(url) - }) - - // Set a new server URL - async function handleSetUrl() { - const url = inputUrl().trim() - if (!isValidServerUrl(url)) { - showToast({ - variant: "error", - icon: "circle-x", - title: "Invalid URL", - description: "Please enter a valid HTTP or HTTPS URL.", - }) - return - } - - setIsSubmitting(true) - setStoredServerUrl(url) - addToServerUrlHistory(url) - - showToast({ - variant: "success", - icon: "circle-check", - title: "Server URL updated", - description: "Reloading to apply changes...", - }) - - // Short delay to show the toast, then reload - setTimeout(() => { - platform.restart?.() ?? window.location.reload() - }, 500) - } - - // Select a URL from history - function handleSelectHistory(url: string) { - setInputUrl(url) - } - - // Clear the override and reload - function handleClearOverride() { - clearStoredServerUrl() - showToast({ - variant: "success", - icon: "circle-check", - title: "Server URL reset", - description: "Reloading to apply changes...", - }) - - setTimeout(() => { - platform.restart?.() ?? window.location.reload() - }, 500) - } - - return ( - -
- {/* Current URL display */} -
-
Current server URL
-
- {currentUrl()} - - - - - (default) - -
-
- - {/* Set custom URL */} -
-
Set custom URL
-
- setInputUrl(e.currentTarget.value)} - placeholder="http://localhost:4096" - class="flex-1 font-mono" - onKeyDown={(e: KeyboardEvent) => { - if (e.key === "Enter" && inputValid()) { - handleSetUrl() - } - }} - /> - -
- - {/* Mixed content warning */} - -
- - - This HTTP URL will be blocked by your browser (mixed content). Use{" "} - localhost or an HTTPS URL instead. - -
-
-
- - {/* History */} - 0}> -
-
Recent URLs
-
- - {(url) => { - const isSafe = !hasMixedContentRisk(url) - return ( - - ) - }} - -
-
-
- - {/* Clear override button */} - -
- -
-
-
-
- ) -} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 58f6fe94657..35f2b833d0e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -134,6 +134,7 @@ export const PromptInput: Component = (props) => { imageAttachments: ImageAttachmentPart[] mode: "normal" | "shell" applyingHistory: boolean + killBuffer: string }>({ popover: null, historyIndex: -1, @@ -143,6 +144,7 @@ export const PromptInput: Component = (props) => { imageAttachments: [], mode: "normal", applyingHistory: false, + killBuffer: "", }) const MAX_HISTORY = 100 @@ -659,6 +661,77 @@ export const PromptInput: Component = (props) => { setStore("popover", null) } + const setSelectionOffsets = (start: number, end: number) => { + const selection = window.getSelection() + if (!selection) return false + + const length = promptLength(prompt.current()) + const a = Math.max(0, Math.min(start, length)) + const b = Math.max(0, Math.min(end, length)) + const rangeStart = Math.min(a, b) + const rangeEnd = Math.max(a, b) + + const range = document.createRange() + range.selectNodeContents(editorRef) + + const setEdge = (edge: "start" | "end", offset: number) => { + let remaining = offset + const nodes = Array.from(editorRef.childNodes) + + for (const node of nodes) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + if (edge === "start") range.setStart(node, remaining) + if (edge === "end") range.setEnd(node, remaining) + return + } + + if ((isFile || isBreak) && remaining <= length) { + if (edge === "start" && remaining === 0) range.setStartBefore(node) + if (edge === "start" && remaining > 0) range.setStartAfter(node) + if (edge === "end" && remaining === 0) range.setEndBefore(node) + if (edge === "end" && remaining > 0) range.setEndAfter(node) + return + } + + remaining -= length + } + + const last = editorRef.lastChild + if (!last) { + if (edge === "start") range.setStart(editorRef, 0) + if (edge === "end") range.setEnd(editorRef, 0) + return + } + if (edge === "start") range.setStartAfter(last) + if (edge === "end") range.setEndAfter(last) + } + + setEdge("start", rangeStart) + setEdge("end", rangeEnd) + selection.removeAllRanges() + selection.addRange(range) + return true + } + + const replaceOffsets = (start: number, end: number, content: string) => { + if (!setSelectionOffsets(start, end)) return false + addPart({ type: "text", content, start: 0, end: 0 }) + return true + } + + const killText = (start: number, end: number) => { + if (start === end) return + const current = prompt.current() + if (!current.every((part) => part.type === "text")) return + const text = current.map((part) => part.content).join("") + setStore("killBuffer", text.slice(start, end)) + } + const abort = () => sdk.client.session .abort({ @@ -779,6 +852,164 @@ export const PromptInput: Component = (props) => { return } + const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + const alt = event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey + + if (ctrl && event.code === "KeyG") { + if (store.popover) { + setStore("popover", null) + event.preventDefault() + return + } + if (working()) { + abort() + event.preventDefault() + } + return + } + + if (ctrl || alt) { + const { collapsed, cursorPosition, textLength } = getCaretState() + if (collapsed) { + const current = prompt.current() + const text = current.map((part) => ("content" in part ? part.content : "")).join("") + + if (ctrl) { + if (event.code === "KeyA") { + const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1 + setCursorPosition(editorRef, pos) + event.preventDefault() + queueScroll() + return + } + + if (event.code === "KeyE") { + const next = text.indexOf("\n", cursorPosition) + const pos = next === -1 ? textLength : next + setCursorPosition(editorRef, pos) + event.preventDefault() + queueScroll() + return + } + + if (event.code === "KeyB") { + const pos = Math.max(0, cursorPosition - 1) + setCursorPosition(editorRef, pos) + event.preventDefault() + queueScroll() + return + } + + if (event.code === "KeyF") { + const pos = Math.min(textLength, cursorPosition + 1) + setCursorPosition(editorRef, pos) + event.preventDefault() + queueScroll() + return + } + + if (event.code === "KeyD") { + if (store.mode === "shell" && cursorPosition === 0 && textLength === 0) { + setStore("mode", "normal") + event.preventDefault() + return + } + if (cursorPosition >= textLength) return + replaceOffsets(cursorPosition, cursorPosition + 1, "") + event.preventDefault() + return + } + + if (event.code === "KeyK") { + const next = text.indexOf("\n", cursorPosition) + const lineEnd = next === -1 ? textLength : next + const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd + if (end === cursorPosition) return + killText(cursorPosition, end) + replaceOffsets(cursorPosition, end, "") + event.preventDefault() + return + } + + if (event.code === "KeyU") { + const start = text.lastIndexOf("\n", cursorPosition - 1) + 1 + if (start === cursorPosition) return + killText(start, cursorPosition) + replaceOffsets(start, cursorPosition, "") + event.preventDefault() + return + } + + if (event.code === "KeyW") { + let start = cursorPosition + while (start > 0 && /\s/.test(text[start - 1])) start -= 1 + while (start > 0 && !/\s/.test(text[start - 1])) start -= 1 + if (start === cursorPosition) return + killText(start, cursorPosition) + replaceOffsets(start, cursorPosition, "") + event.preventDefault() + return + } + + if (event.code === "KeyY") { + if (!store.killBuffer) return + addPart({ type: "text", content: store.killBuffer, start: 0, end: 0 }) + event.preventDefault() + return + } + + if (event.code === "KeyT") { + if (!current.every((part) => part.type === "text")) return + if (textLength < 2) return + if (cursorPosition === 0) return + + const atEnd = cursorPosition === textLength + const first = atEnd ? cursorPosition - 2 : cursorPosition - 1 + const second = atEnd ? cursorPosition - 1 : cursorPosition + + if (text[first] === "\n" || text[second] === "\n") return + + replaceOffsets(first, second + 1, `${text[second]}${text[first]}`) + event.preventDefault() + return + } + } + + if (alt) { + if (event.code === "KeyB") { + let pos = cursorPosition + while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1 + while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1 + setCursorPosition(editorRef, pos) + event.preventDefault() + queueScroll() + return + } + + if (event.code === "KeyF") { + let pos = cursorPosition + while (pos < textLength && /\s/.test(text[pos])) pos += 1 + while (pos < textLength && !/\s/.test(text[pos])) pos += 1 + setCursorPosition(editorRef, pos) + event.preventDefault() + queueScroll() + return + } + + if (event.code === "KeyD") { + let end = cursorPosition + while (end < textLength && /\s/.test(text[end])) end += 1 + while (end < textLength && !/\s/.test(text[end])) end += 1 + if (end === cursorPosition) return + killText(cursorPosition, end) + replaceOffsets(cursorPosition, end, "") + event.preventDefault() + return + } + } + } + } + if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (event.altKey || event.ctrlKey || event.metaKey) return const { collapsed } = getCaretState() @@ -898,11 +1129,17 @@ export const PromptInput: Component = (props) => { // Blur the editor to dismiss mobile keyboard after submission editorRef.blur() + const currentModel = local.model.current() + const currentAgent = local.agent.current() + if (!currentModel || !currentAgent) { + console.warn("No agent or model available for prompt submission") + return + } const model = { - modelID: local.model.current()!.id, - providerID: local.model.current()!.provider.id, + modelID: currentModel.id, + providerID: currentModel.provider.id, } - const agent = local.agent.current()!.name + const agent = currentAgent.name if (isShellMode) { sdk.client.session @@ -1110,6 +1347,7 @@ export const PromptInput: Component = (props) => { onInput={handleInput} onKeyDown={handleKeyDown} classList={{ + "select-text": true, "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-icon-info-active": true, "font-mono!": store.mode === "shell", @@ -1148,7 +1386,7 @@ export const PromptInput: Component = (props) => { > project.worktree)} - current={currentDirectory()} - label={(x) => getFilename(x)} - onSelect={(x) => (x ? navigateToProject(x) : undefined)} - class="text-14-regular text-text-base" - rootClass="min-w-0 shrink" - variant="ghost" - size="large" - > - {/* @ts-ignore */} - {(i) => ( -
- -
{getFilename(i)}
-
- )} - -
/
- -
- - - session - - - - -
- navigate(`/${params.dir}/session`)}> - -
- - New session -
-
- - - - - -
- - {(session) => ( - navigateToSession(session())}> - -
- -
- {session().title} - Current session -
-
-
- - - -
- )} -
-
- - -
- - {(session) => ( - navigateToSession(session)}> - -
- {session.title} -
-
-
- )} -
-
-
-
-
-
-
- {/* Mobile review button - shows file count when there are changes */} - - - - - - - - - -
- {/* Mobile message navigation */} - -
- - - - {layout.mobileMessageNav.currentIndex() + 1}/{layout.mobileMessageNav.messages().length} - - - - - - - {(msg, index) => ( - layout.mobileMessageNav.onSelect()?.(index())}> - - {index() + 1} - {msg.title || "Message"} - - - - - - - - )} - - - - -
-
- - - Toggle review - Cmd Shift R -
- } - > - - - - - Toggle terminal - Ctrl ` - - } - > - - - - - - - -
-
- - - -
+ const SidebarContent = (sidebarProps: { mobile?: boolean }) => { + const expanded = () => sidebarProps.mobile || layout.sidebar.opened() + return ( + <> +
+ {command.keybind("sidebar.toggle")}
} - inactive={layout.sidebar.opened()} + inactive={expanded()} >
-
Toggle sidebar
+
Toggle sidebar
- + + + +
{ + if (!sidebarProps.mobile) scrollContainerRef = el + }} + class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar" > - - -
- p.worktree)}> - {(project) => } - -
- - - - -
-
- - 0 && !providers.paid().length && layout.sidebar.opened()}> -
-
-
Getting started
-
OpenCode includes free models so you can start immediately.
-
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
-
- - - + p.worktree)}> + + {(project) => } + + +
+ + + + +
+
+ + 0 && !providers.paid().length && expanded()}> +
+
+
Getting started
+
OpenCode includes free models so you can start immediately.
+
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
- - 0}> - + - - - - - Open project - {command.keybind("project.open")} -
- } - inactive={layout.sidebar.opened()} - > +
+ + 0}> + - - - - - - - - - - -
- -
- v{__APP_VERSION__} ({__COMMIT_HASH__}) -
-
-
-
- {/* Desktop: direct children, Mobile: wrap in PullToRefresh for swipe-to-refresh */} - -
- {props.children} -
-
- + + - {/* Mobile fullscreen menu overlay */} - -
- {/* Mobile menu header */} -
- setStore("mobileMenuOpen", false)}> - - - +
- - {/* Mobile menu content */} -
- {/* Home/Recent Projects link */} + size="large" + icon="folder-add-left" + onClick={createProject} + > + Add project + + + - - {/* Projects section */} - 0}> -
-
Open Projects
- - {(project) => { - const name = () => getFilename(project.worktree) - return ( - - ) - }} - -
-
- - {/* Actions section */} -
-
Actions
- - - - - - -
- - {/* Settings section */} -
-
Settings
- - -
-
- - {/* Mobile menu footer */} -
-
+ + + + + +
v{__APP_VERSION__} ({__COMMIT_HASH__})
+
+
+ + ) + } + + return ( +
+
+
+
+ + + + +
+
+
{ + if (e.target === e.currentTarget) mobileSidebar.hide() + }} + /> +
e.stopPropagation()} + > +
- +
+ +
+ {props.children} +
+
+
) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 48558884248..4decec887d9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -331,21 +331,6 @@ export default function Page() { slash: "open", onSelect: () => dialog.show(() => ), }, - // { - // id: "theme.toggle", - // title: "Toggle theme", - // description: "Switch between themes", - // category: "View", - // keybind: "ctrl+t", - // slash: "theme", - // onSelect: () => { - // const currentTheme = localStorage.getItem("theme") ?? "oc-1" - // const themes = ["oc-1", "oc-2-paper"] - // const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length] - // localStorage.setItem("theme", nextTheme) - // document.documentElement.setAttribute("data-theme", nextTheme) - // }, - // }, { id: "terminal.toggle", title: "Toggle terminal", diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index e01c71e18e3..59c4f0e1239 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vite" import { readFileSync } from "fs" +import { sep } from "path" import { VitePWA } from "vite-plugin-pwa" import desktopPlugin from "./vite" @@ -17,6 +18,23 @@ const commitHash = })() const apiPort = process.env.VITE_OPENCODE_SERVER_PORT ?? "4096" const apiTarget = `http://127.0.0.1:${apiPort}` +const repoRoot = + process.env.OPENCODE_REPO_ROOT || + (() => { + try { + return execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim() + } catch { + return process.cwd() + } + })() +const reposRoot = (() => { + const parts = repoRoot.split(sep) + const reposIndex = parts.lastIndexOf("repos") + return reposIndex === -1 ? repoRoot : parts.slice(0, reposIndex + 1).join(sep) +})() +const devOrigin = process.env.VITE_DEV_ORIGIN +const devHmrHost = process.env.VITE_DEV_HMR_HOST +const devHmrPort = process.env.VITE_DEV_HMR_PORT // All API route prefixes from the opencode server const apiRoutes = [ @@ -114,6 +132,17 @@ export default defineConfig({ host: "0.0.0.0", allowedHosts: true, port: 3000, + origin: devOrigin, + hmr: + devHmrHost || devHmrPort + ? { + host: devHmrHost, + clientPort: devHmrPort ? Number(devHmrPort) : undefined, + } + : undefined, + fs: { + allow: [repoRoot, reposRoot], + }, proxy: Object.fromEntries( apiRoutes.map((route) => [ route, @@ -127,6 +156,6 @@ export default defineConfig({ }, build: { target: "esnext", - sourcemap: true, + // sourcemap: true, }, }) diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 3ae9027081f..2b4bb72e6aa 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.209-1", + "version": "1.0.218", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 5266f937eed..ab9875388dc 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.209-1", + "version": "1.0.218", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 84924e9d1f5..a1a1dc737bb 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.209-1", + "version": "1.0.218", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 0d70c2ff53b..f045fe0e8e3 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.209-1", + "version": "1.0.218", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/README.md b/packages/desktop/README.md index b381dcf5bf4..90618540e15 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -1,7 +1,21 @@ -# Tauri + Vanilla TS +# Shuvcode Desktop (Tauri) -This template should help get you started developing with Tauri in vanilla HTML, CSS and Typescript. +This package bundles the Shuvcode desktop app and ships the CLI sidecar. + +## Development + +1. Build the sidecar CLI for your target: + `bun run predev` +2. Start the desktop app: + `bun run tauri dev` + +## Build + +1. Ensure the sidecar is present: + `bun run predev` +2. Build the Tauri bundles: + `bun run tauri build` ## Recommended IDE Setup -- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) +- VS Code + Tauri + rust-analyzer diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 319f48d1c82..af6a03f2fc4 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { - "name": "@opencode-ai/desktop", + "name": "@shuvcode/desktop", "private": true, - "version": "1.0.209-1", + "version": "1.0.218", "type": "module", "scripts": { "typecheck": "tsgo -b", @@ -18,6 +18,7 @@ "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "~2", + "@tauri-apps/plugin-notification": "~2", "@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-shell": "~2", "@tauri-apps/plugin-store": "~2", diff --git a/packages/desktop/scripts/copy-bundles.ts b/packages/desktop/scripts/copy-bundles.ts index 3fde1c19010..8f16f967a70 100644 --- a/packages/desktop/scripts/copy-bundles.ts +++ b/packages/desktop/scripts/copy-bundles.ts @@ -9,4 +9,4 @@ const BUNDLE_DIR = `src-tauri/target/${RUST_TARGET}/release/bundle` const BUNDLES_OUT_DIR = path.join(process.cwd(), `src-tauri/target/bundles`) await $`mkdir -p ${BUNDLES_OUT_DIR}` -await $`cp -r ${BUNDLE_DIR}/*/OpenCode* ${BUNDLES_OUT_DIR}` +await $`cp -r ${BUNDLE_DIR}/*/Shuvcode* ${BUNDLES_OUT_DIR}` diff --git a/packages/desktop/scripts/prepare.ts b/packages/desktop/scripts/prepare.ts index 495a0baea42..fcff0da4b79 100755 --- a/packages/desktop/scripts/prepare.ts +++ b/packages/desktop/scripts/prepare.ts @@ -5,11 +5,11 @@ import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils" const sidecarConfig = getCurrentSidecar() -const dir = "src-tauri/target/opencode-binaries" +const dir = "src-tauri/target/shuvcode-binaries" await $`mkdir -p ${dir}` -await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir) +await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n shuvcode-cli`.cwd(dir) await copyBinaryToSidecarFolder( - `${dir}/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`, + `${dir}/${sidecarConfig.ocBinary}/bin/shuvcode${process.platform === "win32" ? ".exe" : ""}`, ) diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index d0a27683506..20868430488 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -23,12 +23,7 @@ export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; ass }, { rustTarget: "aarch64-unknown-linux-gnu", - ocBinary: "opencode-linux-arm64", - assetExt: "tar.gz", - }, - { - rustTarget: "aarch64-unknown-linux-gnu", - ocBinary: "opencode-linux-arm64", + ocBinary: "shuvcode-linux-arm64", assetExt: "tar.gz", }, ] @@ -46,7 +41,7 @@ export function getCurrentSidecar(target = RUST_TARGET) { export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) { await $`mkdir -p src-tauri/sidecars` - const dest = `src-tauri/sidecars/opencode-cli-${target}${process.platform === "win32" ? ".exe" : ""}` + const dest = `src-tauri/sidecars/shuvcode-cli-${target}${process.platform === "win32" ? ".exe" : ""}` await $`cp ${source} ${dest}` console.log(`Copied ${source} to ${dest}`) diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 0bf5f70139e..f720388360d 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2210,6 +2210,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2384,6 +2396,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "notify-rust" +version = "4.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2745,30 +2771,6 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "opencode-desktop" -version = "0.0.0" -dependencies = [ - "gtk", - "listeners", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-clipboard-manager", - "tauri-plugin-dialog", - "tauri-plugin-http", - "tauri-plugin-opener", - "tauri-plugin-os", - "tauri-plugin-process", - "tauri-plugin-shell", - "tauri-plugin-store", - "tauri-plugin-updater", - "tauri-plugin-window-state", - "tokio", - "webkit2gtk", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -3978,6 +3980,31 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shuvcode-desktop" +version = "0.0.0" +dependencies = [ + "gtk", + "listeners", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", + "tauri-plugin-http", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-os", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-store", + "tauri-plugin-updater", + "tauri-plugin-window-state", + "tokio", + "webkit2gtk", +] + [[package]] name = "sigchld" version = "0.2.4" @@ -4519,6 +4546,25 @@ dependencies = [ "urlpattern", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.2" @@ -4754,6 +4800,18 @@ dependencies = [ "toml 0.9.8", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.17", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.23.0" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 0463966c084..d269fd926e9 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "opencode-desktop" +name = "shuvcode-desktop" version = "0.0.0" description = "The open source AI coding agent" authors = ["Anomaly Innovations"] @@ -11,7 +11,7 @@ edition = "2021" # The `_lib` suffix may seem redundant but it is necessary # to make the lib name unique and wouldn't conflict with the bin name. # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 -name = "opencode_lib" +name = "shuvcode_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] @@ -28,6 +28,7 @@ tauri-plugin-store = "2" tauri-plugin-window-state = "2" tauri-plugin-clipboard-manager = "2" tauri-plugin-http = "2" +tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index c805f623b16..1b305aebeeb 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -8,6 +8,10 @@ "opener:default", "core:window:allow-start-dragging", "core:webview:allow-set-webview-zoom", + "core:window:allow-is-focused", + "core:window:allow-show", + "core:window:allow-unminimize", + "core:window:allow-set-focus", "shell:default", "updater:default", "dialog:default", @@ -15,6 +19,7 @@ "store:default", "window-state:default", "os:default", + "notification:default", { "identifier": "http:default", "allow": [{ "url": "http://*" }, { "url": "https://*" }, { "url": "http://*:*/*" }] diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 3c08841ab83..0a45a17dec3 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -76,8 +76,10 @@ async fn get_logs(app: AppHandle) -> Result { } fn get_sidecar_port() -> u32 { - option_env!("OPENCODE_PORT") + option_env!("SHUVCODE_PORT") .map(|s| s.to_string()) + .or_else(|| option_env!("OPENCODE_PORT").map(|s| s.to_string())) + .or_else(|| std::env::var("SHUVCODE_PORT").ok()) .or_else(|| std::env::var("OPENCODE_PORT").ok()) .and_then(|port_str| port_str.parse().ok()) .unwrap_or_else(|| { @@ -105,14 +107,16 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild { #[cfg(target_os = "windows")] let (mut rx, child) = app .shell() - .sidecar("opencode-cli") + .sidecar("shuvcode-cli") .unwrap() + .env("SHUVCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") + .env("SHUVCODE_CLIENT", "desktop") .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") .env("OPENCODE_CLIENT", "desktop") .env("XDG_STATE_HOME", &state_dir) .args(["serve", &format!("--port={port}")]) .spawn() - .expect("Failed to spawn opencode"); + .expect("Failed to spawn shuvcode"); #[cfg(not(target_os = "windows"))] let (mut rx, child) = { @@ -120,10 +124,12 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild { .expect("Failed to get current exe") .parent() .expect("Failed to get parent dir") - .join("opencode-cli"); + .join("shuvcode-cli"); let shell = get_user_shell(); app.shell() .command(&shell) + .env("SHUVCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") + .env("SHUVCODE_CLIENT", "desktop") .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") .env("OPENCODE_CLIENT", "desktop") .env("XDG_STATE_HOME", &state_dir) @@ -133,7 +139,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild { &format!("{} serve --port={}", sidecar_path.display(), port), ]) .spawn() - .expect("Failed to spawn opencode") + .expect("Failed to spawn shuvcode") }; tauri::async_runtime::spawn(async move { @@ -198,6 +204,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_notification::init()) .plugin(PinchZoomDisablePlugin) .invoke_handler(tauri::generate_handler![ kill_sidecar, @@ -222,7 +229,7 @@ pub fn run() { loop { if timestamp.elapsed() > Duration::from_secs(7) { let res = app.dialog() - .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.") + .message("Failed to spawn Shuvcode Server. Copy logs using the button below and send them to the team for assistance.") .title("Startup Failed") .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string())) .blocking_show_with_result(); @@ -263,16 +270,17 @@ pub fn run() { let mut window_builder = WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) - .title("OpenCode") + .title("Shuvcode") .inner_size(size.width as f64, size.height as f64) .decorations(true) .zoom_hotkeys_enabled(true) .disable_drag_drop_handler() .initialization_script(format!( r#" - window.__OPENCODE__ ??= {{}}; - window.__OPENCODE__.updaterEnabled = {updater_enabled}; - window.__OPENCODE__.port = {port}; + window.__SHUVCODE__ ??= {{}}; + window.__SHUVCODE__.updaterEnabled = {updater_enabled}; + window.__SHUVCODE__.port = {port}; + window.__OPENCODE__ ??= window.__SHUVCODE__; "# )); diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index b215f8c55a9..db6580ec156 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -25,11 +25,12 @@ fn configure_display_backend() -> Option { // Allow users to explicitly keep Wayland if they know their setup is stable. let allow_wayland = matches!( - env::var("OC_ALLOW_WAYLAND"), + env::var("SHUVCODE_ALLOW_WAYLAND") + .or_else(|_| env::var("OC_ALLOW_WAYLAND")), Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes") ); if allow_wayland { - return Some("Wayland session detected; respecting OC_ALLOW_WAYLAND=1".into()); + return Some("Wayland session detected; respecting SHUVCODE_ALLOW_WAYLAND=1".into()); } // Prefer XWayland when available to avoid Wayland protocol errors seen during startup. @@ -39,7 +40,7 @@ fn configure_display_backend() -> Option { set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); return Some( "Wayland session detected; forcing X11 backend to avoid compositor protocol errors. \ - Set OC_ALLOW_WAYLAND=1 to keep native Wayland." + Set SHUVCODE_ALLOW_WAYLAND=1 to keep native Wayland." .into(), ); } @@ -59,5 +60,5 @@ fn main() { } } - opencode_lib::run() + shuvcode_lib::run() } diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index bcb067a3207..1651718597a 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "OpenCode Dev", - "identifier": "ai.opencode.desktop.dev", - "mainBinaryName": "OpenCode", + "productName": "Shuvcode Dev", + "identifier": "dev.shuvcode.desktop.dev", + "mainBinaryName": "Shuvcode", "version": "../package.json", "build": { "beforeDevCommand": "bun run dev", @@ -27,7 +27,7 @@ ], "active": true, "targets": ["deb", "rpm", "dmg", "nsis", "app", "appimage"], - "externalBin": ["sidecars/opencode-cli"], + "externalBin": ["sidecars/shuvcode-cli"], "macOS": { "entitlements": "./entitlements.plist" }, diff --git a/packages/desktop/src-tauri/tauri.prod.conf.json b/packages/desktop/src-tauri/tauri.prod.conf.json index 7894b8ab207..5d9410c91b6 100644 --- a/packages/desktop/src-tauri/tauri.prod.conf.json +++ b/packages/desktop/src-tauri/tauri.prod.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "OpenCode", - "identifier": "ai.opencode.desktop", + "productName": "Shuvcode", + "identifier": "dev.shuvcode.desktop", "bundle": { "createUpdaterArtifacts": true, "icon": [ @@ -20,7 +20,7 @@ "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK", - "endpoints": ["https://github.com/sst/opencode/releases/latest/download/latest.json"] + "endpoints": ["https://github.com/Latitudes-Dev/shuvcode/releases/latest/download/latest.json"] } } } diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 58aca8fd172..a36da412520 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -12,6 +12,8 @@ import { UPDATER_ENABLED } from "./updater" import { createMenu } from "./menu" import { check, Update } from "@tauri-apps/plugin-updater" import { invoke } from "@tauri-apps/api/core" +import { getCurrentWindow } from "@tauri-apps/api/window" +import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { relaunch } from "@tauri-apps/plugin-process" import pkg from "../package.json" @@ -94,6 +96,33 @@ const platform: Platform = { await relaunch() }, + notify: async (title, description, href) => { + const granted = await isPermissionGranted().catch(() => false) + const permission = granted ? "granted" : await requestPermission().catch(() => "denied") + if (permission !== "granted") return + + const win = getCurrentWindow() + const focused = await win.isFocused().catch(() => document.hasFocus()) + if (focused) return + + await Promise.resolve() + .then(() => { + const notification = new Notification(title, { body: description ?? "" }) + notification.onclick = () => { + const win = getCurrentWindow() + void win.show().catch(() => undefined) + void win.unminimize().catch(() => undefined) + void win.setFocus().catch(() => undefined) + if (href) { + window.history.pushState(null, "", href) + window.dispatchEvent(new PopStateEvent("popstate")) + } + notification.close() + } + }) + .catch(() => undefined) + }, + // @ts-expect-error fetch: tauriFetch, } diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index d1a5fba8e3a..26ac1e8990e 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -9,7 +9,7 @@ export async function createMenu() { const menu = await Menu.new({ items: [ await Submenu.new({ - text: "OpenCode", + text: "Shuvcode", items: [ await PredefinedMenuItem.new({ item: { About: null }, diff --git a/packages/desktop/src/updater.ts b/packages/desktop/src/updater.ts index 4753ee66390..f01014042e0 100644 --- a/packages/desktop/src/updater.ts +++ b/packages/desktop/src/updater.ts @@ -4,7 +4,8 @@ import { ask, message } from "@tauri-apps/plugin-dialog" import { invoke } from "@tauri-apps/api/core" import { type as ostype } from "@tauri-apps/plugin-os" -export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false +export const UPDATER_ENABLED = + window.__SHUVCODE__?.updaterEnabled ?? window.__OPENCODE__?.updaterEnabled ?? false export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { let update @@ -17,7 +18,7 @@ export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { if (!update) { if (alertOnFail) - await message("You are already using the latest version of OpenCode", { title: "No Update Available" }) + await message("You are already using the latest version of Shuvcode", { title: "No Update Available" }) return } @@ -29,7 +30,7 @@ export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { } const shouldUpdate = await ask( - `Version ${update.version} of OpenCode has been downloaded, would you like to install it and relaunch?`, + `Version ${update.version} of Shuvcode has been downloaded, would you like to install it and relaunch?`, { title: "Update Downloaded" }, ) if (!shouldUpdate) return diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts index 9d17fa9da61..37bc633930e 100644 --- a/packages/desktop/vite.config.ts +++ b/packages/desktop/vite.config.ts @@ -11,12 +11,12 @@ export default defineConfig({ // 1. prevent Vite from obscuring rust errors clearScreen: false, esbuild: { - // Improves production stack traces (less "kQ@..." noise) + // Improves production stack traces keepNames: true, }, - build: { - sourcemap: true, - }, + // build: { + // sourcemap: true, + // }, // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1420, diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 5aedd98646b..ec36524fa07 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.209-1", + "version": "1.0.218", "private": true, "type": "module", "scripts": { diff --git a/packages/enterprise/src/app.tsx b/packages/enterprise/src/app.tsx index 6d32c8b6cf6..0fd3a009ca3 100644 --- a/packages/enterprise/src/app.tsx +++ b/packages/enterprise/src/app.tsx @@ -3,6 +3,7 @@ import { FileRoutes } from "@solidjs/start/router" import { Font } from "@opencode-ai/ui/font" import { MetaProvider } from "@solidjs/meta" import { MarkedProvider } from "@opencode-ai/ui/context/marked" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { Suspense } from "solid-js" import "./app.css" import { Favicon } from "@opencode-ai/ui/favicon" @@ -12,11 +13,13 @@ export default function App() { ( - - - - {props.children} - + + + + + {props.children} + + )} > diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index f38706588b9..8f3c503dc31 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -161,11 +161,20 @@ export default function () { return ( { + fallback={(error) => { + if (SessionDataMissingError.isInstance(error)) { + return + } + console.error(error) + const details = error instanceof Error ? (error.stack ?? error.message) : String(error) return ( - - - +
+

Unable to render this share.

+

Check the console for more details.

+
+              {details}
+            
+
) }} > diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 78d1cffc0e3..e3071a8b6fc 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.0.209-1" +version = "1.0.218" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.209-1/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.209-1/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.209-1/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.209-1/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.209-1/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index b4904938e29..b45e3a973a2 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.209-1", + "version": "1.0.218", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml index 9afe227b326..c227328d5ae 100644 --- a/packages/opencode/bunfig.toml +++ b/packages/opencode/bunfig.toml @@ -2,5 +2,6 @@ preload = ["@opentui/solid/preload"] [test] preload = ["./test/preload.ts"] +timeout = 10000 # 10 seconds (default is 5000ms) # Enable code coverage coverage = true diff --git a/packages/opencode/package.json b/packages/opencode/package.json index bddd84c1375..a5bf5a1e34b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.209-1", + "version": "1.0.218", "name": "opencode", "type": "module", "private": true, @@ -51,18 +51,17 @@ "@agentclientprotocol/sdk": "0.5.1", "@ai-sdk/amazon-bedrock": "3.0.57", "@ai-sdk/anthropic": "2.0.56", - "@ai-sdk/azure": "2.0.73", + "@ai-sdk/azure": "2.0.82", "@ai-sdk/cerebras": "1.0.33", "@ai-sdk/cohere": "2.0.21", "@ai-sdk/deepinfra": "1.0.30", "@ai-sdk/gateway": "2.0.23", - "@ai-sdk/google": "2.0.44", + "@ai-sdk/google": "2.0.49", "@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/groq": "2.0.33", - "@ai-sdk/mcp": "0.0.8", "@ai-sdk/mistral": "2.0.26", "@ai-sdk/openai": "2.0.71", - "@ai-sdk/openai-compatible": "1.0.27", + "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/perplexity": "2.0.22", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", @@ -80,8 +79,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.2", - "@opentui/core": "0.1.63", - "@opentui/solid": "0.1.63", + "@opentui/core": "0.1.67", + "@opentui/solid": "0.1.67", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 658329fb6ef..a5e52dd0e62 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -349,6 +349,12 @@ export const AuthLoginCommand = cmd({ prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") } + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { + prompts.log.info( + "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", + ) + } + const key = await prompts.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index aff3deae878..6b31d5fbae9 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -392,6 +392,15 @@ function App() { local.agent.move(1) }, }, + { + title: "Variant cycle", + value: "variant.cycle", + keybind: "variant_cycle", + category: "Agent", + onSelect: () => { + local.model.variant.cycle() + }, + }, { title: "Agent cycle reverse", value: "agent.cycle.reverse", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index bc90dbb5c6e..50cf43896a9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -150,7 +150,7 @@ export function DialogModel(props: { providerID?: string }) { (item) => item.providerID === value.providerID && item.modelID === value.modelID, ) if (inFavorites) return false - const inRecents = recents.some( + const inRecents = recentList.some( (item) => item.providerID === value.providerID && item.modelID === value.modelID, ) if (inRecents) return false 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 6b1eb063cd2..958e6ef2ad9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,5 +1,5 @@ import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core" -import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js" +import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match, batch } from "solid-js" import "opentui-spinner/solid" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" @@ -158,6 +158,49 @@ export function Prompt(props: PromptProps) { const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId: number + const lastUserMessage = createMemo(() => { + if (!props.sessionID) return undefined + const messages = sync.data.message[props.sessionID] + if (!messages) return undefined + return messages.findLast((m) => m.role === "user") + }) + + const [store, setStore] = createStore<{ + prompt: PromptInfo + mode: "normal" | "shell" + extmarkToPartIndex: Map + interrupt: number + placeholder: number + }>({ + placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + prompt: { + input: "", + parts: [], + }, + mode: "normal", + extmarkToPartIndex: new Map(), + interrupt: 0, + }) + + // Initialize agent/model/variant from last user message when session changes + let syncedSessionID: string | undefined + createEffect(() => { + const sessionID = props.sessionID + const msg = lastUserMessage() + + if (sessionID !== syncedSessionID) { + if (!sessionID || !msg) return + + syncedSessionID = sessionID + + batch(() => { + if (msg.agent) local.agent.set(msg.agent) + if (msg.model) local.model.set(msg.model) + if (msg.variant) local.model.variant.set(msg.variant) + }) + } + }) + command.register(() => { return [ { @@ -286,6 +329,7 @@ export function Prompt(props: PromptProps) { start: newStart, end: newEnd, }, + path: part.source.path, }, } } @@ -303,7 +347,7 @@ export function Prompt(props: PromptProps) { return part }) - .filter((part) => part !== null) + .filter((part): part is Exclude => part !== null) setStore("prompt", { input: content, @@ -392,6 +436,11 @@ export function Prompt(props: PromptProps) { // Not a file reference, just insert as plain text input.insertText(text) } + setTimeout(() => { + input.getLayoutNode().markDirty() + input.gotoBufferEnd() + renderer.requestRender() + }, 0) }) sdk.event.on(Ide.Event.SelectionChanged.type, (evt) => { @@ -403,23 +452,6 @@ export function Prompt(props: PromptProps) { if (!props.disabled) input.cursorColor = theme.text }) - const [store, setStore] = createStore<{ - prompt: PromptInfo - mode: "normal" | "shell" - extmarkToPartIndex: Map - interrupt: number - placeholder: number - }>({ - placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), - prompt: { - input: "", - parts: [], - }, - mode: "normal", - extmarkToPartIndex: new Map(), - interrupt: 0, - }) - createEffect(() => { input.focus() }) @@ -710,6 +742,8 @@ export function Prompt(props: PromptProps) { // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") + const variant = local.model.variant.current() + if (store.mode === "shell") { sdk.client.session.shell({ sessionID, @@ -738,6 +772,7 @@ export function Prompt(props: PromptProps) { agent: local.agent.current().name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, messageID, + variant, }) } else { sdk.client.session.prompt({ @@ -746,6 +781,7 @@ export function Prompt(props: PromptProps) { messageID, agent: local.agent.current().name, model: selectedModel, + variant, parts: [ { id: Identifier.ascending("part"), @@ -894,6 +930,13 @@ export function Prompt(props: PromptProps) { return local.agent.color(local.agent.current().name) }) + const showVariant = createMemo(() => { + const variants = local.model.variant.list() + if (variants.length === 0) return false + const current = local.model.variant.current() + return !!current + }) + const spinnerDef = createMemo(() => { const color = local.agent.color(local.agent.current().name) return { @@ -1118,7 +1161,7 @@ export function Prompt(props: PromptProps) { syntaxStyle={syntax()} /> - + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} @@ -1128,6 +1171,12 @@ export function Prompt(props: PromptProps) { {local.model.parsed().model} {local.model.parsed().provider} + + · + + {local.model.variant.current()} + + @@ -1252,6 +1301,12 @@ export function Prompt(props: PromptProps) { {local.model.parsed().model} {local.model.parsed().provider} + + · + + {local.model.variant.current()} + + diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index d5d78aa1715..71701684250 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -34,24 +34,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - // Automatically update model when agent changes - createEffect(() => { - const value = agent.current() - if (value.model) { - if (isModelValid(value.model)) - model.set({ - providerID: value.model.providerID, - modelID: value.model.modelID, - }) - else - toast.show({ - variant: "warning", - message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, - duration: 3000, - }) - } - }) - const agent = iife(() => { const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const [agentStore, setAgentStore] = createStore<{ @@ -121,11 +103,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ providerID: string modelID: string }[] + variant: Record }>({ ready: false, model: {}, recent: [], favorite: [], + variant: {}, }) const file = Bun.file(path.join(Global.Path.state, "model.json")) @@ -136,6 +120,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ JSON.stringify({ recent: modelStore.recent, favorite: modelStore.favorite, + variant: modelStore.variant, }), ) } @@ -145,6 +130,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .then((x) => { if (Array.isArray(x.recent)) setModelStore("recent", x.recent) if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite) + if (typeof x.variant === "object" && x.variant !== null) setModelStore("variant", x.variant) }) .catch(() => {}) .finally(() => { @@ -219,6 +205,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return { provider: "Connect a provider", model: "No provider selected", + reasoning: false, } } const provider = sync.data.provider.find((x) => x.id === value.providerID) @@ -226,6 +213,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return { provider: provider?.name ?? value.providerID, model: info?.name ?? value.modelID, + reasoning: info?.capabilities?.reasoning ?? false, } }), cycle(direction: 1 | -1) { @@ -268,7 +256,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setModelStore("model", agent.current().name, { ...next }) const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID) if (uniq.length > 10) uniq.pop() - setModelStore("recent", uniq) + setModelStore( + "recent", + uniq.map((x) => ({ providerID: x.providerID, modelID: x.modelID })), + ) save() }, set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { @@ -285,7 +276,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (options?.recent) { const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID) if (uniq.length > 10) uniq.pop() - setModelStore("recent", uniq) + setModelStore( + "recent", + uniq.map((x) => ({ providerID: x.providerID, modelID: x.modelID })), + ) save() } }) @@ -306,10 +300,53 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const next = exists ? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID) : [model, ...modelStore.favorite] - setModelStore("favorite", next) + setModelStore( + "favorite", + next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })), + ) save() }) }, + variant: { + current() { + const m = currentModel() + if (!m) return undefined + const key = `${m.providerID}/${m.modelID}` + return modelStore.variant[key] + }, + list() { + const m = currentModel() + if (!m) return [] + const provider = sync.data.provider.find((x) => x.id === m.providerID) + const info = provider?.models[m.modelID] + if (!info?.variants) return [] + return Object.entries(info.variants) + .filter(([_, v]) => !v.disabled) + .map(([name]) => name) + }, + set(value: string | undefined) { + const m = currentModel() + if (!m) return + const key = `${m.providerID}/${m.modelID}` + setModelStore("variant", key, value) + save() + }, + cycle() { + const variants = this.list() + if (variants.length === 0) return + const current = this.current() + if (!current) { + this.set(variants[0]) + return + } + const index = variants.indexOf(current) + if (index === -1 || index === variants.length - 1) { + this.set(undefined) + return + } + this.set(variants[index + 1]) + }, + }, } }) @@ -378,6 +415,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) + // Automatically update model when agent changes + createEffect(() => { + const value = agent.current() + if (value.model) { + if (isModelValid(value.model)) + model.set({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + else + toast.show({ + variant: "warning", + message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, + duration: 3000, + }) + } + }) + const result = { model, agent, 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 1e764d66bba..2a39bb01e07 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -115,11 +115,12 @@ export function DialogSelect(props: DialogSelectProps) { setStore("selected", currentIndex) } } - scroll.scrollTo(0) + scroll?.scrollTo(0) }), ) function move(direction: number) { + if (flat().length === 0) return let next = store.selected + direction if (next < 0) next = flat().length - 1 if (next >= flat().length) next = 0 @@ -129,6 +130,7 @@ export function DialogSelect(props: DialogSelectProps) { function moveTo(next: number) { setStore("selected", next) props.onMove?.(selected()!) + if (!scroll) return const target = scroll.getChildren().find((child) => { return child.id === JSON.stringify(selected()?.value) }) @@ -172,7 +174,7 @@ export function DialogSelect(props: DialogSelectProps) { } }) - let scroll: ScrollBoxRenderable + let scroll: ScrollBoxRenderable | undefined const ref: DialogSelectRef = { get filter() { return store.filter @@ -213,61 +215,70 @@ export function DialogSelect(props: DialogSelectProps) { /> - (scroll = r)} - maxHeight={height()} + 0} + fallback={ + + No results found + + } > - - {([category, options], index) => ( - <> - - 0 ? 1 : 0} paddingLeft={3}> - - {category} - - - - - {(option) => { - const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) - const current = createMemo(() => isDeepEqual(option.value, props.current)) - return ( - { - option.onSelect?.(dialog) - props.onSelect?.(option) - }} - onMouseOver={() => { - const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value)) - if (index === -1) return - moveTo(index) - }} - backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} - paddingLeft={current() || option.gutter ? 1 : 3} - paddingRight={3} - gap={1} - > - - ) - }} - - - )} - - + (scroll = r)} + maxHeight={height()} + > + + {([category, options], index) => ( + <> + + 0 ? 1 : 0} paddingLeft={3}> + + {category} + + + + + {(option) => { + const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) + const current = createMemo(() => isDeepEqual(option.value, props.current)) + return ( + { + option.onSelect?.(dialog) + props.onSelect?.(option) + }} + onMouseOver={() => { + const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value)) + if (index === -1) return + moveTo(index) + }} + backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} + paddingLeft={current() || option.gutter ? 1 : 3} + paddingRight={3} + gap={1} + > + + ) + }} + + + )} + + + }> diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7e1e7a65628..bc9ba24859a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -541,6 +541,7 @@ export namespace Config { agent_list: z.string().optional().default("a").describe("List agents"), agent_cycle: z.string().optional().default("tab").describe("Next agent"), agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), + variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), input_submit: z.string().optional().default("return").describe("Submit input"), diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 1194d7a0326..f8a2dce571a 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -11,6 +11,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Ripgrep } from "./ripgrep" import fuzzysort from "fuzzysort" +import { Global } from "../global" export namespace File { const log = Log.create({ service: "file" }) @@ -160,10 +161,49 @@ export namespace File { type Entry = { files: string[]; dirs: string[] } let cache: Entry = { files: [], dirs: [] } let fetching = false + + const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" + const fn = async (result: Entry) => { // Disable scanning if in root of file system if (Instance.directory === path.parse(Instance.directory).root) return fetching = true + + if (isGlobalHome) { + const dirs = new Set() + const ignore = new Set() + + if (process.platform === "darwin") ignore.add("Library") + if (process.platform === "win32") ignore.add("AppData") + + const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) + const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name) + const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) + + const top = await fs.promises + .readdir(Instance.directory, { withFileTypes: true }) + .catch(() => [] as fs.Dirent[]) + + for (const entry of top) { + if (!entry.isDirectory()) continue + if (shouldIgnore(entry.name)) continue + dirs.add(entry.name + "/") + + const base = path.join(Instance.directory, entry.name) + const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) + for (const child of children) { + if (!child.isDirectory()) continue + if (shouldIgnoreNested(child.name)) continue + dirs.add(entry.name + "/" + child.name + "/") + } + } + + result.dirs = Array.from(dirs).toSorted() + cache = result + fetching = false + return + } + const set = new Set() for await (const file of Ripgrep.files({ cwd: Instance.directory })) { result.files.push(file) @@ -394,15 +434,43 @@ export namespace File { }) } - export async function search(input: { query: string; limit?: number; dirs?: boolean }) { - log.info("search", { query: input.query }) + export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { + const query = input.query.trim() const limit = input.limit ?? 100 + const kind = input.type ?? (input.dirs === false ? "file" : "all") + log.info("search", { query, kind }) + const result = await state().then((x) => x.files()) - if (!input.query) - return input.dirs !== false ? result.dirs.toSorted().slice(0, limit) : result.files.slice(0, limit) - const items = input.dirs !== false ? [...result.files, ...result.dirs] : result.files - const sorted = fuzzysort.go(input.query, items, { limit: limit }).map((r) => r.target) - log.info("search", { query: input.query, results: sorted.length }) - return sorted + + const hidden = (item: string) => { + const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") + return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) + } + const preferHidden = query.startsWith(".") || query.includes("/.") + const sortHiddenLast = (items: string[]) => { + if (preferHidden) return items + const visible: string[] = [] + const hiddenItems: string[] = [] + for (const item of items) { + const isHidden = hidden(item) + if (isHidden) hiddenItems.push(item) + if (!isHidden) visible.push(item) + } + return [...visible, ...hiddenItems] + } + if (!query) { + if (kind === "file") return result.files.slice(0, limit) + return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) + } + + const items = + kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] + + const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit + const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) + const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted + + log.info("search", { query, kind, results: output.length }) + return output } } diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 22b714b85d4..841f5f30517 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -205,8 +205,17 @@ export namespace Ripgrep { return filepath } - export async function* files(input: { cwd: string; glob?: string[] }) { - const args = [await filepath(), "--files", "--follow", "--hidden", "--glob=!.git/*"] + export async function* files(input: { + cwd: string + glob?: string[] + hidden?: boolean + follow?: boolean + maxDepth?: number + }) { + const args = [await filepath(), "--files", "--glob=!.git/*"] + if (input.follow !== false) args.push("--follow") + if (input.hidden !== false) args.push("--hidden") + if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) if (input.glob) { for (const g of input.glob) { args.push(`--glob=${g}`) diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 2504a47dc5b..7be58634e1c 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -12,14 +12,17 @@ const state = path.join(xdgState!, app) export namespace Global { export const Path = { - home: os.homedir(), + // Allow override via OPENCODE_TEST_HOME for test isolation + get home() { + return process.env.OPENCODE_TEST_HOME || os.homedir() + }, data, bin: path.join(data, "bin"), log: path.join(data, "log"), cache, config, state, - } as const + } } await Promise.all([ diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index f56f1b1947b..379c2db89d4 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1437,6 +1437,24 @@ export namespace LSPServer { }, } + export const Prisma: Info = { + id: "prisma", + extensions: [".prisma"], + root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]), + async spawn(root) { + const prisma = Bun.which("prisma") + if (!prisma) { + log.info("prisma not found, please install prisma") + return + } + return { + process: spawn(prisma, ["language-server"], { + cwd: root, + }), + } + }, + } + export const Dart: Info = { id: "dart", extensions: [".dart"], diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 9bd8d7b3048..66540001592 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -89,6 +89,7 @@ export namespace Plugin { project: Instance.project, worktree: Instance.worktree, directory: Instance.directory, + serverUrl: Server.url(), $: Bun.$, } const plugins = [...(config.plugin ?? [])] diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2b68d4f7619..7a31f7f1b86 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -34,6 +34,7 @@ import { createCohere } from "@ai-sdk/cohere" import { createGateway } from "@ai-sdk/gateway" import { createTogetherAI } from "@ai-sdk/togetherai" import { createPerplexity } from "@ai-sdk/perplexity" +import { ProviderTransform } from "./transform" export namespace Provider { const log = Log.create({ service: "provider" }) @@ -423,6 +424,16 @@ export namespace Provider { }, } + export const Variant = z + .object({ + disabled: z.boolean(), + }) + .catchall(z.any()) + .meta({ + ref: "Variant", + }) + export type Variant = z.infer + export const Model = z .object({ id: z.string(), @@ -486,6 +497,7 @@ export namespace Provider { options: z.record(z.string(), z.any()), headers: z.record(z.string(), z.string()), release_date: z.string(), + variants: z.record(z.string(), Variant).optional(), }) .meta({ ref: "Model", @@ -508,7 +520,7 @@ export namespace Provider { export type Info = z.infer function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { - return { + const m: Model = { id: model.id, providerID: provider.id, name: model.name, @@ -565,7 +577,12 @@ export namespace Provider { interleaved: model.interleaved ?? false, }, release_date: model.release_date, + variants: {}, } + + m.variants = mapValues(ProviderTransform.variants(m), (v) => ({ disabled: false, ...v })) + + return m } export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6feceb5e6f8..9de2ad52c5d 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -124,7 +124,7 @@ export namespace ProviderTransform { cacheControl: { type: "ephemeral" }, }, openrouter: { - cache_control: { type: "ephemeral" }, + cacheControl: { type: "ephemeral" }, }, bedrock: { cachePoint: { type: "ephemeral" }, @@ -243,6 +243,162 @@ export namespace ProviderTransform { return undefined } + const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] + const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] + + export function variants(model: Provider.Model) { + if (!model.capabilities.reasoning) return {} + + const id = model.id.toLowerCase() + if (id.includes("deepseek") || id.includes("minimax") || id.includes("glm") || id.includes("mistral")) 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 {} + return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }])) + + // TODO: YOU CANNOT SET max_tokens if this is set!!! + case "@ai-sdk/gateway": + return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + + case "@ai-sdk/cerebras": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras + case "@ai-sdk/togetherai": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/togetherai + case "@ai-sdk/xai": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/xai + case "@ai-sdk/deepinfra": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra + case "@ai-sdk/openai-compatible": + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + + case "@ai-sdk/azure": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure + if (id === "o1-mini") return {} + const azureEfforts = ["low", "medium", "high"] + if (id.includes("gpt-5")) { + azureEfforts.unshift("minimal") + } + return Object.fromEntries( + azureEfforts.map((effort) => [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ]), + ) + case "@ai-sdk/openai": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai + if (id === "gpt-5-pro") return {} + const openaiEfforts = ["minimal", ...WIDELY_SUPPORTED_EFFORTS] + if (model.release_date >= "2025-11-13") { + openaiEfforts.unshift("none") + } + if (model.release_date >= "2025-12-04") { + openaiEfforts.push("xhigh") + } + return Object.fromEntries( + openaiEfforts.map((effort) => [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ]), + ) + + case "@ai-sdk/anthropic": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic + return { + high: { + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + }, + max: { + thinking: { + type: "enabled", + budgetTokens: 31999, + }, + }, + } + + case "@ai-sdk/amazon-bedrock": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock + return Object.fromEntries( + WIDELY_SUPPORTED_EFFORTS.map((effort) => [ + effort, + { + reasoningConfig: { + type: "enabled", + maxReasoningEffort: effort, + }, + }, + ]), + ) + + case "@ai-sdk/google-vertex": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex + case "@ai-sdk/google": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai + if (id.includes("2.5")) { + return { + high: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 16000, + }, + }, + max: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 24576, + }, + }, + } + } + return Object.fromEntries( + ["low", "high"].map((effort) => [ + effort, + { + includeThoughts: true, + thinkingLevel: effort, + }, + ]), + ) + + case "@ai-sdk/mistral": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral + return {} + + case "@ai-sdk/cohere": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cohere + return {} + + case "@ai-sdk/groq": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/groq + const groqEffort = ["none", ...WIDELY_SUPPORTED_EFFORTS] + return Object.fromEntries( + groqEffort.map((effort) => [ + effort, + { + includeThoughts: true, + thinkingLevel: effort, + }, + ]), + ) + + case "@ai-sdk/perplexity": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity + return {} + } + return {} + } + export function options( model: Provider.Model, sessionID: string, @@ -322,6 +478,7 @@ 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 { @@ -335,6 +492,7 @@ export namespace ProviderTransform { return { ["anthropic" as string]: options, } + case "@ai-sdk/google-vertex": case "@ai-sdk/google": return { ["google" as string]: options, diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 45e61d361ac..8bddb910503 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -1,5 +1,5 @@ import { Log } from "@/util/log" -import Bonjour from "bonjour-service" +import { Bonjour } from "bonjour-service" const log = Log.create({ service: "mdns" }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 4508f474b7d..63ef1b647f9 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -63,6 +63,12 @@ globalThis.AI_SDK_LOG_WARNINGS = false export namespace Server { const log = Log.create({ service: "server" }) + let _url: URL | undefined + + export function url(): URL { + return _url ?? new URL("http://localhost:4096") + } + export const Event = { Connected: BusEvent.define("server.connected", z.object({})), Disposed: BusEvent.define("global.disposed", z.object({})), @@ -105,7 +111,23 @@ export namespace Server { timer.stop() } }) - .use(cors()) + .use( + cors({ + origin(input) { + if (!input) return + + if (input.startsWith("http://localhost:")) return input + if (input.startsWith("http://127.0.0.1:")) return input + if (input === "tauri://localhost" || input === "http://tauri.localhost") return input + + // *.opencode.ai (https only, adjust if needed) + if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { + return input + } + return + }, + }), + ) .get( "/global/health", describeRoute({ @@ -1917,7 +1939,7 @@ export namespace Server { "/find/file", describeRoute({ summary: "Find files", - description: "Search for files by name or pattern in the project directory.", + description: "Search for files or directories by name or pattern in the project directory.", operationId: "find.files", responses: { 200: { @@ -1935,15 +1957,20 @@ export namespace Server { z.object({ query: z.string(), dirs: z.enum(["true", "false"]).optional(), + type: z.enum(["file", "directory"]).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), }), ), async (c) => { const query = c.req.valid("query").query const dirs = c.req.valid("query").dirs + const type = c.req.valid("query").type + const limit = c.req.valid("query").limit const results = await File.search({ query, - limit: 10, + limit: limit ?? 10, dirs: dirs !== "false", + type, }) return c.json(results) }, @@ -2867,6 +2894,8 @@ export namespace Server { const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + _url = server.url + const shouldPublishMDNS = opts.mdns && server.port && diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a81aa7db224..0736a1f9eba 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,6 +1,14 @@ import { Provider } from "@/provider/provider" import { Log } from "@/util/log" -import { streamText, wrapLanguageModel, type ModelMessage, type StreamTextResult, type Tool, type ToolSet } from "ai" +import { + streamText, + wrapLanguageModel, + type ModelMessage, + type StreamTextResult, + type Tool, + type ToolSet, + extractReasoningMiddleware, +} from "ai" import { clone, mergeDeep, pipe } from "remeda" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" @@ -74,6 +82,14 @@ export namespace LLM { } const provider = await Provider.getProvider(input.model.providerID) + const variant = input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : undefined + const options = pipe( + ProviderTransform.options(input.model, input.sessionID, provider.options), + mergeDeep(input.small ? ProviderTransform.smallOptions(input.model) : {}), + mergeDeep(input.model.options), + mergeDeep(input.agent.options), + mergeDeep(variant && !variant.disabled ? variant : {}), + ) const params = await Plugin.trigger( "chat.params", @@ -90,13 +106,7 @@ export namespace LLM { : undefined, topP: input.agent.topP ?? ProviderTransform.topP(input.model), topK: ProviderTransform.topK(input.model), - options: pipe( - {}, - mergeDeep(ProviderTransform.options(input.model, input.sessionID, provider.options)), - input.small ? mergeDeep(ProviderTransform.smallOptions(input.model)) : mergeDeep({}), - mergeDeep(input.model.options), - mergeDeep(input.agent.options), - ), + options, }, ) @@ -181,6 +191,7 @@ export namespace LLM { return args.params }, }, + extractReasoningMiddleware({ tagName: "think", startWithReasoning: false }), ], }), experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry }, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index da89a1a0e04..bb78ae64ce6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,8 +1,6 @@ import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import z from "zod" import { NamedError } from "@opencode-ai/util/error" -import { Message } from "./message" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { Identifier } from "../id/id" import { LSP } from "../lsp" @@ -308,6 +306,7 @@ export namespace MessageV2 { }), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + variant: z.string().optional(), }).meta({ ref: "UserMessage", }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f201d9b06dd..a4e6eee3cff 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -90,6 +90,7 @@ export namespace SessionPrompt { noReply: z.boolean().optional(), tools: z.record(z.string(), z.boolean()).optional(), system: z.string().optional(), + variant: z.string().optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -763,6 +764,7 @@ export namespace SessionPrompt { agent: agent.name, model: input.model ?? agent.model ?? (await lastModel(input.sessionID)), system: input.system, + variant: input.variant, } const parts = await Promise.all( @@ -1308,6 +1310,7 @@ export namespace SessionPrompt { model: z.string().optional(), arguments: z.string(), command: z.string(), + variant: z.string().optional(), }) export type CommandInput = z.infer const bashRegex = /!`([^`]+)`/g @@ -1462,6 +1465,7 @@ export namespace SessionPrompt { model, agent: agentName, parts, + variant: input.variant, })) as MessageV2.WithParts Bus.publish(Command.Event.Executed, { diff --git a/packages/opencode/src/session/prompt/codex.txt b/packages/opencode/src/session/prompt/codex.txt index 23952ecdd13..56ff315c1f8 100644 --- a/packages/opencode/src/session/prompt/codex.txt +++ b/packages/opencode/src/session/prompt/codex.txt @@ -240,7 +240,7 @@ You are producing plain text that will later be styled by the CLI. Follow these - Choose descriptive names that fit the content - Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**` - Leave no blank line before the first bullet under a header. -- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer. +- Section headers should only be used where they genuinely improve scannability; avoid fragmenting the answer. **Bullets** @@ -289,7 +289,7 @@ When referencing files in your response, make sure to include the relevant start - Don’t nest bullets or create deep hierarchies. - Don’t output ANSI escape codes directly — the CLI renderer applies them. - Don’t cram unrelated keywords into a single bullet; split for clarity. -- Don’t let keyword lists run long — wrap or reformat for scanability. +- Don’t let keyword lists run long — wrap or reformat for scannability. Generally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable. diff --git a/packages/opencode/src/session/prompt/plan.txt b/packages/opencode/src/session/prompt/plan.txt index 42205626568..7ce6aa7310b 100644 --- a/packages/opencode/src/session/prompt/plan.txt +++ b/packages/opencode/src/session/prompt/plan.txt @@ -12,7 +12,7 @@ is a critical violation. ZERO exceptions. ## Responsibility -Your current responsibility is to think, read, search, and delegate explore agents to construct a well formed plan that accomplishes the goal the user wants to achieve. Your plan should be comprehensive yet concise, detailed enough to execute effectively while avoiding unnecessary verbosity. +Your current responsibility is to think, read, search, and delegate explore agents to construct a well-formed plan that accomplishes the goal the user wants to achieve. Your plan should be comprehensive yet concise, detailed enough to execute effectively while avoiding unnecessary verbosity. ## Clarifying Questions @@ -32,5 +32,5 @@ If you need a user decision or missing information, use the `askquestion` tool t ## Important -The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received. +The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 16fa1d08f6a..fa6fd7e43e7 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -4,6 +4,9 @@ import { Instance } from "../project/instance" import { NamedError } from "@opencode-ai/util/error" import { ConfigMarkdown } from "../config/markdown" import { Log } from "../util/log" +import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" +import { exists } from "fs/promises" export namespace Skill { const log = Log.create({ service: "skill" }) @@ -33,10 +36,9 @@ export namespace Skill { ) const OPENCODE_SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md") - const CLAUDE_SKILL_GLOB = new Bun.Glob(".claude/skills/**/SKILL.md") + const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md") export const state = Instance.state(async () => { - const directories = await Config.directories() const skills: Record = {} const addSkill = async (match: string) => { @@ -64,25 +66,42 @@ export namespace Skill { } } - for (const dir of directories) { - for await (const match of OPENCODE_SKILL_GLOB.scan({ + // Scan .claude/skills/ directories (project-level) + const claudeDirs = await Array.fromAsync( + Filesystem.up({ + targets: [".claude"], + start: Instance.directory, + stop: Instance.worktree, + }), + ) + // Also include global ~/.claude/skills/ + const globalClaude = `${Global.Path.home}/.claude` + if (await exists(globalClaude)) { + claudeDirs.push(globalClaude) + } + + for (const dir of claudeDirs) { + for await (const match of CLAUDE_SKILL_GLOB.scan({ cwd: dir, absolute: true, onlyFiles: true, followSymlinks: true, + dot: true, })) { await addSkill(match) } } - for await (const match of CLAUDE_SKILL_GLOB.scan({ - cwd: Instance.worktree, - absolute: true, - onlyFiles: true, - followSymlinks: true, - dot: true, - })) { - await addSkill(match) + // Scan .opencode/skill/ directories + for (const dir of await Config.directories()) { + for await (const match of OPENCODE_SKILL_GLOB.scan({ + cwd: dir, + absolute: true, + onlyFiles: true, + followSymlinks: true, + })) { + await addSkill(match) + } } return skills diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index e1104350053..dc7d994b623 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -315,7 +315,7 @@ export const BashTool = Tool.define("bash", async () => { } if (timedOut) { - resultMetadata.push(`bash tool terminated commmand after exceeding timeout ${timeout} ms`) + resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) } if (aborted) { diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index a066757c2dc..7af2a6f60dd 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -1,4 +1,4 @@ -Launch a new agent to handle complex, multi-step tasks autonomously. +Launch a new agent to handle complex, multistep tasks autonomously. Available agent types and the tools they have access to: {agents} diff --git a/packages/opencode/src/tool/todowrite.txt b/packages/opencode/src/tool/todowrite.txt index 52c3bfe970b..2737cd18b62 100644 --- a/packages/opencode/src/tool/todowrite.txt +++ b/packages/opencode/src/tool/todowrite.txt @@ -4,7 +4,7 @@ It also helps the user understand the progress of the task and overall progress ## When to Use This Tool Use this tool proactively in these scenarios: -1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions +1. Complex multistep tasks - When a task requires 3 or more distinct steps or actions 2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations 3. User explicitly requests todo list - When the user directly asks you to use the todo list 4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index f6d54a3ed2f..f6966824510 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -11,6 +11,12 @@ await fs.mkdir(dir, { recursive: true }) afterAll(() => { fsSync.rmSync(dir, { recursive: true, force: true }) }) +// Set test home directory to isolate tests from user's actual home directory +// This prevents tests from picking up real user configs/skills from ~/.claude/skills +const testHome = path.join(dir, "home") +await fs.mkdir(testHome, { recursive: true }) +process.env["OPENCODE_TEST_HOME"] = testHome + process.env["XDG_DATA_HOME"] = path.join(dir, "share") process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 1da8105bd83..72415c1411e 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,9 +1,26 @@ import { test, expect } from "bun:test" import { Skill } from "../../src/skill" -import { SystemPrompt } from "../../src/session/system" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import path from "path" +import fs from "fs/promises" + +async function createGlobalSkill(homeDir: string) { + const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") + await fs.mkdir(skillDir, { recursive: true }) + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: global-test-skill +description: A global skill from ~/.claude/skills for testing. +--- + +# Global Test Skill + +This skill is loaded from the global home directory. +`, + ) +} test("discovers skills from .opencode/skill/ directory", async () => { await using tmp = await tmpdir({ @@ -30,9 +47,10 @@ Instructions here. fn: async () => { const skills = await Skill.all() expect(skills.length).toBe(1) - expect(skills[0].name).toBe("test-skill") - expect(skills[0].description).toBe("A test skill for verification.") - expect(skills[0].location).toContain("skill/test-skill/SKILL.md") + const testSkill = skills.find((s) => s.name === "test-skill") + expect(testSkill).toBeDefined() + expect(testSkill!.description).toBe("A test skill for verification.") + expect(testSkill!.location).toContain("skill/test-skill/SKILL.md") }, }) }) @@ -41,15 +59,26 @@ test("discovers multiple skills from .opencode/skill/ directory", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { - const skillDir = path.join(dir, ".opencode", "skill", "my-skill") + const skillDir1 = path.join(dir, ".opencode", "skill", "skill-one") + const skillDir2 = path.join(dir, ".opencode", "skill", "skill-two") await Bun.write( - path.join(skillDir, "SKILL.md"), + path.join(skillDir1, "SKILL.md"), `--- -name: my-skill -description: Another test skill. +name: skill-one +description: First test skill. --- -# My Skill +# Skill One +`, + ) + await Bun.write( + path.join(skillDir2, "SKILL.md"), + `--- +name: skill-two +description: Second test skill. +--- + +# Skill Two `, ) }, @@ -59,8 +88,9 @@ description: Another test skill. directory: tmp.path, fn: async () => { const skills = await Skill.all() - expect(skills.length).toBe(1) - expect(skills[0].name).toBe("my-skill") + expect(skills.length).toBe(2) + expect(skills.find((s) => s.name === "skill-one")).toBeDefined() + expect(skills.find((s) => s.name === "skill-two")).toBeDefined() }, }) }) @@ -89,18 +119,6 @@ Just some content without YAML frontmatter. }) }) -test("returns empty array when no skills exist", async () => { - await using tmp = await tmpdir({ git: true }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const skills = await Skill.all() - expect(skills).toEqual([]) - }, - }) -}) - test("discovers skills from .claude/skills/ directory", async () => { await using tmp = await tmpdir({ git: true, @@ -124,8 +142,44 @@ description: A skill in the .claude/skills directory. fn: async () => { const skills = await Skill.all() expect(skills.length).toBe(1) - expect(skills[0].name).toBe("claude-skill") - expect(skills[0].location).toContain(".claude/skills/claude-skill/SKILL.md") + const claudeSkill = skills.find((s) => s.name === "claude-skill") + expect(claudeSkill).toBeDefined() + expect(claudeSkill!.location).toContain(".claude/skills/claude-skill/SKILL.md") + }, + }) +}) + +test("discovers global skills from ~/.claude/skills/ directory", async () => { + await using tmp = await tmpdir({ git: true }) + + const originalHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + + try { + await createGlobalSkill(tmp.path) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const skills = await Skill.all() + expect(skills.length).toBe(1) + expect(skills[0].name).toBe("global-test-skill") + expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.") + expect(skills[0].location).toContain(".claude/skills/global-test-skill/SKILL.md") + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = originalHome + } +}) + +test("returns empty array when no skills exist", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const skills = await Skill.all() + expect(skills).toEqual([]) }, }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0f60e12e46d..764ad40b9c9 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.209-1", + "version": "1.0.218", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index a72f1ed7d72..23370b8acf0 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -28,6 +28,7 @@ export type PluginInput = { project: Project directory: string worktree: string + serverUrl: URL $: BunShell } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 94b483b4880..ebea43eb466 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.209-1", + "version": "1.0.218", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 55cbe56810a..0d098040b11 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1297,6 +1297,7 @@ export class Session extends HeyApiClient { [key: string]: boolean } system?: string + variant?: string parts?: Array }, options?: Options, @@ -1314,6 +1315,7 @@ export class Session extends HeyApiClient { { in: "body", key: "noReply" }, { in: "body", key: "tools" }, { in: "body", key: "system" }, + { in: "body", key: "variant" }, { in: "body", key: "parts" }, ], }, @@ -1383,6 +1385,7 @@ export class Session extends HeyApiClient { [key: string]: boolean } system?: string + variant?: string parts?: Array }, options?: Options, @@ -1400,6 +1403,7 @@ export class Session extends HeyApiClient { { in: "body", key: "noReply" }, { in: "body", key: "tools" }, { in: "body", key: "system" }, + { in: "body", key: "variant" }, { in: "body", key: "parts" }, ], }, @@ -1431,6 +1435,7 @@ export class Session extends HeyApiClient { model?: string arguments?: string command?: string + variant?: string }, options?: Options, ) { @@ -1446,6 +1451,7 @@ export class Session extends HeyApiClient { { in: "body", key: "model" }, { in: "body", key: "arguments" }, { in: "body", key: "command" }, + { in: "body", key: "variant" }, ], }, ], @@ -1983,13 +1989,15 @@ export class Find extends HeyApiClient { /** * Find files * - * Search for files by name or pattern in the project directory. + * Search for files or directories by name or pattern in the project directory. */ public files( parameters: { directory?: string query: string dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number }, options?: Options, ) { @@ -2001,6 +2009,8 @@ export class Find extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "query" }, { in: "query", key: "dirs" }, + { in: "query", key: "type" }, + { in: "query", key: "limit" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b3bd564ea82..eeae051ed0c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -90,6 +90,7 @@ export type UserMessage = { tools?: { [key: string]: boolean } + variant?: string } export type ProviderAuthError = { @@ -1102,6 +1103,10 @@ export type KeybindsConfig = { * Previous agent */ agent_cycle_reverse?: string + /** + * Cycle model variants + */ + variant_cycle?: string /** * Clear input field */ @@ -1871,6 +1876,11 @@ export type Command = { aliases?: Array } +export type Variant = { + disabled: boolean + [key: string]: unknown | boolean +} + export type Model = { id: string providerID: string @@ -1934,6 +1944,9 @@ export type Model = { [key: string]: string } release_date: string + variants?: { + [key: string]: Variant + } } export type Provider = { @@ -3189,6 +3202,7 @@ export type SessionPromptData = { [key: string]: boolean } system?: string + variant?: string parts: Array } path: { @@ -3372,6 +3386,7 @@ export type SessionPromptAsyncData = { [key: string]: boolean } system?: string + variant?: string parts: Array } path: { @@ -3415,6 +3430,7 @@ export type SessionCommandData = { model?: string arguments: string command: string + variant?: string } path: { /** @@ -3960,6 +3976,8 @@ export type FindFilesData = { directory?: string query: string dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number } url: "/find/file" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3903566b91e..db9e5411899 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2048,6 +2048,9 @@ "system": { "type": "string" }, + "variant": { + "type": "string" + }, "parts": { "type": "array", "items": { @@ -2420,6 +2423,9 @@ "system": { "type": "string" }, + "variant": { + "type": "string" + }, "parts": { "type": "array", "items": { @@ -2541,6 +2547,9 @@ }, "command": { "type": "string" + }, + "variant": { + "type": "string" } }, "required": ["arguments", "command"] @@ -3556,10 +3565,27 @@ "type": "string", "enum": ["true", "false"] } + }, + { + "in": "query", + "name": "type", + "schema": { + "type": "string", + "enum": ["file", "directory"] + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + } } ], "summary": "Find files", - "description": "Search for files by name or pattern in the project directory.", + "description": "Search for files or directories by name or pattern in the project directory.", "responses": { "200": { "description": "File paths", @@ -5265,6 +5291,9 @@ "additionalProperties": { "type": "boolean" } + }, + "variant": { + "type": "string" } }, "required": ["id", "sessionID", "role", "time", "agent", "model"] @@ -7496,6 +7525,11 @@ "default": "shift+tab", "type": "string" }, + "variant_cycle": { + "description": "Cycle model variants", + "default": "ctrl+t", + "type": "string" + }, "input_clear": { "description": "Clear input field", "default": "ctrl+c", @@ -8914,6 +8948,16 @@ }, "required": ["name", "template"] }, + "Variant": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + } + }, + "required": ["disabled"], + "additionalProperties": {} + }, "Model": { "type": "object", "properties": { @@ -9103,6 +9147,15 @@ }, "release_date": { "type": "string" + }, + "variants": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/Variant" + } } }, "required": [ diff --git a/packages/slack/package.json b/packages/slack/package.json index bce53ceb635..374335d0166 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.209-1", + "version": "1.0.218", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index ab89aecb07a..cff26b92013 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.209-1", + "version": "1.0.218", "type": "module", "exports": { "./*": "./src/components/*.tsx", diff --git a/packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..b441202d1ce Binary files /dev/null and b/packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Medium.woff2 b/packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Medium.woff2 new file mode 100644 index 00000000000..d726b57c5c7 Binary files /dev/null and b/packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Medium.woff2 differ diff --git a/packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..8c8a38b91b5 Binary files /dev/null and b/packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/CaskaydiaCoveNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/CaskaydiaCoveNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..3593e5459d8 Binary files /dev/null and b/packages/ui/src/assets/fonts/CaskaydiaCoveNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/CaskaydiaCoveNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/CaskaydiaCoveNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..51c850980c8 Binary files /dev/null and b/packages/ui/src/assets/fonts/CaskaydiaCoveNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/FiraCodeNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/FiraCodeNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..7dad0b8aea0 Binary files /dev/null and b/packages/ui/src/assets/fonts/FiraCodeNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/FiraCodeNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/FiraCodeNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..7134c34f7d4 Binary files /dev/null and b/packages/ui/src/assets/fonts/FiraCodeNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/GeistMonoNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/GeistMonoNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..5346900dc2f Binary files /dev/null and b/packages/ui/src/assets/fonts/GeistMonoNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/GeistMonoNerdFontMono-Medium.woff2 b/packages/ui/src/assets/fonts/GeistMonoNerdFontMono-Medium.woff2 new file mode 100644 index 00000000000..dafeb377952 Binary files /dev/null and b/packages/ui/src/assets/fonts/GeistMonoNerdFontMono-Medium.woff2 differ diff --git a/packages/ui/src/assets/fonts/GeistMonoNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/GeistMonoNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..e8df6f452ac Binary files /dev/null and b/packages/ui/src/assets/fonts/GeistMonoNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/HackNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/HackNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..e883f433088 Binary files /dev/null and b/packages/ui/src/assets/fonts/HackNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/HackNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/HackNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..d4d3c109263 Binary files /dev/null and b/packages/ui/src/assets/fonts/HackNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/InconsolataNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/InconsolataNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..dd2583aafb6 Binary files /dev/null and b/packages/ui/src/assets/fonts/InconsolataNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/InconsolataNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/InconsolataNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..b33b80592ce Binary files /dev/null and b/packages/ui/src/assets/fonts/InconsolataNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/IntoneMonoNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/IntoneMonoNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..a433eb5f6c3 Binary files /dev/null and b/packages/ui/src/assets/fonts/IntoneMonoNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/IntoneMonoNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/IntoneMonoNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..37d5560ba89 Binary files /dev/null and b/packages/ui/src/assets/fonts/IntoneMonoNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/JetBrainsMonoNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/JetBrainsMonoNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..4d671d1db45 Binary files /dev/null and b/packages/ui/src/assets/fonts/JetBrainsMonoNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..5b6a7ec08fe Binary files /dev/null and b/packages/ui/src/assets/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/MesloLGSNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/MesloLGSNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..c301f792207 Binary files /dev/null and b/packages/ui/src/assets/fonts/MesloLGSNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/MesloLGSNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/MesloLGSNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..b855fcda7b9 Binary files /dev/null and b/packages/ui/src/assets/fonts/MesloLGSNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/RobotoMonoNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/RobotoMonoNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..ea83b3deeae Binary files /dev/null and b/packages/ui/src/assets/fonts/RobotoMonoNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/RobotoMonoNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/RobotoMonoNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..750fe71c590 Binary files /dev/null and b/packages/ui/src/assets/fonts/RobotoMonoNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/SauceCodeProNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/SauceCodeProNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..c1b3e4e70bf Binary files /dev/null and b/packages/ui/src/assets/fonts/SauceCodeProNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/SauceCodeProNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/SauceCodeProNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..be8f60a6fbb Binary files /dev/null and b/packages/ui/src/assets/fonts/SauceCodeProNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/UbuntuMonoNerdFontMono-Bold.woff2 b/packages/ui/src/assets/fonts/UbuntuMonoNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..3466efea9ea Binary files /dev/null and b/packages/ui/src/assets/fonts/UbuntuMonoNerdFontMono-Bold.woff2 differ diff --git a/packages/ui/src/assets/fonts/UbuntuMonoNerdFontMono-Regular.woff2 b/packages/ui/src/assets/fonts/UbuntuMonoNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..2132a437b52 Binary files /dev/null and b/packages/ui/src/assets/fonts/UbuntuMonoNerdFontMono-Regular.woff2 differ diff --git a/packages/ui/src/assets/fonts/cascadia-code-nerd-font-bold.woff2 b/packages/ui/src/assets/fonts/cascadia-code-nerd-font-bold.woff2 new file mode 120000 index 00000000000..91c8c42965d --- /dev/null +++ b/packages/ui/src/assets/fonts/cascadia-code-nerd-font-bold.woff2 @@ -0,0 +1 @@ +CaskaydiaCoveNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/cascadia-code-nerd-font.woff2 b/packages/ui/src/assets/fonts/cascadia-code-nerd-font.woff2 new file mode 120000 index 00000000000..f5e93889252 --- /dev/null +++ b/packages/ui/src/assets/fonts/cascadia-code-nerd-font.woff2 @@ -0,0 +1 @@ +CaskaydiaCoveNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/fira-code-nerd-font-bold.woff2 b/packages/ui/src/assets/fonts/fira-code-nerd-font-bold.woff2 new file mode 120000 index 00000000000..40ea06ea1fe --- /dev/null +++ b/packages/ui/src/assets/fonts/fira-code-nerd-font-bold.woff2 @@ -0,0 +1 @@ +FiraCodeNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/fira-code-nerd-font.woff2 b/packages/ui/src/assets/fonts/fira-code-nerd-font.woff2 new file mode 120000 index 00000000000..92b5675aebd --- /dev/null +++ b/packages/ui/src/assets/fonts/fira-code-nerd-font.woff2 @@ -0,0 +1 @@ +FiraCodeNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/geist-mono-bold.woff2 b/packages/ui/src/assets/fonts/geist-mono-bold.woff2 new file mode 120000 index 00000000000..46aa6156756 --- /dev/null +++ b/packages/ui/src/assets/fonts/geist-mono-bold.woff2 @@ -0,0 +1 @@ +GeistMonoNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/geist-mono-medium.woff2 b/packages/ui/src/assets/fonts/geist-mono-medium.woff2 new file mode 120000 index 00000000000..62b2ed3dddd --- /dev/null +++ b/packages/ui/src/assets/fonts/geist-mono-medium.woff2 @@ -0,0 +1 @@ +GeistMonoNerdFontMono-Medium.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/geist-mono.woff2 b/packages/ui/src/assets/fonts/geist-mono.woff2 deleted file mode 100644 index 6c2f194c704..00000000000 Binary files a/packages/ui/src/assets/fonts/geist-mono.woff2 and /dev/null differ diff --git a/packages/ui/src/assets/fonts/geist-mono.woff2 b/packages/ui/src/assets/fonts/geist-mono.woff2 new file mode 120000 index 00000000000..0b57cb92fee --- /dev/null +++ b/packages/ui/src/assets/fonts/geist-mono.woff2 @@ -0,0 +1 @@ +GeistMonoNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/hack-nerd-font-bold.woff2 b/packages/ui/src/assets/fonts/hack-nerd-font-bold.woff2 new file mode 120000 index 00000000000..e5083df956f --- /dev/null +++ b/packages/ui/src/assets/fonts/hack-nerd-font-bold.woff2 @@ -0,0 +1 @@ +HackNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/hack-nerd-font.woff2 b/packages/ui/src/assets/fonts/hack-nerd-font.woff2 new file mode 120000 index 00000000000..935746196c9 --- /dev/null +++ b/packages/ui/src/assets/fonts/hack-nerd-font.woff2 @@ -0,0 +1 @@ +HackNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/ibm-plex-mono-bold.woff2 b/packages/ui/src/assets/fonts/ibm-plex-mono-bold.woff2 new file mode 120000 index 00000000000..f31cff001f1 --- /dev/null +++ b/packages/ui/src/assets/fonts/ibm-plex-mono-bold.woff2 @@ -0,0 +1 @@ +BlexMonoNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/ibm-plex-mono-medium.woff2 b/packages/ui/src/assets/fonts/ibm-plex-mono-medium.woff2 new file mode 120000 index 00000000000..50487e3c28b --- /dev/null +++ b/packages/ui/src/assets/fonts/ibm-plex-mono-medium.woff2 @@ -0,0 +1 @@ +BlexMonoNerdFontMono-Medium.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/ibm-plex-mono.woff2 b/packages/ui/src/assets/fonts/ibm-plex-mono.woff2 deleted file mode 100644 index 2553571d82b..00000000000 Binary files a/packages/ui/src/assets/fonts/ibm-plex-mono.woff2 and /dev/null differ diff --git a/packages/ui/src/assets/fonts/ibm-plex-mono.woff2 b/packages/ui/src/assets/fonts/ibm-plex-mono.woff2 new file mode 120000 index 00000000000..b47b2985309 --- /dev/null +++ b/packages/ui/src/assets/fonts/ibm-plex-mono.woff2 @@ -0,0 +1 @@ +BlexMonoNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/inconsolata-nerd-font-bold.woff2 b/packages/ui/src/assets/fonts/inconsolata-nerd-font-bold.woff2 new file mode 120000 index 00000000000..8842341649a --- /dev/null +++ b/packages/ui/src/assets/fonts/inconsolata-nerd-font-bold.woff2 @@ -0,0 +1 @@ +InconsolataNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/inconsolata-nerd-font.woff2 b/packages/ui/src/assets/fonts/inconsolata-nerd-font.woff2 new file mode 120000 index 00000000000..61f898cab82 --- /dev/null +++ b/packages/ui/src/assets/fonts/inconsolata-nerd-font.woff2 @@ -0,0 +1 @@ +InconsolataNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/intel-one-mono-nerd-font-bold.woff2 b/packages/ui/src/assets/fonts/intel-one-mono-nerd-font-bold.woff2 new file mode 120000 index 00000000000..d0970396de1 --- /dev/null +++ b/packages/ui/src/assets/fonts/intel-one-mono-nerd-font-bold.woff2 @@ -0,0 +1 @@ +IntoneMonoNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/intel-one-mono-nerd-font.woff2 b/packages/ui/src/assets/fonts/intel-one-mono-nerd-font.woff2 new file mode 120000 index 00000000000..ebb75f73480 --- /dev/null +++ b/packages/ui/src/assets/fonts/intel-one-mono-nerd-font.woff2 @@ -0,0 +1 @@ +IntoneMonoNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/jetbrains-mono-nerd-font-bold.woff2 b/packages/ui/src/assets/fonts/jetbrains-mono-nerd-font-bold.woff2 new file mode 120000 index 00000000000..d8f97928647 --- /dev/null +++ b/packages/ui/src/assets/fonts/jetbrains-mono-nerd-font-bold.woff2 @@ -0,0 +1 @@ +JetBrainsMonoNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/jetbrains-mono-nerd-font.woff2 b/packages/ui/src/assets/fonts/jetbrains-mono-nerd-font.woff2 new file mode 120000 index 00000000000..e78e08a9f4e --- /dev/null +++ b/packages/ui/src/assets/fonts/jetbrains-mono-nerd-font.woff2 @@ -0,0 +1 @@ +JetBrainsMonoNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/meslo-lgs-nerd-font-bold.woff2 b/packages/ui/src/assets/fonts/meslo-lgs-nerd-font-bold.woff2 new file mode 120000 index 00000000000..ecccdfb5648 --- /dev/null +++ b/packages/ui/src/assets/fonts/meslo-lgs-nerd-font-bold.woff2 @@ -0,0 +1 @@ +MesloLGSNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/meslo-lgs-nerd-font.woff2 b/packages/ui/src/assets/fonts/meslo-lgs-nerd-font.woff2 new file mode 120000 index 00000000000..83aaf404259 --- /dev/null +++ b/packages/ui/src/assets/fonts/meslo-lgs-nerd-font.woff2 @@ -0,0 +1 @@ +MesloLGSNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/roboto-mono-nerd-font-bold.woff2 b/packages/ui/src/assets/fonts/roboto-mono-nerd-font-bold.woff2 new file mode 120000 index 00000000000..9f250bb52b3 --- /dev/null +++ b/packages/ui/src/assets/fonts/roboto-mono-nerd-font-bold.woff2 @@ -0,0 +1 @@ +RobotoMonoNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/roboto-mono-nerd-font.woff2 b/packages/ui/src/assets/fonts/roboto-mono-nerd-font.woff2 new file mode 120000 index 00000000000..17f3aa403d2 --- /dev/null +++ b/packages/ui/src/assets/fonts/roboto-mono-nerd-font.woff2 @@ -0,0 +1 @@ +RobotoMonoNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/source-code-pro-nerd-font-bold.woff2 b/packages/ui/src/assets/fonts/source-code-pro-nerd-font-bold.woff2 new file mode 120000 index 00000000000..70b65ed06d9 --- /dev/null +++ b/packages/ui/src/assets/fonts/source-code-pro-nerd-font-bold.woff2 @@ -0,0 +1 @@ +SauceCodeProNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/source-code-pro-nerd-font.woff2 b/packages/ui/src/assets/fonts/source-code-pro-nerd-font.woff2 new file mode 120000 index 00000000000..5503219c4df --- /dev/null +++ b/packages/ui/src/assets/fonts/source-code-pro-nerd-font.woff2 @@ -0,0 +1 @@ +SauceCodeProNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/ubuntu-mono-nerd-font-bold.woff2 b/packages/ui/src/assets/fonts/ubuntu-mono-nerd-font-bold.woff2 new file mode 120000 index 00000000000..c4c87b522d8 --- /dev/null +++ b/packages/ui/src/assets/fonts/ubuntu-mono-nerd-font-bold.woff2 @@ -0,0 +1 @@ +UbuntuMonoNerdFontMono-Bold.woff2 \ No newline at end of file diff --git a/packages/ui/src/assets/fonts/ubuntu-mono-nerd-font.woff2 b/packages/ui/src/assets/fonts/ubuntu-mono-nerd-font.woff2 new file mode 120000 index 00000000000..1d58e90fb26 --- /dev/null +++ b/packages/ui/src/assets/fonts/ubuntu-mono-nerd-font.woff2 @@ -0,0 +1 @@ +UbuntuMonoNerdFontMono-Regular.woff2 \ No newline at end of file diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 316795e2ad3..e798139507c 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -98,7 +98,7 @@ [data-slot="dialog-description"] { display: flex; padding: 16px; - padding-left: 20px; + padding-left: 24px; padding-top: 0; margin-top: -8px; justify-content: space-between; diff --git a/packages/ui/src/components/font.tsx b/packages/ui/src/components/font.tsx index b69139c0fea..7e4b77b1cf6 100644 --- a/packages/ui/src/components/font.tsx +++ b/packages/ui/src/components/font.tsx @@ -1,6 +1,106 @@ import { Style, Link } from "@solidjs/meta" import inter from "../assets/fonts/inter.woff2" -import ibmPlexMono from "../assets/fonts/ibm-plex-mono.woff2" +import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" +import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" +import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2" + +import cascadiaCode from "../assets/fonts/cascadia-code-nerd-font.woff2" +import cascadiaCodeBold from "../assets/fonts/cascadia-code-nerd-font-bold.woff2" +import firaCode from "../assets/fonts/fira-code-nerd-font.woff2" +import firaCodeBold from "../assets/fonts/fira-code-nerd-font-bold.woff2" +import hack from "../assets/fonts/hack-nerd-font.woff2" +import hackBold from "../assets/fonts/hack-nerd-font-bold.woff2" +import inconsolata from "../assets/fonts/inconsolata-nerd-font.woff2" +import inconsolataBold from "../assets/fonts/inconsolata-nerd-font-bold.woff2" +import intelOneMono from "../assets/fonts/intel-one-mono-nerd-font.woff2" +import intelOneMonoBold from "../assets/fonts/intel-one-mono-nerd-font-bold.woff2" +import jetbrainsMono from "../assets/fonts/jetbrains-mono-nerd-font.woff2" +import jetbrainsMonoBold from "../assets/fonts/jetbrains-mono-nerd-font-bold.woff2" +import mesloLgs from "../assets/fonts/meslo-lgs-nerd-font.woff2" +import mesloLgsBold from "../assets/fonts/meslo-lgs-nerd-font-bold.woff2" +import robotoMono from "../assets/fonts/roboto-mono-nerd-font.woff2" +import robotoMonoBold from "../assets/fonts/roboto-mono-nerd-font-bold.woff2" +import sourceCodePro from "../assets/fonts/source-code-pro-nerd-font.woff2" +import sourceCodeProBold from "../assets/fonts/source-code-pro-nerd-font-bold.woff2" +import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2" +import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2" + +type MonoFont = { + family: string + regular: string + bold: string +} + +export const MONO_NERD_FONTS = [ + { + family: "JetBrains Mono Nerd Font", + regular: jetbrainsMono, + bold: jetbrainsMonoBold, + }, + { + family: "Fira Code Nerd Font", + regular: firaCode, + bold: firaCodeBold, + }, + { + family: "Cascadia Code Nerd Font", + regular: cascadiaCode, + bold: cascadiaCodeBold, + }, + { + family: "Hack Nerd Font", + regular: hack, + bold: hackBold, + }, + { + family: "Source Code Pro Nerd Font", + regular: sourceCodePro, + bold: sourceCodeProBold, + }, + { + family: "Inconsolata Nerd Font", + regular: inconsolata, + bold: inconsolataBold, + }, + { + family: "Roboto Mono Nerd Font", + regular: robotoMono, + bold: robotoMonoBold, + }, + { + family: "Ubuntu Mono Nerd Font", + regular: ubuntuMono, + bold: ubuntuMonoBold, + }, + { + family: "Intel One Mono Nerd Font", + regular: intelOneMono, + bold: intelOneMonoBold, + }, + { + family: "Meslo LGS Nerd Font", + regular: mesloLgs, + bold: mesloLgsBold, + }, +] satisfies MonoFont[] + +const monoNerdCss = MONO_NERD_FONTS.map( + (font) => ` + @font-face { + font-family: "${font.family}"; + src: url("${font.regular}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "${font.family}"; + src: url("${font.bold}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 700; + }`, +).join("") export const Font = () => { return ( @@ -23,10 +123,24 @@ export const Font = () => { } @font-face { font-family: "IBM Plex Mono"; - src: url("${ibmPlexMono}") format("woff2-variations"); + src: url("${ibmPlexMonoRegular}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "IBM Plex Mono"; + src: url("${ibmPlexMonoMedium}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 500; + } + @font-face { + font-family: "IBM Plex Mono"; + src: url("${ibmPlexMonoBold}") format("woff2"); font-display: swap; font-style: normal; - font-weight: 400 700; + font-weight: 700; } @font-face { font-family: "IBM Plex Mono Fallback"; @@ -36,9 +150,10 @@ export const Font = () => { descent-override: 25%; line-gap-override: 1%; } +${monoNerdCss} `} - + ) } diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 852bf486c91..651f5ef971e 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -53,6 +53,8 @@ } > [data-component="icon-button"] { + width: 20px; + height: 20px; background-color: transparent; &:hover:not(:disabled), @@ -185,11 +187,24 @@ letter-spacing: var(--letter-spacing-normal); [data-slot="list-item-selected-icon"] { - color: var(--icon-strong-base); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + aspect-ratio: 1/1; + [data-component="icon"] { + color: var(--icon-strong-base); + } } [data-slot="list-item-active-icon"] { display: none; - color: var(--icon-strong-base); + align-items: center; + justify-content: center; + flex-shrink: 0; + aspect-ratio: 1/1; + [data-component="icon"] { + color: var(--icon-strong-base); + } } [data-slot="list-item-extra-icon"] { @@ -201,7 +216,7 @@ border-radius: var(--radius-md); background: var(--surface-raised-base-hover); [data-slot="list-item-active-icon"] { - display: block; + display: inline-flex; } [data-slot="list-item-extra-icon"] { display: block !important; diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 60331b78675..dbed4da3059 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -208,10 +208,16 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) > {props.children(item)} - + + + - {(icon) => } + {(icon) => ( + + + + )} )} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 4338940cb56..c34a76e6d89 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -377,6 +377,18 @@ } } +[data-component="user-message"] [data-slot="user-message-text"], +[data-component="text-part"], +[data-component="reasoning-part"], +[data-component="tool-error"], +[data-component="tool-output"], +[data-component="edit-content"], +[data-component="write-content"], +[data-component="todos"], +[data-component="diagnostics"] { + user-select: text; +} + [data-component="tool-part-wrapper"] { width: 100%; diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index 35df4a80f50..15a0447b2cc 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -65,6 +65,10 @@ } } + [data-slot="accordion-content"] { + user-select: text; + } + [data-slot="session-review-trigger-content"] { display: flex; align-items: center; diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index fff3385245d..a86b6de7d8b 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -116,6 +116,11 @@ color: var(--text-weak); } + [data-slot="session-turn-markdown"], + [data-slot="session-turn-accordion"] [data-slot="accordion-content"] { + user-select: text; + } + [data-slot="session-turn-markdown"] { &[data-diffs="true"] { font-size: 15px; diff --git a/packages/util/package.json b/packages/util/package.json index 9a3461cb006..7df95a5ad81 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.209-1", + "version": "1.0.218", "private": true, "type": "module", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 79e5e7f046e..8e86155a9ba 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.209-1", + "version": "1.0.218", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index 2c0687b8ea5..137d3be1ee1 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -30,6 +30,7 @@ OpenCode comes with several built-in formatters for popular languages and framew | ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file | | terraform | .tf, .tfvars | `terraform` command available | | gleam | .gleam | `gleam` command available | +| nixfmt | .nix | `nixfmt` command available | | shfmt | .sh, .bash | `shfmt` command available | | oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) | diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 4f1b09d3f1b..04d03d0d849 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -118,6 +118,29 @@ You can disable a keybind by adding the key to your config with a value of "none --- +## Desktop prompt shortcuts + +The OpenCode desktop app prompt input supports common Readline/Emacs-style shortcuts for editing text. These are built-in and currently not configurable via `opencode.json`. + +| Shortcut | Action | +| -------- | ---------------------------------------- | +| `ctrl+a` | Move to start of current line | +| `ctrl+e` | Move to end of current line | +| `ctrl+b` | Move cursor back one character | +| `ctrl+f` | Move cursor forward one character | +| `alt+b` | Move cursor back one word | +| `alt+f` | Move cursor forward one word | +| `ctrl+d` | Delete character under cursor | +| `ctrl+k` | Kill to end of line | +| `ctrl+u` | Kill to start of line | +| `ctrl+w` | Kill previous word | +| `alt+d` | Kill next word | +| `ctrl+y` | Yank (paste) last killed text | +| `ctrl+t` | Transpose characters | +| `ctrl+g` | Cancel popovers / abort running response | + +--- + ## Shift+Enter Some terminals don't send modifier keys with Enter by default. You may need to configure your terminal to send `Shift+Enter` as an escape sequence. diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index 230f782d313..a4232a70e74 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -31,6 +31,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | ocaml-lsp | .ml, .mli | `ocamllsp` command available | | oxlint | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue, .astro, .svelte | `oxlint` dependency in project | | php intelephense | .php | Auto-installs for PHP projects | +| prisma | .prisma | `prisma` command available | | pyright | .py, .pyi | `pyright` dependency installed | | ruby-lsp (rubocop) | .rb, .rake, .gemspec, .ru | `ruby` and `gem` commands available | | rust | .rs | `rust-analyzer` command available | diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index ef2f1338d09..59a7010833d 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -230,6 +230,10 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr We are using `osascript` to run AppleScript on macOS. Here we are using it to send notifications. +:::note +If you’re using the OpenCode desktop app, it can send system notifications automatically when a response is ready or when a session errors. +::: + --- ### .env protection diff --git a/packages/web/src/content/docs/sdk.mdx b/packages/web/src/content/docs/sdk.mdx index 1ff84310325..5fe738407c9 100644 --- a/packages/web/src/content/docs/sdk.mdx +++ b/packages/web/src/content/docs/sdk.mdx @@ -283,13 +283,19 @@ await client.session.prompt({ ### Files -| Method | Description | Response | -| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------- | -| `find.text({ query })` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` | -| `find.files({ query })` | Find files by name | `string[]` (file paths) | -| `find.symbols({ query })` | Find workspace symbols | Symbol[] | -| `file.read({ query })` | Read a file | `{ type: "raw" \| "patch", content: string }` | -| `file.status({ query? })` | Get status for tracked files | File[] | +| Method | Description | Response | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------- | +| `find.text({ query })` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` | +| `find.files({ query })` | Find files and directories by name | `string[]` (paths) | +| `find.symbols({ query })` | Find workspace symbols | Symbol[] | +| `file.read({ query })` | Read a file | `{ type: "raw" \| "patch", content: string }` | +| `file.status({ query? })` | Get status for tracked files | File[] | + +`find.files` supports a few optional query fields: + +- `type`: `"file"` or `"directory"` +- `directory`: override the project root for the search +- `limit`: max results (1–200) --- @@ -302,7 +308,11 @@ const textResults = await client.find.text({ }) const files = await client.find.files({ - query: { query: "*.ts" }, + query: { query: "*.ts", type: "file" }, +}) + +const directories = await client.find.files({ + query: { query: "packages", type: "directory", limit: 20 }, }) const content = await client.file.read({ diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx index c63917f792e..2568ade35cb 100644 --- a/packages/web/src/content/docs/server.mdx +++ b/packages/web/src/content/docs/server.mdx @@ -173,14 +173,22 @@ The opencode server exposes the following APIs. ### Files -| Method | Path | Description | Response | -| ------ | ------------------------ | ---------------------------- | ------------------------------------------------------------------------------------------- | -| `GET` | `/find?pattern=` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` | -| `GET` | `/find/file?query=` | Find files by name | `string[]` (file paths) | -| `GET` | `/find/symbol?query=` | Find workspace symbols | Symbol[] | -| `GET` | `/file?path=` | List files and directories | FileNode[] | -| `GET` | `/file/content?path=

` | Read a file | FileContent | -| `GET` | `/file/status` | Get status for tracked files | File[] | +| Method | Path | Description | Response | +| ------ | ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------- | +| `GET` | `/find?pattern=` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` | +| `GET` | `/find/file?query=` | Find files and directories by name | `string[]` (paths) | +| `GET` | `/find/symbol?query=` | Find workspace symbols | Symbol[] | +| `GET` | `/file?path=` | List files and directories | FileNode[] | +| `GET` | `/file/content?path=

` | Read a file | FileContent | +| `GET` | `/file/status` | Get status for tracked files | File[] | + +#### `/find/file` query parameters + +- `query` (required) — search string (fuzzy match) +- `type` (optional) — limit results to `"file"` or `"directory"` +- `directory` (optional) — override the project root for the search +- `limit` (optional) — max results (1–200) +- `dirs` (optional) — legacy flag (`"false"` returns only files) --- diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index c559c067c4c..09519e2be65 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -15,7 +15,8 @@ OpenCode searches these locations: - Project config: `.opencode/skill//SKILL.md` - Global config: `~/.config/opencode/skill//SKILL.md` -- Claude-compatible: `.claude/skills//SKILL.md` +- Project Claude-compatible: `.claude/skills//SKILL.md` +- Global Claude-compatible: `~/.claude/skills//SKILL.md` --- @@ -24,7 +25,7 @@ OpenCode searches these locations: 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. -Global definitions are also loaded from `~/.config/opencode/skill/*/SKILL.md`. +Global definitions are also loaded from `~/.config/opencode/skill/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. --- diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 90268ee68f5..4b81371a9f9 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -235,7 +235,7 @@ Share current session. [Learn more](/docs/share). List available themes. ```bash frame="none" -/themes +/theme ``` **Keybind:** `ctrl+x t` diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 5cb10f97669..891027aee0b 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -156,6 +156,26 @@ The free models: --- +### Auto-reload + +If your balance goes below $5, Zen will automatically reload $20 (plus $1.23 +processing fee). + +You can change the auto-reload amount. You can also disable auto-reload entirely. + +--- + +### Monthly limits + +You can also set a monthly usage limit for the entire workspace and for each +member of your team. + +For example, let's say you set a monthly usage limit to $20, Zen will not use +more than $20 in a month. But if you have auto-reload enabled, Zen might end up +charging you more than $20 if your balance goes below $5. + +--- + ## Privacy All our models are hosted in the US. Our providers follow a zero-retention policy and do not use your data for model training, with the following exceptions: diff --git a/script/changelog.ts b/script/changelog.ts index e1b91cff8a5..9e3b3c692e2 100755 --- a/script/changelog.ts +++ b/script/changelog.ts @@ -86,7 +86,29 @@ export async function getCommits(from: string, to: string): Promise { }) } - return commits + return filterRevertedCommits(commits) +} + +function filterRevertedCommits(commits: Commit[]): Commit[] { + const revertPattern = /^Revert "(.+)"$/ + const seen = new Map() + + for (const commit of commits) { + const match = commit.message.match(revertPattern) + if (match) { + // It's a revert - remove the original if we've seen it + const original = match[1]! + if (seen.has(original)) seen.delete(original) + else seen.set(commit.message, commit) // Keep revert if original not in range + } else { + // Regular commit - remove if its revert exists, otherwise add + const revertMsg = `Revert "${commit.message}"` + if (seen.has(revertMsg)) seen.delete(revertMsg) + else seen.set(commit.message, commit) + } + } + + return [...seen.values()] } const sections = { @@ -110,15 +132,12 @@ function getSection(areas: Set): string { return "Core" } -async function summarizeCommit( - opencode: Awaited>, - sessionId: string, - message: string, -): Promise { +async function summarizeCommit(opencode: Awaited>, message: string): Promise { console.log("summarizing commit:", message) + const session = await opencode.client.session.create() const result = await opencode.client.session .prompt({ - path: { id: sessionId }, + path: { id: session.data!.id }, body: { model: { providerID: "opencode", modelID: "claude-sonnet-4-5" }, tools: { @@ -140,15 +159,21 @@ Commit: ${message}`, } export async function generateChangelog(commits: Commit[], opencode: Awaited>) { - const session = await opencode.client.session.create() + // Summarize commits in parallel with max 10 concurrent requests + const BATCH_SIZE = 10 + const summaries: string[] = [] + for (let i = 0; i < commits.length; i += BATCH_SIZE) { + const batch = commits.slice(i, i + BATCH_SIZE) + const results = await Promise.all(batch.map((c) => summarizeCommit(opencode, c.message))) + summaries.push(...results) + } const grouped = new Map() - - for (const commit of commits) { + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]! const section = getSection(commit.areas) - const summary = await summarizeCommit(opencode, session.data!.id, commit.message) const attribution = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : "" - const entry = `- ${summary}${attribution}` + const entry = `- ${summaries[i]}${attribution}` if (!grouped.has(section)) grouped.set(section, []) grouped.get(section)!.push(entry) diff --git a/script/sync-zed.ts b/script/sync-zed.ts index 4bbf845462c..151bfe23ce7 100755 --- a/script/sync-zed.ts +++ b/script/sync-zed.ts @@ -15,6 +15,9 @@ async function main() { const token = process.env.ZED_EXTENSIONS_PAT if (!token) throw new Error("ZED_EXTENSIONS_PAT environment variable required") + const prToken = process.env.ZED_PR_PAT + if (!prToken) throw new Error("ZED_PR_PAT environment variable required") + const cleanVersion = version.replace(/^v/, "") console.log(`📦 Syncing Zed extension for version ${cleanVersion}`) @@ -106,11 +109,17 @@ async function main() { await $`git push https://x-access-token:${token}@github.com/${FORK_REPO}.git ${branchName}` console.log(`📬 Creating pull request...`) - const prUrl = + const prResult = await $`gh pr create --repo ${UPSTREAM_REPO} --base main --head ${FORK_REPO.split("/")[0]}:${branchName} --title "Update ${EXTENSION_NAME} to v${cleanVersion}" --body "Updating OpenCode extension to v${cleanVersion}"` - .env({ GH_TOKEN: token }) - .text() + .env({ ...process.env, GH_TOKEN: prToken }) + .nothrow() + + if (prResult.exitCode !== 0) { + console.error("stderr:", prResult.stderr.toString()) + throw new Error(`Failed with exit code ${prResult.exitCode}`) + } + const prUrl = prResult.stdout.toString().trim() console.log(`✅ Pull request created: ${prUrl}`) console.log(`🎉 Done!`) } diff --git a/script/sync/fork-features.json b/script/sync/fork-features.json index 3810cdaca08..303ea36fc14 100644 --- a/script/sync/fork-features.json +++ b/script/sync/fork-features.json @@ -1,7 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "Fork-specific features from upstream PRs that must be preserved during merges", - "lastUpdated": "2025-12-29", + "lastUpdated": "2025-12-30", + "lastChange": "Removed Open Project (uses native file picker, doesn't work in web). Add Project dialog now the single entry point.", "note": "Replaced PR 5563 (Ask Tool) with PR 5958 (askquestion tool) which fixes race conditions and improves UX", "forkDependencies": { "description": "NPM dependencies added by fork features that MUST be preserved during package.json merges. These are frequently lost when accepting upstream version bumps.", @@ -31,6 +32,34 @@ "reason": "Buggy implementation - removed from fork", "filesRemoved": ["packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx"], "configRemoved": ["sessions_sidebar_toggle keybind"] + }, + { + "pr": "upstream", + "title": "Custom server URL settings dialog", + "author": "fork", + "removedDate": "2025-12-30", + "reason": "Upstream now provides DialogSelectServer with equivalent functionality. Simplified to align with upstream while maintaining shuv.ai domain support.", + "filesRemoved": [ + "packages/app/src/components/dialog-server-settings.tsx", + "packages/app/src/lib/server-url.ts" + ], + "note": "Server URL handling simplified in app.tsx to match upstream. shuv.ai domains are supported alongside opencode.ai." + }, + { + "pr": "upstream", + "title": "Open Project button and command", + "author": "upstream", + "removedDate": "2025-12-30", + "reason": "Upstream's Open Project uses native file picker which doesn't work in web browsers. Our Add Project dialog (DialogCreateProject) with folder browser provides better web app experience.", + "filesRemoved": [ + "packages/app/src/components/dialog-select-directory.tsx" + ], + "codeRemoved": [ + "chooseProject() function in layout.tsx and home.tsx", + "project.open command with mod+o keybind", + "Open project sidebar button" + ], + "note": "Users should use Add Project button which opens DialogCreateProject with folder browser, git clone, and create new project tabs." } ], "apiDependencies": [ @@ -886,11 +915,11 @@ }, { "pr": 0, - "title": "Add existing project dialog with folder browser", + "title": "Add project dialog with folder browser (replaces Open Project)", "author": "fork", "status": "fork-only", - "description": "Redesigned create project dialog with tabbed interface: Add Existing tab for browsing/searching folders from $HOME, Create New tab for path input. Uses List component for keyboard navigation and shows git/added badges.", - "files": ["packages/app/src/components/dialog-create-project.tsx"], + "description": "Redesigned project dialog with tabbed interface: Add Existing tab for browsing/searching folders from $HOME, Create New tab for path input, Git Clone tab for cloning repos. Uses List component for keyboard navigation and shows git/added badges. This replaces upstream's Open Project functionality which uses native file picker (doesn't work in web browsers).", + "files": ["packages/app/src/components/dialog-create-project.tsx", "packages/app/src/pages/home.tsx", "packages/app/src/pages/layout.tsx"], "criticalCode": [ { "file": "packages/app/src/components/dialog-create-project.tsx", @@ -922,6 +951,16 @@ "file": "packages/app/src/components/dialog-create-project.tsx", "description": "Dialog uses lg size for more folder visibility", "markers": ["

"] + }, + { + "file": "packages/app/src/pages/home.tsx", + "description": "Single Add project button on home page (replaces Create+Open buttons)", + "markers": ["addProject()", "Add project"] + }, + { + "file": "packages/app/src/pages/layout.tsx", + "description": "Single Add project button in sidebar (replaces Create+Open buttons)", + "markers": ["Add project", "createProject"] } ] }, @@ -1315,43 +1354,7 @@ } ] }, - { - "pr": 0, - "title": "Custom server URL settings", - "author": "fork", - "status": "fork-only", - "description": "Desktop app settings dialog for configuring custom API server URL. Includes URL validation, persistence to localStorage, and server URL configuration on error pages for recovery from connection failures.", - "files": [ - "packages/app/src/components/dialog-server-settings.tsx", - "packages/app/src/lib/server-url.ts", - "packages/app/src/pages/error.tsx", - "packages/app/src/app.tsx", - "packages/app/src/context/command.tsx", - "packages/app/src/pages/layout.tsx" - ], - "criticalCode": [ - { - "file": "packages/app/src/lib/server-url.ts", - "description": "Server URL management with localStorage persistence and validation", - "markers": ["STORAGE_KEY", "getServerUrl", "setServerUrl", "validateServerUrl"] - }, - { - "file": "packages/app/src/components/dialog-server-settings.tsx", - "description": "Settings dialog with URL input, validation feedback, and reset option", - "markers": ["DialogServerSettings", "validateUrl", "handleSave", "handleReset"] - }, - { - "file": "packages/app/src/pages/error.tsx", - "description": "Server URL configuration on error page for connection failure recovery", - "markers": ["serverUrl", "setServerUrl", "Try Again"] - }, - { - "file": "packages/app/src/context/command.tsx", - "description": "Settings command in command palette to open server settings dialog", - "markers": ["id: \"settings\"", "Settings", "DialogServerSettings"] - } - ] - }, + { "pr": 0, "title": "Create project dialog with Git Clone tab", @@ -1432,6 +1435,64 @@ } ] }, + { + "pr": 0, + "title": "Desktop Tauri shuvcode branding", + "author": "fork", + "status": "fork-only", + "description": "Rebrand desktop Tauri app to shuvcode. Package renamed to shuvcode-desktop, Rust lib to shuvcode_lib. Window API uses window.__SHUVCODE__ with __OPENCODE__ fallback. Environment variables prioritize SHUVCODE_* prefix (SHUVCODE_PORT, SHUVCODE_ALLOW_WAYLAND, SHUVCODE_CLIENT, SHUVCODE_EXPERIMENTAL_ICON_DISCOVERY) with OPENCODE_* fallback for backwards compatibility.", + "files": [ + "packages/desktop/package.json", + "packages/desktop/src-tauri/Cargo.toml", + "packages/desktop/src-tauri/Cargo.lock", + "packages/desktop/src-tauri/src/lib.rs", + "packages/desktop/src-tauri/src/main.rs", + "packages/desktop/src/updater.ts", + "packages/app/src/app.tsx" + ], + "criticalCode": [ + { + "file": "packages/desktop/src-tauri/Cargo.toml", + "description": "Package and lib renamed to shuvcode-desktop and shuvcode_lib", + "markers": ["name = \"shuvcode-desktop\"", "name = \"shuvcode_lib\""] + }, + { + "file": "packages/desktop/src-tauri/src/lib.rs", + "description": "Window API initialization with __SHUVCODE__ and __OPENCODE__ fallback", + "markers": ["window.__SHUVCODE__", "window.__OPENCODE__ ??= window.__SHUVCODE__"] + }, + { + "file": "packages/desktop/src-tauri/src/lib.rs", + "description": "SHUVCODE_PORT env var with OPENCODE_PORT fallback", + "markers": ["option_env!(\"SHUVCODE_PORT\")", "option_env!(\"OPENCODE_PORT\")"] + }, + { + "file": "packages/desktop/src-tauri/src/lib.rs", + "description": "Sidecar env vars for shuvcode branding with opencode fallback", + "markers": ["SHUVCODE_EXPERIMENTAL_ICON_DISCOVERY", "SHUVCODE_CLIENT"] + }, + { + "file": "packages/desktop/src-tauri/src/main.rs", + "description": "SHUVCODE_ALLOW_WAYLAND env var with OC_ALLOW_WAYLAND fallback", + "markers": ["SHUVCODE_ALLOW_WAYLAND", "OC_ALLOW_WAYLAND"] + }, + { + "file": "packages/app/src/app.tsx", + "description": "Window.__SHUVCODE__ interface with port/updaterEnabled", + "markers": ["window.__SHUVCODE__?: { updaterEnabled?: boolean; port?: number }"] + }, + { + "file": "packages/app/src/app.tsx", + "description": "Port resolution chain: __SHUVCODE__ -> __OPENCODE__ -> env -> location", + "markers": ["window.__SHUVCODE__?.port ??", "window.__OPENCODE__?.port ??"] + }, + { + "file": "packages/desktop/src/updater.ts", + "description": "Updater enabled flag checks __SHUVCODE__ first", + "markers": ["window.__SHUVCODE__?.updaterEnabled ?? window.__OPENCODE__?.updaterEnabled"] + } + ] + }, { "pr": 0, "title": "File line range syntax support", diff --git a/scripts/analyze-first-time-contributors.sh b/scripts/analyze-first-time-contributors.sh new file mode 100755 index 00000000000..0a3a1e3c829 --- /dev/null +++ b/scripts/analyze-first-time-contributors.sh @@ -0,0 +1,249 @@ +#!/bin/bash + +# First-Time Contributor Analyzer +# Analyzes PRs from first-time contributors over the last 4 weeks +# Usage: ./scripts/analyze-first-time-contributors.sh + +set -euo pipefail + +REPO="sst/opencode" +GITHUB_API="https://api.github.com/repos" +FOUR_WEEKS_AGO=$(date -u -v-28d '+%Y-%m-%dT00:00:00Z' 2>/dev/null || date -u -d '4 weeks ago' '+%Y-%m-%dT00:00:00Z') + +echo "Analyzing first-time contributors from last 4 weeks..." +echo "Start date: $FOUR_WEEKS_AGO" +echo "" + +# Create temp files +TEMP_PRS=$(mktemp) +TEMP_CONTRIBUTORS=$(mktemp) +trap "rm -f $TEMP_PRS $TEMP_CONTRIBUTORS" EXIT + +# Fetch all PRs from the last 4 weeks +echo "Fetching PRs..." +ALL_PRS="[]" +for page in {1..10}; do + echo " Page $page..." + PAGE_DATA=$(curl -s "${GITHUB_API}/${REPO}/pulls?state=all&sort=created&direction=desc&per_page=100&page=${page}") + + COUNT=$(echo "$PAGE_DATA" | jq 'length') + if [ "$COUNT" -eq 0 ]; then + break + fi + + FILTERED=$(echo "$PAGE_DATA" | jq "[.[] | select(.created_at >= \"${FOUR_WEEKS_AGO}\")]") + ALL_PRS=$(echo "$ALL_PRS" "$FILTERED" | jq -s '.[0] + .[1]') + + OLDEST=$(echo "$PAGE_DATA" | jq -r '.[-1].created_at') + if [[ "$OLDEST" < "$FOUR_WEEKS_AGO" ]]; then + break + fi +done + +echo "$ALL_PRS" > "$TEMP_PRS" +PR_COUNT=$(jq 'length' "$TEMP_PRS") +echo " Found $PR_COUNT PRs" + +echo "" +echo "Checking contributor status for each PR..." + +# Get contributors list (people with previous PRs) +# For each PR, check if the author has "first-time contributor" label or +# if this is their first PR to the repo + +# Extract PR data with author info +jq -r '.[] | "\(.number)|\(.user.login)|\(.created_at)|\(.author_association)"' "$TEMP_PRS" > "$TEMP_CONTRIBUTORS" + +echo "" + +# Analyze with Python +PYTHON_SCRIPT=$(mktemp) +trap "rm -f $PYTHON_SCRIPT $TEMP_PRS $TEMP_CONTRIBUTORS" EXIT + +cat > "$PYTHON_SCRIPT" << 'EOF' +import json +import sys +from datetime import datetime +from collections import defaultdict + +# Read PR data +pr_data = [] +with open(sys.argv[1], 'r') as f: + for line in f: + if line.strip(): + parts = line.strip().split('|') + pr_data.append({ + 'number': parts[0], + 'author': parts[1], + 'created_at': parts[2], + 'author_association': parts[3] + }) + +print(f"Analyzing {len(pr_data)} PRs...\n") + +# Categorize by week +def get_week_label(date_str): + date = datetime.fromisoformat(date_str.replace('Z', '+00:00')).replace(tzinfo=None) + + if date >= datetime(2025, 12, 22): + return "Week 51: Dec 22-26" + elif date >= datetime(2025, 12, 15): + return "Week 50: Dec 15-21" + elif date >= datetime(2025, 12, 8): + return "Week 49: Dec 8-14" + elif date >= datetime(2025, 12, 1): + return "Week 48: Dec 1-7" + else: + return "Earlier" + +# First-time contributors have author_association of "FIRST_TIME_CONTRIBUTOR" or "NONE" +# or sometimes "CONTRIBUTOR" for their first few PRs + +by_week = defaultdict(lambda: { + 'total': 0, + 'first_time': 0, + 'returning': 0, + 'first_time_authors': set() +}) + +all_authors = defaultdict(int) + +for pr in pr_data: + week = get_week_label(pr['created_at']) + author = pr['author'] + assoc = pr['author_association'] + + by_week[week]['total'] += 1 + all_authors[author] += 1 + + # GitHub marks first-time contributors explicitly + # FIRST_TIME_CONTRIBUTOR = first PR to this repo + # NONE = no association (could be first time) + # For more accuracy, we check if author appears only once in our dataset + + if assoc == 'FIRST_TIME_CONTRIBUTOR' or (assoc == 'NONE' and all_authors[author] == 1): + by_week[week]['first_time'] += 1 + by_week[week]['first_time_authors'].add(author) + else: + by_week[week]['returning'] += 1 + +# Print results +print("="*90) +print("FIRST-TIME CONTRIBUTOR ANALYSIS - LAST 4 WEEKS") +print("="*90 + "\n") + +weeks = ["Week 48: Dec 1-7", "Week 49: Dec 8-14", "Week 50: Dec 15-21", "Week 51: Dec 22-26"] + +print("PRs by Contributor Type:\n") +for week in weeks: + if week in by_week: + data = by_week[week] + total = data['total'] + first_time = data['first_time'] + returning = data['returning'] + first_time_pct = (first_time / total * 100) if total > 0 else 0 + + print(f"{week}: {total} PRs") + print(f" ✨ First-time contributors: {first_time} ({first_time_pct:.1f}%)") + print(f" ↩️ Returning contributors: {returning} ({100-first_time_pct:.1f}%)") + print() + +# Overall summary +total_prs = sum(data['total'] for data in by_week.values()) +total_first_time = sum(data['first_time'] for data in by_week.values()) +total_returning = sum(data['returning'] for data in by_week.values()) +overall_first_time_pct = (total_first_time / total_prs * 100) if total_prs > 0 else 0 + +print("="*90) +print("OVERALL SUMMARY") +print("="*90 + "\n") + +print(f"Total PRs (4 weeks): {total_prs}") +print(f"From first-time contributors: {total_first_time} ({overall_first_time_pct:.1f}%)") +print(f"From returning contributors: {total_returning} ({100-overall_first_time_pct:.1f}%)") + +# Count unique first-time contributors +all_first_time_authors = set() +for data in by_week.values(): + all_first_time_authors.update(data['first_time_authors']) + +print(f"\nUnique first-time contributors: {len(all_first_time_authors)}") + +# Week by week trend +print("\n" + "="*90) +print("TREND ANALYSIS") +print("="*90 + "\n") + +print("First-Time Contributor Rate by Week:\n") +for week in weeks: + if week in by_week: + data = by_week[week] + rate = (data['first_time'] / data['total'] * 100) if data['total'] > 0 else 0 + bar = "█" * int(rate / 2) + print(f" {week}: {rate:5.1f}% {bar}") + +print("\n" + "="*90) +print("KEY INSIGHTS") +print("="*90 + "\n") + +insights = [] + +if total_first_time > 0: + insights.append( + f"1. New Contributors: {total_first_time} PRs from first-timers shows healthy\n" + + f" community growth and welcoming environment for new contributors." + ) + +if overall_first_time_pct > 20: + insights.append( + f"2. High New Contributor Rate: {overall_first_time_pct:.1f}% from first-timers is\n" + + f" excellent. Indicates strong onboarding and accessible contribution process." + ) +elif overall_first_time_pct > 10: + insights.append( + f"2. Moderate New Contributor Rate: {overall_first_time_pct:.1f}% from first-timers\n" + + f" is healthy. Good balance of new and returning contributors." + ) +else: + insights.append( + f"2. Low New Contributor Rate: {overall_first_time_pct:.1f}% from first-timers.\n" + + f" Most PRs from established contributors (mature project pattern)." + ) + +# Check for trend +week_rates = [] +for week in weeks: + if week in by_week: + data = by_week[week] + rate = (data['first_time'] / data['total'] * 100) if data['total'] > 0 else 0 + week_rates.append(rate) + +if len(week_rates) >= 3: + if week_rates[-1] > week_rates[0]: + insights.append( + f"3. Growing Trend: First-time contributor rate increasing\n" + + f" ({week_rates[0]:.1f}% → {week_rates[-1]:.1f}%). Project attracting more new contributors." + ) + elif week_rates[-1] < week_rates[0]: + insights.append( + f"3. Declining Trend: First-time contributor rate decreasing\n" + + f" ({week_rates[0]:.1f}% → {week_rates[-1]:.1f}%). May indicate shifting to core contributors." + ) + else: + insights.append( + f"3. Stable Trend: First-time contributor rate relatively stable\n" + + f" across weeks. Consistent new contributor engagement." + ) + +insights.append( + f"4. Unique Contributors: {len(all_first_time_authors)} unique new people made their\n" + + f" first contribution. Shows breadth of community involvement." +) + +for insight in insights: + print(f"{insight}\n") + +print("="*90 + "\n") +EOF + +python3 "$PYTHON_SCRIPT" "$TEMP_CONTRIBUTORS" diff --git a/scripts/analyze-recent-weeks.sh b/scripts/analyze-recent-weeks.sh new file mode 100755 index 00000000000..f9654e85fa0 --- /dev/null +++ b/scripts/analyze-recent-weeks.sh @@ -0,0 +1,219 @@ +#!/bin/bash + +# GitHub Issues Analyzer for Recent Weeks +# Analyzes Dec 15-21 (Week 50) and Dec 22-26 (Week 51) +# Usage: ./scripts/analyze-recent-weeks.sh + +set -euo pipefail + +REPO="sst/opencode" +GITHUB_API="https://api.github.com/repos" + +# Start from Dec 15 +START_DATE="2025-12-15T00:00:00Z" + +echo "Analyzing GitHub issues from Dec 15 onwards..." +echo "Start date: $START_DATE" +echo "" + +# Create temp file +TEMP_FILE=$(mktemp) +trap "rm -f $TEMP_FILE" EXIT + +echo "[]" > "$TEMP_FILE" + +# Fetch all issues from Dec 15 onwards (paginate through results) +for page in {1..5}; do + echo " Fetching page $page..." + PAGE_DATA=$(curl -s "${GITHUB_API}/${REPO}/issues?state=all&sort=created&direction=desc&per_page=100&page=${page}") + + # Check if we got any results + COUNT=$(echo "$PAGE_DATA" | jq 'length') + if [ "$COUNT" -eq 0 ]; then + echo " No more results on page $page" + break + fi + + # Filter issues from Dec 15 onwards + FILTERED=$(echo "$PAGE_DATA" | jq "[.[] | select(.created_at >= \"${START_DATE}\")]") + FILTERED_COUNT=$(echo "$FILTERED" | jq 'length') + echo " Found $FILTERED_COUNT issues from Dec 15 onwards on page $page" + + # Append to temp file + CURRENT=$(cat "$TEMP_FILE") + MERGED=$(echo "$CURRENT" "$FILTERED" | jq -s '.[0] + .[1]') + echo "$MERGED" > "$TEMP_FILE" + + # If we've started getting old data, we can stop + OLDEST=$(echo "$PAGE_DATA" | jq -r '.[-1].created_at') + if [[ "$OLDEST" < "$START_DATE" ]]; then + echo " Reached data older than Dec 15, stopping" + break + fi +done + +echo "" + +# Create Python analysis script +PYTHON_SCRIPT=$(mktemp) +trap "rm -f $PYTHON_SCRIPT $TEMP_FILE" EXIT + +cat > "$PYTHON_SCRIPT" << 'EOF' +import json +import sys +from datetime import datetime +from collections import defaultdict + +# Read the issues data from file +with open(sys.argv[1], 'r') as f: + data = json.load(f) + +if not data: + print("No issues found from Dec 15 onwards") + sys.exit(0) + +print(f"Analyzing {len(data)} issues...\n") + +# Categorize and group by week +issues_by_week = defaultdict(lambda: defaultdict(int)) +week_totals = defaultdict(int) +week_order = [] + +# Response tracking +response_by_week = defaultdict(lambda: { + 'total': 0, + 'with_response': 0, + 'no_response': 0 +}) + +def get_week_label(date_str): + """Convert date to week label""" + date = datetime.fromisoformat(date_str.replace('Z', '+00:00')).replace(tzinfo=None) + + # Manual week grouping for clarity + if date >= datetime(2025, 12, 22): + return "Week 51: Dec 22-26" + elif date >= datetime(2025, 12, 15): + return "Week 50: Dec 15-21" + else: + return "Earlier" + +def categorize_issue(item): + """Categorize an issue""" + if item.get('pull_request'): + return "PR" + + labels = [label['name'] for label in item.get('labels', [])] + title = item['title'].lower() + + if 'discussion' in labels: + return "Feature Request" + elif 'help-wanted' in labels: + return "Help Question" + elif 'bug' in labels: + return "Bug Report" + elif any(x in title for x in ['[feature]', 'feature request', '[feat]']): + return "Feature Request" + elif title.endswith('?') and 'bug' not in title: + return "Help Question" + else: + return "Other" + +# Process each issue +for item in data: + week_label = get_week_label(item['created_at']) + if week_label not in week_order: + week_order.append(week_label) + + category = categorize_issue(item) + + # Check if it's an actual issue (not PR) + if not item.get('pull_request'): + response_by_week[week_label]['total'] += 1 + if item['comments'] > 0: + response_by_week[week_label]['with_response'] += 1 + else: + response_by_week[week_label]['no_response'] += 1 + + issues_by_week[week_label][category] += 1 + week_totals[week_label] += 1 + +# Sort weeks (most recent first) +week_order = sorted([w for w in week_order if w != "Earlier"], reverse=True) + +# Print results +print("="*80) +print("GITHUB ISSUES BREAKDOWN - RECENT WEEKS") +print("="*80 + "\n") + +for week in week_order: + print(f"{week}: {week_totals[week]} total") + for category in sorted(issues_by_week[week].keys()): + count = issues_by_week[week][category] + print(f" • {category}: {count}") + print() + +print("---") +total = sum(week_totals[w] for w in week_order) +print(f"TOTAL: {total} issues/PRs\n") + +print("OVERALL SUMMARY:") +all_counts = defaultdict(int) +for week in week_order: + for category, count in issues_by_week[week].items(): + all_counts[category] += count + +for category in sorted(all_counts.keys(), key=lambda x: -all_counts[x]): + count = all_counts[category] + pct = (count / total) * 100 + print(f" • {category}: {count} ({pct:.1f}%)") + +# Response rates +print("\n" + "="*80) +print("ISSUE RESPONSE RATES") +print("="*80 + "\n") + +for week in week_order: + data = response_by_week[week] + if data['total'] > 0: + rate = (data['with_response'] / data['total'] * 100) + print(f"{week}:") + print(f" Total issues: {data['total']}") + print(f" With response: {data['with_response']} ({rate:.1f}%)") + print(f" No response: {data['no_response']}") + print() + +# Week over week comparison +print("="*80) +print("WEEK-OVER-WEEK COMPARISON") +print("="*80 + "\n") + +if len(week_order) >= 2: + w1 = week_order[0] # Most recent + w2 = week_order[1] # Previous + + vol_change = week_totals[w1] - week_totals[w2] + vol_pct = (vol_change / week_totals[w2] * 100) if week_totals[w2] > 0 else 0 + + print(f"Volume Change: {week_totals[w2]} → {week_totals[w1]} ({vol_pct:+.1f}%)") + print() + + print("Category Changes:") + for category in sorted(all_counts.keys()): + old_val = issues_by_week[w2].get(category, 0) + new_val = issues_by_week[w1].get(category, 0) + change = new_val - old_val + direction = "↑" if change > 0 else "↓" if change < 0 else "→" + print(f" {category:18s}: {old_val:3d} → {new_val:3d} {direction} {abs(change)}") + + print() + if response_by_week[w1]['total'] > 0 and response_by_week[w2]['total'] > 0: + r1 = (response_by_week[w1]['with_response'] / response_by_week[w1]['total'] * 100) + r2 = (response_by_week[w2]['with_response'] / response_by_week[w2]['total'] * 100) + print(f"Response Rate: {r2:.1f}% → {r1:.1f}% ({r1-r2:+.1f}pp)") + +print("\n" + "="*80 + "\n") +EOF + +# Run the analysis +python3 "$PYTHON_SCRIPT" "$TEMP_FILE" diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 948e4766538..1534fe500c3 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.0.209-1", + "version": "1.0.218", "publisher": "sst-dev", "repository": { "type": "git",