diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 244ea80cd66..b0a5d7c2ca8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,29 @@ on: workflow_dispatch: jobs: test: - runs-on: blacksmith-4vcpu-ubuntu-2404 + name: test (${{ matrix.settings.name }}) + strategy: + fail-fast: false + matrix: + settings: + - name: linux + host: blacksmith-4vcpu-ubuntu-2404 + playwright: bunx playwright install --with-deps + workdir: . + command: | + git config --global user.email "bot@opencode.ai" + git config --global user.name "opencode" + bun turbo typecheck + bun turbo test + - name: windows + host: windows-latest + playwright: bunx playwright install + workdir: packages/app + command: bun test:e2e + runs-on: ${{ matrix.settings.host }} + defaults: + run: + shell: bash steps: - name: Checkout repository uses: actions/checkout@v4 @@ -21,42 +43,63 @@ jobs: - name: Install Playwright browsers working-directory: packages/app - run: bunx playwright install --with-deps + run: ${{ matrix.settings.playwright }} + + - name: Set OS-specific paths + run: | + if [ "${{ runner.os }}" = "Windows" ]; then + printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV" + printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV" + printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV" + printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV" + printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV" + printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV" + printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}\\packages\\opencode\\test\\tool\\fixtures\\models-api.json" >> "$GITHUB_ENV" + else + printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV" + printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV" + printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV" + printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV" + printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV" + printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV" + printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json" >> "$GITHUB_ENV" + fi - name: Seed opencode data working-directory: packages/opencode run: bun script/seed-e2e.ts env: - MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }} OPENCODE_DISABLE_MODELS_FETCH: "true" OPENCODE_DISABLE_SHARE: "true" OPENCODE_DISABLE_LSP_DOWNLOAD: "true" OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" - OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home - XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share - XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache - XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config - XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }} + XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }} + XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }} + XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }} + XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }} OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }} OPENCODE_E2E_SESSION_TITLE: "E2E Session" OPENCODE_E2E_MESSAGE: "Seeded for UI e2e" OPENCODE_E2E_MODEL: "opencode/gpt-5-nano" - name: Run opencode server - run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 & + working-directory: packages/opencode + run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 & env: - MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }} OPENCODE_DISABLE_MODELS_FETCH: "true" OPENCODE_DISABLE_SHARE: "true" OPENCODE_DISABLE_LSP_DOWNLOAD: "true" OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" - OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home - XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share - XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache - XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config - XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }} + XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }} + XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }} + XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }} + XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }} OPENCODE_CLIENT: "app" - name: Wait for opencode server @@ -68,24 +111,21 @@ jobs: exit 1 - name: run - run: | - git config --global user.email "bot@opencode.ai" - git config --global user.name "opencode" - bun turbo typecheck - bun turbo test + working-directory: ${{ matrix.settings.workdir }} + run: ${{ matrix.settings.command }} env: CI: true - MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }} OPENCODE_DISABLE_MODELS_FETCH: "true" OPENCODE_DISABLE_SHARE: "true" OPENCODE_DISABLE_LSP_DOWNLOAD: "true" OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" - OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home - XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share - XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache - XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config - XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }} + XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }} + XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }} + XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }} + XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }} PLAYWRIGHT_SERVER_HOST: "localhost" PLAYWRIGHT_SERVER_PORT: "4096" VITE_OPENCODE_SERVER_HOST: "localhost" diff --git a/.github/workflows/update-nix-hashes.yml b/.github/workflows/update-nix-hashes.yml new file mode 100644 index 00000000000..7175f4fbdd6 --- /dev/null +++ b/.github/workflows/update-nix-hashes.yml @@ -0,0 +1,138 @@ +name: Update Nix Hashes + +permissions: + contents: write + +on: + workflow_dispatch: + push: + paths: + - "bun.lock" + - "package.json" + - "packages/*/package.json" + - "flake.lock" + - ".github/workflows/update-nix-hashes.yml" + pull_request: + paths: + - "bun.lock" + - "package.json" + - "packages/*/package.json" + - "flake.lock" + - ".github/workflows/update-nix-hashes.yml" + +jobs: + update-node-modules-hashes: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + TITLE: node_modules hashes + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + ref: ${{ github.head_ref || github.ref_name }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + + - name: Setup Nix + uses: nixbuild/nix-quick-install-action@v34 + + - name: Configure git + run: | + git config --global user.email "action@github.com" + git config --global user.name "Github Action" + + - name: Pull latest changes + env: + TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" + git pull --rebase --autostash origin "$BRANCH" + + - name: Compute all node_modules hashes + run: | + set -euo pipefail + + HASH_FILE="nix/hashes.json" + SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin" + + if [ ! -f "$HASH_FILE" ]; then + mkdir -p "$(dirname "$HASH_FILE")" + echo '{"nodeModules":{}}' > "$HASH_FILE" + fi + + for SYSTEM in $SYSTEMS; do + echo "Computing hash for ${SYSTEM}..." + BUILD_LOG=$(mktemp) + trap 'rm -f "$BUILD_LOG"' EXIT + + # The updater derivations use fakeHash, so they will fail and reveal the correct hash + UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules" + + nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true + + CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)" + + if [ -z "$CORRECT_HASH" ]; then + CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)" + fi + + if [ -z "$CORRECT_HASH" ]; then + echo "Failed to determine correct node_modules hash for ${SYSTEM}." + cat "$BUILD_LOG" + exit 1 + fi + + echo " ${SYSTEM}: ${CORRECT_HASH}" + jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \ + '.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp" + mv "${HASH_FILE}.tmp" "$HASH_FILE" + done + + echo "All hashes computed:" + cat "$HASH_FILE" + + - name: Commit ${{ env.TITLE }} changes + env: + TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + set -euo pipefail + + HASH_FILE="nix/hashes.json" + echo "Checking for changes..." + + summarize() { + local status="$1" + { + echo "### Nix $TITLE" + echo "" + echo "- ref: ${GITHUB_REF_NAME}" + echo "- status: ${status}" + } >> "$GITHUB_STEP_SUMMARY" + if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then + echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + } + + FILES=("$HASH_FILE") + STATUS="$(git status --short -- "${FILES[@]}" || true)" + if [ -z "$STATUS" ]; then + echo "No changes detected." + summarize "no changes" + exit 0 + fi + + echo "Changes detected:" + echo "$STATUS" + git add "${FILES[@]}" + git commit -m "chore: update nix node_modules hashes" + + BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" + git pull --rebase --autostash origin "$BRANCH" + git push origin HEAD:"$BRANCH" + echo "Changes pushed successfully" + + summarize "committed $(git rev-parse --short HEAD)" diff --git a/.opencode/skill/bun-file-io/SKILL.md b/.opencode/skill/bun-file-io/SKILL.md new file mode 100644 index 00000000000..ea39507d269 --- /dev/null +++ b/.opencode/skill/bun-file-io/SKILL.md @@ -0,0 +1,39 @@ +--- +name: bun-file-io +description: Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories. +--- + +## Use this when + +- Editing file I/O or scans in `packages/opencode` +- Handling directory operations or external tools + +## Bun file APIs (from Bun docs) + +- `Bun.file(path)` is lazy; call `text`, `json`, `stream`, `arrayBuffer`, `bytes`, `exists` to read. +- Metadata: `file.size`, `file.type`, `file.name`. +- `Bun.write(dest, input)` writes strings, buffers, Blobs, Responses, or files. +- `Bun.file(...).delete()` deletes a file. +- `file.writer()` returns a FileSink for incremental writes. +- `Bun.Glob` + `Array.fromAsync(glob.scan({ cwd, absolute, onlyFiles, dot }))` for scans. +- Use `Bun.which` to find a binary, then `Bun.spawn` to run it. +- `Bun.readableStreamToText/Bytes/JSON` for stream output. + +## When to use node:fs + +- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive operations). + +## Repo patterns + +- Prefer Bun APIs over Node `fs` for file access. +- Check `Bun.file(...).exists()` before reading. +- For binary/large files use `arrayBuffer()` and MIME checks via `file.type`. +- Use `Bun.Glob` + `Array.fromAsync` for scans. +- Decode tool stderr with `Bun.readableStreamToText`. +- For large writes, use `Bun.write(Bun.file(path), text)`. + +## Quick checklist + +- Use Bun APIs first. +- Use `path.join`/`path.resolve` for paths. +- Prefer promise `.catch(...)` over `try/catch` when possible. diff --git a/.opencode/skill/test-skill/SKILL.md b/.opencode/skill/test-skill/SKILL.md deleted file mode 100644 index 3fef059f2e9..00000000000 --- a/.opencode/skill/test-skill/SKILL.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: test-skill -description: use this when asked to test skill ---- - -woah this is a test skill diff --git a/SECURITY.md b/SECURITY.md index 3a653d01c6e..93c7341cef6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,6 +24,7 @@ Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to requ | **Sandbox escapes** | The permission system is not a sandbox (see above) | | **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies | | **MCP server behavior** | External MCP servers you configure are outside our trust boundary | +| **Malicious config files** | Users control their own config; modifying it is not an attack vector | --- diff --git a/bun.lock b/bun.lock index bd37194ab7e..5fd001aee80 100644 --- a/bun.lock +++ b/bun.lock @@ -16,13 +16,14 @@ "@tsconfig/bun": "catalog:", "husky": "9.1.7", "prettier": "3.6.2", + "semver": "^7.6.0", "sst": "3.17.23", "turbo": "2.5.6", }, }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -72,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -106,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -133,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -157,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -181,7 +182,7 @@ }, "packages/desktop": { "name": "@shuvcode/desktop", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -210,7 +211,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -239,7 +240,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -255,7 +256,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.27", + "version": "1.1.28", "bin": { "opencode": "./bin/opencode", }, @@ -360,7 +361,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -376,11 +377,12 @@ "name": "@opencode-ai/script", "devDependencies": { "@types/bun": "catalog:", + "@types/semver": "catalog:", }, }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.27", + "version": "1.1.28", "devDependencies": { "@hey-api/openapi-ts": "0.90.4", "@tsconfig/node22": "catalog:", @@ -391,7 +393,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -404,7 +406,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -445,7 +447,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "zod": "catalog:", }, @@ -456,7 +458,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -517,6 +519,7 @@ "@types/bun": "1.3.5", "@types/luxon": "3.7.1", "@types/node": "22.13.9", + "@types/semver": "7.7.1", "@typescript/native-preview": "7.0.0-dev.20251207.1", "ai": "5.0.119", "diff": "8.0.2", @@ -1996,6 +1999,8 @@ "@types/scheduler": ["@types/scheduler@0.26.0", "", {}, "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], @@ -4356,6 +4361,8 @@ "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], + "@babel/plugin-bugfix-safari-class-field-initializer-scope/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], @@ -4442,6 +4449,10 @@ "@babel/plugin-transform-modules-systemjs/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/plugin-transform-modules-systemjs/@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], + + "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/plugin-transform-modules-umd/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], "@babel/plugin-transform-named-capturing-groups-regex/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], @@ -5094,6 +5105,16 @@ "@babel/helper-wrap-function/@babel/traverse/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], @@ -5174,6 +5195,20 @@ "@babel/plugin-transform-function-name/@babel/traverse/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + + "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], + "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -5766,6 +5801,18 @@ "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + + "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + + "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + + "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-modules-umd/@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@babel/plugin-transform-private-methods/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], diff --git a/nix/hashes.json b/nix/hashes.json index fa91b3b3102..b945a90f3df 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=", - "aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=", - "aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=", - "x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg=" + "x86_64-linux": "sha256-xVA4r7Qugw0TSx5wiTI5al93FI4D5LlvQo2ab3cUlmE=", + "aarch64-linux": "sha256-EV0U/mXlrnEyCryL9rLlOZvMn6U0+BSgPhTIudVeqTo=", + "aarch64-darwin": "sha256-zQvdRyNEHrpJsQMj8PZH0Ub21EREmDetVaJ0yBCgDlE=", + "x86_64-darwin": "sha256-Tt5k5KBnrsNVIqPET7OFzClerjdR68XYstyCj3KpvdI=" } } diff --git a/package.json b/package.json index a3a8b9f545c..d579ec6c6d9 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", "@types/node": "22.13.9", + "@types/semver": "7.7.1", "@tsconfig/node22": "22.0.2", "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", @@ -67,6 +68,7 @@ "@tsconfig/bun": "catalog:", "husky": "9.1.7", "prettier": "3.6.2", + "semver": "^7.6.0", "sst": "3.17.23", "turbo": "2.5.6" }, diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 0d9b7cd17ec..bba2b4ebac4 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -1,7 +1,5 @@ ## Debugging -- To test the opencode app, use the playwright MCP server, the app is already - running at http://localhost:3000 - NEVER try to restart the app, or the server process, EVER. ## SolidJS @@ -32,3 +30,14 @@ The desktop dev server runs at http://localhost:3000 and connects to the API at ## Tool Calling - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. + +## Browser Automation + +Use `agent-browser` for web automation. Run `agent-browser --help` for all commands. + +Core workflow: + +1. `agent-browser open ` - Navigate to page +2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2) +3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs +4. Re-snapshot after page changes diff --git a/packages/app/README.md b/packages/app/README.md index 42a68815090..54d1b2861b6 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -31,18 +31,20 @@ Your app is ready to be deployed! ## E2E Testing -The Playwright runner expects the app already running at `http://localhost:3000`. +Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`). +Use the local runner to create a temp sandbox, seed data, and run the tests. ```bash -bun add -D @playwright/test bunx playwright install -bun run test:e2e +bun run test:e2e:local +bun run test:e2e:local -- --grep "settings" ``` Environment options: -- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`) -- `PLAYWRIGHT_PORT` (default: `3000`) +- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`) +- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`) +- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:`) ## Deployment diff --git a/packages/app/e2e/context.spec.ts b/packages/app/e2e/context.spec.ts index 29b8ee4dc2f..3f6684dd091 100644 --- a/packages/app/e2e/context.spec.ts +++ b/packages/app/e2e/context.spec.ts @@ -1,6 +1,4 @@ import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" - test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => { const title = `e2e smoke context ${Date.now()}` const created = await sdk.session.create({ title }).then((r) => r.data) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 8b18b67bd56..c5455efef2d 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -33,7 +33,7 @@ export const test = base.extend({ await page.goto(sessionPath(directory, sessionID)) // Wait for app to be ready (may show loading states briefly) await waitForAppReady(page) - await expect(page.locator(promptSelector)).toBeVisible({ timeout: 15000 }) + await expect(page.locator(promptSelector).first()).toBeVisible({ timeout: 15000 }) } await use(gotoSession) }, diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/navigation.spec.ts index 8656cc3effe..ebc4b947fdb 100644 --- a/packages/app/e2e/navigation.spec.ts +++ b/packages/app/e2e/navigation.spec.ts @@ -8,5 +8,5 @@ test("project route redirects to /session", async ({ page, directory, slug }) => await waitForAppReady(page) await expect(page).toHaveURL(new RegExp(`/${slug}/session`), { timeout: 15000 }) - await expect(page.locator(promptSelector)).toBeVisible({ timeout: 15000 }) + await expect(page.locator(promptSelector).first()).toBeVisible({ timeout: 15000 }) }) diff --git a/packages/app/e2e/prompt.spec.ts b/packages/app/e2e/prompt.spec.ts new file mode 100644 index 00000000000..71a38166508 --- /dev/null +++ b/packages/app/e2e/prompt.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "./fixtures" +import { promptSelector } from "./utils" + +function sessionIDFromUrl(url: string) { + const match = /\/session\/([^/?#]+)/.exec(url) + return match?.[1] +} + +test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + const pageErrors: string[] = [] + const onPageError = (err: Error) => { + pageErrors.push(err.message) + } + page.on("pageerror", onPageError) + + await gotoSession() + + const token = `E2E_OK_${Date.now()}` + + const prompt = page.locator(promptSelector).first() + await prompt.click() + await page.keyboard.type(`Reply with exactly: ${token}`) + await page.keyboard.press("Enter") + + await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + + const sessionID = (() => { + const id = sessionIDFromUrl(page.url()) + if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`) + return id + })() + + try { + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) + return messages + .filter((m) => m.info.role === "assistant") + .flatMap((m) => m.parts) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join("\n") + }, + { timeout: 90_000 }, + ) + + .toContain(token) + + const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first() + await expect(reply).toBeVisible({ timeout: 90_000 }) + } finally { + page.off("pageerror", onPageError) + await sdk.session.delete({ sessionID }).catch(() => undefined) + } + + if (pageErrors.length > 0) { + throw new Error(`Page error(s):\n${pageErrors.join("\n")}`) + } +}) diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/session.spec.ts index 19e25a42131..6acca61d2a0 100644 --- a/packages/app/e2e/session.spec.ts +++ b/packages/app/e2e/session.spec.ts @@ -11,7 +11,7 @@ test("can open an existing session and type into the prompt", async ({ page, sdk try { await gotoSession(sessionID) - const prompt = page.locator(promptSelector) + const prompt = page.locator(promptSelector).first() await prompt.click() await page.keyboard.type("hello from e2e") await expect(prompt).toContainText("hello from e2e") diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal.spec.ts index fc558b63259..27d4cc71fb9 100644 --- a/packages/app/e2e/terminal.spec.ts +++ b/packages/app/e2e/terminal.spec.ts @@ -4,7 +4,7 @@ import { terminalSelector, terminalToggleKey } from "./utils" test("terminal panel can be toggled", async ({ page, gotoSession }) => { await gotoSession() - const terminal = page.locator(terminalSelector) + const terminal = page.locator(terminalSelector).first() const initiallyOpen = await terminal.isVisible() if (initiallyOpen) { await page.keyboard.press(terminalToggleKey) diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index b8183b13ad2..d1aae6d361c 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -53,5 +53,15 @@ export async function waitForAppReady(page: import("@playwright/test").Page, tim // Then wait for either the app to be ready (buttons visible) or stay in a loading/error state // If we timeout waiting for buttons, the test will fail with a clear error - await page.locator('[role="button"], [data-component="prompt-input"]').first().waitFor({ state: "visible", timeout }) + const readyChecks = [ + page.locator('[data-component="prompt-input"]').first(), + page.getByRole("button", { name: "Add project" }).first(), + page.getByRole("button", { name: serverName }).first(), + ] + + try { + await Promise.any(readyChecks.map((locator) => locator.waitFor({ state: "visible", timeout }))) + } catch (error) { + throw new Error("Timed out waiting for app to be ready") + } } diff --git a/packages/app/package.json b/packages/app/package.json index 5ca9a82c26e..2a8c6b112b6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.27", + "version": "1.1.28", "description": "", "type": "module", "exports": { @@ -15,6 +15,7 @@ "serve": "vite preview", "test": "playwright test", "test:e2e": "playwright test", + "test:e2e:local": "bun script/e2e-local.ts", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report e2e/playwright-report" }, diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts new file mode 100644 index 00000000000..dd0e9a52e2b --- /dev/null +++ b/packages/app/script/e2e-local.ts @@ -0,0 +1,130 @@ +import fs from "node:fs/promises" +import net from "node:net" +import os from "node:os" +import path from "node:path" + +async function freePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.once("error", reject) + server.listen(0, () => { + const address = server.address() + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire a free port"))) + return + } + server.close((err) => { + if (err) { + reject(err) + return + } + resolve(address.port) + }) + }) + }) +} + +async function waitForHealth(url: string) { + const timeout = Date.now() + 60_000 + while (Date.now() < timeout) { + const ok = await fetch(url) + .then((r) => r.ok) + .catch(() => false) + if (ok) return + await new Promise((r) => setTimeout(r, 250)) + } + throw new Error(`Timed out waiting for server health: ${url}`) +} + +const appDir = process.cwd() +const repoDir = path.resolve(appDir, "../..") +const opencodeDir = path.join(repoDir, "packages", "opencode") +const modelsJson = path.join(opencodeDir, "test", "tool", "fixtures", "models-api.json") + +const extraArgs = (() => { + const args = process.argv.slice(2) + if (args[0] === "--") return args.slice(1) + return args +})() + +const [serverPort, webPort] = await Promise.all([freePort(), freePort()]) + +const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-")) + +const serverEnv = { + ...process.env, + MODELS_DEV_API_JSON: modelsJson, + OPENCODE_DISABLE_MODELS_FETCH: "true", + OPENCODE_DISABLE_SHARE: "true", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + OPENCODE_TEST_HOME: path.join(sandbox, "home"), + XDG_DATA_HOME: path.join(sandbox, "share"), + XDG_CACHE_HOME: path.join(sandbox, "cache"), + XDG_CONFIG_HOME: path.join(sandbox, "config"), + XDG_STATE_HOME: path.join(sandbox, "state"), + OPENCODE_E2E_PROJECT_DIR: repoDir, + OPENCODE_E2E_SESSION_TITLE: "E2E Session", + OPENCODE_E2E_MESSAGE: "Seeded for UI e2e", + OPENCODE_E2E_MODEL: "opencode/gpt-5-nano", + OPENCODE_CLIENT: "app", +} satisfies Record + +const runnerEnv = { + ...process.env, + PLAYWRIGHT_SERVER_HOST: "localhost", + PLAYWRIGHT_SERVER_PORT: String(serverPort), + VITE_OPENCODE_SERVER_HOST: "localhost", + VITE_OPENCODE_SERVER_PORT: String(serverPort), + PLAYWRIGHT_PORT: String(webPort), +} satisfies Record + +const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { + cwd: opencodeDir, + env: serverEnv, + stdout: "inherit", + stderr: "inherit", +}) + +const seedExit = await seed.exited +if (seedExit !== 0) { + process.exit(seedExit) +} + +const server = Bun.spawn( + [ + "bun", + "dev", + "--", + "--print-logs", + "--log-level", + "WARN", + "serve", + "--port", + String(serverPort), + "--hostname", + "127.0.0.1", + ], + { + cwd: opencodeDir, + env: serverEnv, + stdout: "inherit", + stderr: "inherit", + }, +) + +try { + await waitForHealth(`http://localhost:${serverPort}/global/health`) + + const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], { + cwd: appDir, + env: runnerEnv, + stdout: "inherit", + stderr: "inherit", + }) + + process.exitCode = await runner.exited +} finally { + server.kill() +} diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 32763764c6f..2fb06f6c0d3 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -14,6 +14,7 @@ import { PermissionProvider } from "@/context/permission" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" import { ServerProvider, useServer } from "@/context/server" +import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" import { PromptProvider } from "@/context/prompt" import { FileProvider } from "@/context/file" @@ -109,15 +110,17 @@ export function AppInterface(props: { defaultUrl?: string }) { ( - - - - - {props.children} - - - - + + + + + + {props.children} + + + + + )} > } /> ( - - - - }> - - - - - + component={(p) => ( + + + + + }> + + + + + + )} /> diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index a2dc7b623c7..2e414a43702 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -123,6 +123,7 @@ export function DialogEditProject(props: { project: LocalProject }) { fallback={store.name || defaultName()} {...getAvatarColors(store.color)} class="size-full" + style={{ "font-size": "32px" }} /> } diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 0e8d69628bb..2e28c4d2edf 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -34,7 +34,14 @@ export function DialogSelectFile() { const view = createMemo(() => layout.view(sessionKey())) const state = { cleanup: undefined as (() => void) | void, committed: false } const [grouped, setGrouped] = createSignal(false) - const common = ["session.new", "session.previous", "session.next", "terminal.toggle", "review.toggle"] + const common = [ + "session.new", + "workspace.new", + "session.previous", + "session.next", + "terminal.toggle", + "review.toggle", + ] const limit = 5 const allowed = createMemo(() => diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx new file mode 100644 index 00000000000..5ef89b8bfc7 --- /dev/null +++ b/packages/app/src/components/dialog-settings.tsx @@ -0,0 +1,94 @@ +import { Component } from "solid-js" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Tabs } from "@opencode-ai/ui/tabs" +import { Icon } from "@opencode-ai/ui/icon" +import { SettingsGeneral } from "./settings-general" +import { SettingsKeybinds } from "./settings-keybinds" +import { SettingsPermissions } from "./settings-permissions" +import { SettingsProviders } from "./settings-providers" +import { SettingsModels } from "./settings-models" +import { SettingsAgents } from "./settings-agents" +import { SettingsCommands } from "./settings-commands" +import { SettingsMcp } from "./settings-mcp" + +export const DialogSettings: Component = () => { + return ( + + + +
+ Desktop +
+ + + General + + + + Shortcuts + +
+
+ {/* Server */} + {/* */} + {/* */} + {/* Permissions */} + {/* */} + {/* */} + {/* */} + {/* Providers */} + {/* */} + {/* */} + {/* */} + {/* Models */} + {/* */} + {/* */} + {/* */} + {/* Agents */} + {/* */} + {/* */} + {/* */} + {/* Commands */} + {/* */} + {/* */} + {/* */} + {/* MCP */} + {/* */} +
+ + + + + + + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} +
+
+ ) +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 86414d66396..09ccbbe0da3 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -255,7 +255,6 @@ export const PromptInput: Component = (props) => { createEffect(() => { params.id - editorRef.focus() if (params.id) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 13c4a51e7df..8f3bc1f4114 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,267 +1,327 @@ -import { createMemo, createResource, Show } from "solid-js" -import { A, useNavigate, useParams } from "@solidjs/router" +import { createEffect, createMemo, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { Portal } from "solid-js/web" +import { useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" -import { useServer } from "@/context/server" -import { useDialog } from "@opencode-ai/ui/context/dialog" +// import { useServer } from "@/context/server" +// import { useDialog } from "@opencode-ai/ui/context/dialog" +import { usePlatform } from "@/context/platform" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" -import { base64Decode, base64Encode } from "@opencode-ai/util/encode" -import { iife } from "@opencode-ai/util/iife" +import { base64Decode } from "@opencode-ai/util/encode" + import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { Select } from "@opencode-ai/ui/select" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { SessionLspIndicator } from "@/components/session-lsp-indicator" -import { SessionMcpIndicator } from "@/components/session-mcp-indicator" -import type { Session } from "@opencode-ai/sdk/v2/client" -import { same } from "@/utils/same" +import { Keybind } from "@opencode-ai/ui/keybind" export function SessionHeader() { const globalSDK = useGlobalSDK() const layout = useLayout() const params = useParams() - const navigate = useNavigate() const command = useCommand() - const server = useServer() - const dialog = useDialog() + // const server = useServer() + // const dialog = useDialog() const sync = useSync() + const platform = usePlatform() const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const name = createMemo(() => { + const current = project() + if (current) return current.name || getFilename(current.worktree) + return getFilename(projectDirectory()) + }) + const hotkey = createMemo(() => command.keybind("file.open")) - const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) - const parentSession = createMemo(() => { - const current = currentSession() - if (!current?.parentID) return undefined - return sync.data.session.find((s) => s.id === current.parentID) - }) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same }) + const showReview = createMemo(() => !!currentSession()?.summary?.files) + const showShare = createMemo(() => shareEnabled() && !!currentSession()) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey())) - function navigateToProject(directory: string) { - navigate(`/${base64Encode(directory)}`) + const [state, setState] = createStore({ + share: false, + unshare: false, + copied: false, + timer: undefined as number | undefined, + }) + const shareUrl = createMemo(() => currentSession()?.share?.url) + + createEffect(() => { + const url = shareUrl() + if (url) return + if (state.timer) window.clearTimeout(state.timer) + setState({ copied: false, timer: undefined }) + }) + + onCleanup(() => { + if (state.timer) window.clearTimeout(state.timer) + }) + + function shareSession() { + const session = currentSession() + if (!session || state.share) return + setState("share", true) + globalSDK.client.session + .share({ sessionID: session.id, directory: projectDirectory() }) + .catch((error) => { + console.error("Failed to share session", error) + }) + .finally(() => { + setState("share", false) + }) } - function navigateToSession(session: Session | undefined) { - if (!session) return - // Only navigate if we're actually changing to a different session - if (session.id === params.id) return - navigate(`/${params.dir}/session/${session.id}`) + function unshareSession() { + const session = currentSession() + if (!session || state.unshare) return + setState("unshare", true) + globalSDK.client.session + .unshare({ sessionID: session.id, directory: projectDirectory() }) + .catch((error) => { + console.error("Failed to unshare session", error) + }) + .finally(() => { + setState("unshare", false) + }) } + function copyLink() { + const url = shareUrl() + if (!url) return + navigator.clipboard + .writeText(url) + .then(() => { + if (state.timer) window.clearTimeout(state.timer) + setState("copied", true) + const timer = window.setTimeout(() => { + setState("copied", false) + setState("timer", undefined) + }, 3000) + setState("timer", timer) + }) + .catch((error) => { + console.error("Failed to copy share link", error) + }) + } + + function viewShare() { + const url = shareUrl() + if (!url) return + platform.openLink(url) + } + + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) + const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) + return ( -
- -
-
-
- - - x.title} - value={(x) => x.id} - onSelect={(session) => { - // Only navigate if selecting a different session than current parent - const currentParent = parentSession() - if (session && currentParent && session.id !== currentParent.id) { - navigateToSession(session) - } +
+ + + Search {name()} + +
+ + {(keybind) => {keybind()}} + + + )} +
+ + {(mount) => ( + +
+ {/* */} +
+ - -
- - - -
-
- -
- - - - -
- - - - - } - > - {iife(() => { - const [url] = createResource( - () => currentSession(), - async (session) => { - if (!session) return - let shareURL = session.share?.url - if (!shareURL) { - shareURL = await globalSDK.client.session - .share({ sessionID: session.id, directory: projectDirectory() }) - .then((r) => r.data?.share?.url) - .catch((e) => { - console.error("Failed to share session", e) - return undefined - }) - } - return shareURL - }, - { initialValue: "" }, - ) - return ( - - {(shareUrl) => } - - ) - })} - - -
-
-
+ + + + + + )} + + ) } diff --git a/packages/app/src/components/settings-agents.tsx b/packages/app/src/components/settings-agents.tsx new file mode 100644 index 00000000000..892be152b32 --- /dev/null +++ b/packages/app/src/components/settings-agents.tsx @@ -0,0 +1,12 @@ +import { Component } from "solid-js" + +export const SettingsAgents: Component = () => { + return ( +
+
+

Agents

+

Agent settings will be configurable here.

+
+
+ ) +} diff --git a/packages/app/src/components/settings-commands.tsx b/packages/app/src/components/settings-commands.tsx new file mode 100644 index 00000000000..e98c0eeb032 --- /dev/null +++ b/packages/app/src/components/settings-commands.tsx @@ -0,0 +1,12 @@ +import { Component } from "solid-js" + +export const SettingsCommands: Component = () => { + return ( +
+
+

Commands

+

Command settings will be configurable here.

+
+
+ ) +} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx new file mode 100644 index 00000000000..e8749cbdeaf --- /dev/null +++ b/packages/app/src/components/settings-general.tsx @@ -0,0 +1,246 @@ +import { Component, createMemo, type JSX } from "solid-js" +import { Select } from "@opencode-ai/ui/select" +import { Switch } from "@opencode-ai/ui/switch" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { useSettings, monoFontFamily } from "@/context/settings" +import { playSound, SOUND_OPTIONS } from "@/utils/sound" + +export const SettingsGeneral: Component = () => { + const theme = useTheme() + const settings = useSettings() + + const themeOptions = createMemo(() => + Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), + ) + + const colorSchemeOptions: { value: ColorScheme; label: string }[] = [ + { value: "system", label: "System setting" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + ] + + const fontOptions = [ + { value: "ibm-plex-mono", label: "IBM Plex Mono" }, + { value: "cascadia-code", label: "Cascadia Code" }, + { value: "fira-code", label: "Fira Code" }, + { value: "hack", label: "Hack" }, + { value: "inconsolata", label: "Inconsolata" }, + { value: "intel-one-mono", label: "Intel One Mono" }, + { value: "jetbrains-mono", label: "JetBrains Mono" }, + { value: "meslo-lgs", label: "Meslo LGS" }, + { value: "roboto-mono", label: "Roboto Mono" }, + { value: "source-code-pro", label: "Source Code Pro" }, + { value: "ubuntu-mono", label: "Ubuntu Mono" }, + ] + + const soundOptions = [...SOUND_OPTIONS] + + return ( +
+
+
+

General

+
+
+ +
+ {/* Appearance Section */} +
+

Appearance

+ +
+ + o.id === theme.themeId())} + value={(o) => o.id} + label={(o) => o.name} + onSelect={(option) => { + if (!option) return + theme.setTheme(option.id) + }} + onHighlight={(option) => { + if (!option) return + theme.previewTheme(option.id) + return () => theme.cancelPreview() + }} + variant="secondary" + size="small" + triggerVariant="settings" + /> + + + + + +
+
+ + {/* System notifications Section */} +
+

System notifications

+ +
+ + settings.notifications.setAgent(checked)} + /> + + + + settings.notifications.setPermissions(checked)} + /> + + + + settings.notifications.setErrors(checked)} + /> + +
+
+ + {/* Sound effects Section */} +
+

Sound effects

+ +
+ + o.id === settings.sounds.permissions())} + value={(o) => o.id} + label={(o) => o.label} + onHighlight={(option) => { + if (!option) return + playSound(option.src) + }} + onSelect={(option) => { + if (!option) return + settings.sounds.setPermissions(option.id) + playSound(option.src) + }} + variant="secondary" + size="small" + triggerVariant="settings" + /> + + + + o.value === actionFor(item.id))} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && setPermission(item.id, option.value)} + variant="secondary" + size="small" + triggerVariant="settings" + /> + + )} + +
+
+
+
+ ) +} + +interface SettingsRowProps { + title: string + description: string + children: JSX.Element +} + +const SettingsRow: Component = (props) => { + return ( +
+
+ {props.title} + {props.description} +
+
{props.children}
+
+ ) +} diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx new file mode 100644 index 00000000000..cf90b6c1332 --- /dev/null +++ b/packages/app/src/components/settings-providers.tsx @@ -0,0 +1,12 @@ +import { Component } from "solid-js" + +export const SettingsProviders: Component = () => { + return ( +
+
+

Providers

+

Provider settings will be configurable here.

+
+
+ ) +} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 8001e2caadc..f19366b8ab9 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,6 +1,7 @@ import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" +import { monoFontFamily, useSettings } from "@/context/settings" import { SerializeAddon } from "@/addons/serialize" import { LocalPTY } from "@/context/terminal" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" @@ -36,6 +37,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { export const Terminal = (props: TerminalProps) => { const sdk = useSDK() + const settings = useSettings() const theme = useTheme() let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) @@ -82,6 +84,14 @@ export const Terminal = (props: TerminalProps) => { setOption("theme", colors) }) + createEffect(() => { + const font = monoFontFamily(settings.appearance.font()) + if (!term) return + const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption + if (!setOption) return + setOption("fontFamily", font) + }) + const focusTerminal = () => { const t = term if (!t) return @@ -112,7 +122,7 @@ export const Terminal = (props: TerminalProps) => { cursorBlink: true, cursorStyle: "bar", fontSize: 14, - fontFamily: "IBM Plex Mono, monospace", + fontFamily: monoFontFamily(settings.appearance.font()), allowTransparency: true, theme: terminalColors(), scrollback: 10_000, diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index d8dc13e2344..681dcb23550 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -1,9 +1,28 @@ -import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSettings } from "@/context/settings" +import { Persist, persisted } from "@/utils/persist" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) +const PALETTE_ID = "command.palette" +const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" +const SUGGESTED_PREFIX = "suggested." + +function actionId(id: string) { + if (!id.startsWith(SUGGESTED_PREFIX)) return id + return id.slice(SUGGESTED_PREFIX.length) +} + +function normalizeKey(key: string) { + if (key === ",") return "comma" + if (key === "+") return "plus" + if (key === " ") return "space" + return key.toLowerCase() +} + export type KeybindConfig = string export interface Keybind { @@ -27,6 +46,14 @@ export interface CommandOption { onHighlight?: () => (() => void) | void } +export type CommandCatalogItem = { + title: string + description?: string + category?: string + keybind?: KeybindConfig + slash?: string +} + export function parseKeybind(config: string): Keybind[] { if (!config || config === "none") return [] @@ -73,7 +100,7 @@ export function parseKeybind(config: string): Keybind[] { } export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean { - const eventKey = event.key.toLowerCase() + const eventKey = normalizeKey(event.key) for (const kb of keybinds) { const keyMatch = kb.key === eventKey @@ -105,15 +132,17 @@ export function formatKeybind(config: string): string { if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") if (kb.key) { - const arrows: Record = { + const keys: Record = { arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→", + comma: ",", + plus: "+", + space: "Space", } - const displayKey = - arrows[kb.key.toLowerCase()] ?? - (kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)) + const key = kb.key.toLowerCase() + const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1)) parts.push(displayKey) } @@ -124,10 +153,23 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex name: "Command", init: () => { const dialog = useDialog() + const settings = useSettings() const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) - const options = createMemo(() => { + const [catalog, setCatalog, _, catalogReady] = persisted( + Persist.global("command.catalog.v1"), + createStore>({}), + ) + + const bind = (id: string, def: KeybindConfig | undefined) => { + const custom = settings.keybinds.get(actionId(id)) + const config = custom ?? def + if (!config || config === "none") return + return config + } + + const registered = createMemo(() => { const seen = new Set() const all: CommandOption[] = [] @@ -139,15 +181,41 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } } - const suggested = all.filter((x) => x.suggested && !x.disabled) + return all + }) + + createEffect(() => { + if (!catalogReady()) return + + for (const opt of registered()) { + const id = actionId(opt.id) + setCatalog(id, { + title: opt.title, + description: opt.description, + category: opt.category, + keybind: opt.keybind, + slash: opt.slash, + }) + } + }) + + const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta }))) + + const options = createMemo(() => { + const resolved = registered().map((opt) => ({ + ...opt, + keybind: bind(opt.id, opt.keybind), + })) + + const suggested = resolved.filter((x) => x.suggested && !x.disabled) return [ ...suggested.map((x) => ({ ...x, - id: "suggested." + x.id, + id: SUGGESTED_PREFIX + x.id, category: "Suggested", })), - ...all, + ...resolved, ] }) @@ -169,7 +237,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const handleKeyDown = (event: KeyboardEvent) => { if (suspended() || dialog.active) return - const paletteKeybinds = parseKeybind("mod+shift+p") + const paletteKeybinds = parseKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND) if (matchKeybind(paletteKeybinds, event)) { event.preventDefault() showPalette() @@ -209,15 +277,27 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex run(id, source) }, keybind(id: string) { - const option = options().find((x) => x.id === id || x.id === "suggested." + id) - if (!option?.keybind) return "" - return formatKeybind(option.keybind) + if (id === PALETTE_ID) { + return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND) + } + + const base = actionId(id) + const option = options().find((x) => actionId(x.id) === base) + if (option?.keybind) return formatKeybind(option.keybind) + + const meta = catalog[base] + const config = bind(base, meta?.keybind) + if (!config) return "" + return formatKeybind(config) }, show: showPalette, keybinds(enabled: boolean) { setSuspendCount((count) => count + (enabled ? -1 : 1)) }, suspended, + get catalog() { + return catalogOptions() + }, get options() { return options() }, diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 1c000a62d9b..82fc49ea76c 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -96,6 +96,10 @@ type VcsCache = { ready: Accessor } +type ChildOptions = { + bootstrap?: boolean +} + function createGlobalSync() { const globalSDK = useGlobalSDK() const platform = usePlatform() @@ -111,6 +115,8 @@ function createGlobalSync() { project: Project[] provider: ProviderListResponse provider_auth: ProviderAuthResponse + config: Config + reload: undefined | "pending" | "complete" }>({ connectionState: "connecting", ready: false, @@ -119,11 +125,29 @@ function createGlobalSync() { project: [], provider: { all: [], connected: [], default: {} }, provider_auth: {}, + config: {}, + reload: undefined, + }) + let bootstrapQueue: string[] = [] + + createEffect(async () => { + if (globalStore.reload !== "complete") return + if (bootstrapQueue.length) { + for (const directory of bootstrapQueue) { + bootstrapInstance(directory) + } + bootstrap() + } + bootstrapQueue = [] + setGlobalStore("reload", undefined) }) const children: Record, SetStoreFunction]> = {} + const booting = new Map>() + const sessionLoads = new Map>() + const sessionMeta = new Map() - function child(directory: string) { + function ensureChild(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { const cache = runWithOwner(owner, () => @@ -160,7 +184,6 @@ function createGlobalSync() { message: {}, part: {}, }) - bootstrapInstance(directory) } runWithOwner(owner, init) @@ -170,11 +193,24 @@ function createGlobalSync() { return childStore } + function child(directory: string, options: ChildOptions = {}) { + const childStore = ensureChild(directory) + const shouldBootstrap = options.bootstrap ?? true + if (shouldBootstrap && childStore[0].status === "loading") { + void bootstrapInstance(directory) + } + return childStore + } + async function loadSessions(directory: string) { - const [store, setStore] = child(directory) - const limit = store.limit + const pending = sessionLoads.get(directory) + if (pending) return pending + + const [store, setStore] = child(directory, { bootstrap: false }) + const meta = sessionMeta.get(directory) + if (meta && meta.limit >= store.limit) return - return globalSDK.client.session + const promise = globalSDK.client.session .list({ directory, roots: true }) .then((x) => { const data = Array.isArray(x.data) ? x.data : [] @@ -184,9 +220,15 @@ function createGlobalSync() { .slice() .sort((a, b) => a.id.localeCompare(b.id)) + // Read the current limit at resolve-time so callers that bump the limit while + // a request is in-flight still get the expanded result. + const limit = store.limit + const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory)) if (sandboxWorkspace) { + setStore("sessionTotal", nonArchived.length) setStore("session", reconcile(nonArchived, { key: "id" })) + sessionMeta.set(directory, { limit }) return } @@ -200,136 +242,164 @@ function createGlobalSync() { // Store total session count (used for "load more" pagination) setStore("sessionTotal", nonArchived.length) setStore("session", reconcile(sessions, { key: "id" })) + sessionMeta.set(directory, { limit }) }) .catch((err) => { console.error("Failed to load sessions", err) const project = getFilename(directory) showToast({ title: `Failed to load sessions for ${project}`, description: err.message }) }) + + sessionLoads.set(directory, promise) + promise.finally(() => { + sessionLoads.delete(directory) + }) + return promise } async function bootstrapInstance(directory: string) { if (!directory) return - const [store, setStore] = child(directory) - const cache = vcsCache.get(directory) - if (!cache) return - const sdk = createOpencodeClient({ - baseUrl: globalSDK.url, - fetch: platform.fetch, - directory, - throwOnError: true, - }) + const pending = booting.get(directory) + if (pending) return pending + + const promise = (async () => { + const [store, setStore] = ensureChild(directory) + const cache = vcsCache.get(directory) + if (!cache) return + const sdk = createOpencodeClient({ + baseUrl: globalSDK.url, + fetch: platform.fetch, + directory, + throwOnError: true, + }) - createEffect(() => { - if (!cache.ready()) return - const cached = cache.store.value - if (!cached?.branch) return - setStore("vcs", (value) => value ?? cached) - }) + setStore("status", "loading") - const blockingRequests = { - project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)), - provider: () => - sdk.provider.list().then((x) => { - const data = x.data! - setStore("provider", { - ...data, - all: data.all.map((provider) => ({ - ...provider, - models: Object.fromEntries( - Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"), - ), - })), - }) - }), - agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), - config: () => sdk.config.get().then((x) => setStore("config", x.data!)), - } - await Promise.all(Object.values(blockingRequests).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) - .then(() => { - if (store.status !== "complete") setStore("status", "partial") - // non-blocking - Promise.all([ - sdk.path.get().then((x) => setStore("path", x.data!)), - sdk.command.list().then((x) => setStore("command", x.data ?? [])), - sdk.session.status().then((x) => setStore("session_status", x.data!)), - loadSessions(directory), - sdk.mcp.status().then((x) => setStore("mcp", x.data!)), - sdk.lsp.status().then((x) => setStore("lsp", x.data!)), - sdk.vcs.get().then((x) => { - const next = x.data ?? store.vcs - setStore("vcs", next) - if (next?.branch) cache.setStore("value", next) - }), - sdk.permission.list().then((x) => { - const grouped: Record = {} - for (const perm of x.data ?? []) { - if (!perm?.id || !perm.sessionID) continue - const existing = grouped[perm.sessionID] - if (existing) { - existing.push(perm) - continue - } - grouped[perm.sessionID] = [perm] - } + createEffect(() => { + if (!cache.ready()) return + const cached = cache.store.value + if (!cached?.branch) return + setStore("vcs", (value) => value ?? cached) + }) - batch(() => { - for (const sessionID of Object.keys(store.permission)) { - if (grouped[sessionID]) continue - setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - setStore( - "permission", - sessionID, - reconcile( - permissions - .filter((p) => !!p?.id) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)), - { key: "id" }, - ), - ) - } + const blockingRequests = { + project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)), + provider: () => + sdk.provider.list().then((x) => { + const data = x.data! + setStore("provider", { + ...data, + all: data.all.map((provider) => ({ + ...provider, + models: Object.fromEntries( + Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"), + ), + })), }) }), - sdk.question.list().then((x) => { - const grouped: Record = {} - for (const question of x.data ?? []) { - if (!question?.id || !question.sessionID) continue - const existing = grouped[question.sessionID] - if (existing) { - existing.push(question) - continue - } - grouped[question.sessionID] = [question] + agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + config: () => sdk.config.get().then((x) => setStore("config", x.data!)), + } + + try { + await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) + } catch (err) { + console.error("Failed to bootstrap instance", err) + const project = getFilename(directory) + const message = err instanceof Error ? err.message : String(err) + showToast({ title: `Failed to reload ${project}`, description: message }) + setStore("status", "partial") + return + } + + if (store.status !== "complete") setStore("status", "partial") + + Promise.all([ + sdk.path.get().then((x) => setStore("path", x.data!)), + sdk.command.list().then((x) => setStore("command", x.data ?? [])), + sdk.session.status().then((x) => setStore("session_status", x.data!)), + loadSessions(directory), + sdk.mcp.status().then((x) => setStore("mcp", x.data!)), + sdk.lsp.status().then((x) => setStore("lsp", x.data!)), + sdk.vcs.get().then((x) => { + const next = x.data ?? store.vcs + setStore("vcs", next) + if (next?.branch) cache.setStore("value", next) + }), + sdk.permission.list().then((x) => { + const grouped: Record = {} + for (const perm of x.data ?? []) { + if (!perm?.id || !perm.sessionID) continue + const existing = grouped[perm.sessionID] + if (existing) { + existing.push(perm) + continue } + grouped[perm.sessionID] = [perm] + } - batch(() => { - for (const sessionID of Object.keys(store.question)) { - if (grouped[sessionID]) continue - setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - setStore( - "question", - sessionID, - reconcile( - questions - .filter((q) => !!q?.id) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ]).then(() => { - setStore("status", "complete") - }) + batch(() => { + for (const sessionID of Object.keys(store.permission)) { + if (grouped[sessionID]) continue + setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + setStore( + "permission", + sessionID, + reconcile( + permissions + .filter((p) => !!p?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + }) + }), + sdk.question.list().then((x) => { + const grouped: Record = {} + for (const question of x.data ?? []) { + if (!question?.id || !question.sessionID) continue + const existing = grouped[question.sessionID] + if (existing) { + existing.push(question) + continue + } + grouped[question.sessionID] = [question] + } + + batch(() => { + for (const sessionID of Object.keys(store.question)) { + if (grouped[sessionID]) continue + setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + setStore( + "question", + sessionID, + reconcile( + questions + .filter((q) => !!q?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ]).then(() => { + setStore("status", "complete") }) - .catch((e) => setGlobalStore("error", e)) + })() + + booting.set(directory, promise) + promise.finally(() => { + booting.delete(directory) + }) + return promise } const unsub = globalSDK.event.listen((e) => { @@ -339,6 +409,7 @@ function createGlobalSync() { if (directory === "global") { switch (event?.type) { case "global.disposed": { + if (globalStore.reload) return bootstrap() break } @@ -360,9 +431,16 @@ function createGlobalSync() { return } - const [store, setStore] = child(directory) + const existing = children[directory] + if (!existing) return + + const [store, setStore] = existing switch (event.type) { case "server.instance.disposed": { + if (globalStore.reload) { + bootstrapQueue.push(directory) + return + } bootstrapInstance(directory) break } @@ -636,6 +714,11 @@ function createGlobalSync() { setGlobalStore("path", x.data!) }), ), + retry(() => + globalSDK.client.config.get().then((x) => { + setGlobalStore("config", x.data!) + }), + ), retry(() => globalSDK.client.project.list().then(async (x) => { const data = Array.isArray(x.data) ? x.data : [] @@ -683,6 +766,7 @@ function createGlobalSync() { return { data: globalStore, + set: setGlobalStore, get ready() { return globalStore.ready }, @@ -697,6 +781,14 @@ function createGlobalSync() { }, child, bootstrap, + updateConfig: async (config: Config) => { + setGlobalStore("reload", "pending") + const response = await globalSDK.client.config.update({ config }) + setTimeout(() => { + setGlobalStore("reload", "complete") + }, 1000) + return response + }, project: { loadSessions, }, diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 16b3d306c2d..8b108851949 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -4,13 +4,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSDK } from "./global-sdk" import { useGlobalSync } from "./global-sync" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { Binary } from "@opencode-ai/util/binary" import { base64Encode } from "@opencode-ai/util/encode" import { EventSessionError } from "@opencode-ai/sdk/v2" -import { makeAudioPlayer } from "@solid-primitives/audio" -import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" -import errorSound from "@opencode-ai/ui/audio/nope-03.aac" import { Persist, persisted } from "@/utils/persist" +import { playSound, soundSrc } from "@/utils/sound" type NotificationBase = { directory?: string @@ -44,19 +43,10 @@ function pruneNotifications(list: Notification[]) { export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ name: "Notification", init: () => { - let idlePlayer: ReturnType | undefined - let errorPlayer: ReturnType | undefined - - try { - idlePlayer = makeAudioPlayer(idleSound) - errorPlayer = makeAudioPlayer(errorSound) - } catch (err) { - console.log("Failed to load audio", err) - } - const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() const platform = usePlatform() + const settings = useSettings() const [store, setStore, _, ready] = persisted( Persist.global("notification", ["notification.v1"]), @@ -93,16 +83,20 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const match = Binary.search(syncStore.session, sessionID, (s) => s.id) const session = match.found ? syncStore.session[match.index] : undefined if (session?.parentID) break - try { - idlePlayer?.play() - } catch {} + + playSound(soundSrc(settings.sounds.agent())) + append({ ...base, type: "turn-complete", session: sessionID, }) + const href = `/${base64Encode(directory)}/session/${sessionID}` - void platform.notify("Response ready", session?.title ?? sessionID, href) + if (settings.notifications.agent()) { + void platform.notify("Response ready", session?.title ?? sessionID, href) + } + break } case "session.error": { @@ -111,9 +105,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined const session = sessionID && match?.found ? syncStore.session[match.index] : undefined if (session?.parentID) break - try { - errorPlayer?.play() - } catch {} + + playSound(soundSrc(settings.sounds.errors())) + const error = "error" in event.properties ? event.properties.error : undefined append({ ...base, @@ -121,9 +115,13 @@ export const { use: useNotification, provider: NotificationProvider } = createSi session: sessionID ?? "global", error, }) + const description = session?.title ?? (typeof error === "string" ? error : "An error occurred") const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` - void platform.notify("Session error", description, href) + if (settings.notifications.errors()) { + void platform.notify("Session error", description, href) + } + break } } diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx new file mode 100644 index 00000000000..b44b4e14372 --- /dev/null +++ b/packages/app/src/context/settings.tsx @@ -0,0 +1,158 @@ +import { createStore, reconcile } from "solid-js/store" +import { createEffect, createMemo } from "solid-js" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { persisted } from "@/utils/persist" + +export interface NotificationSettings { + agent: boolean + permissions: boolean + errors: boolean +} + +export interface SoundSettings { + agent: string + permissions: string + errors: string +} + +export interface Settings { + general: { + autoSave: boolean + } + appearance: { + fontSize: number + font: string + } + keybinds: Record + permissions: { + autoApprove: boolean + } + notifications: NotificationSettings + sounds: SoundSettings +} + +const defaultSettings: Settings = { + general: { + autoSave: true, + }, + appearance: { + fontSize: 14, + font: "ibm-plex-mono", + }, + keybinds: {}, + permissions: { + autoApprove: false, + }, + notifications: { + agent: true, + permissions: true, + errors: false, + }, + sounds: { + agent: "staplebops-01", + permissions: "staplebops-02", + errors: "nope-03", + }, +} + +const monoFallback = + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' + +const monoFonts: Record = { + "ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "cascadia-code": `"Cascadia Code Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "fira-code": `"Fira Code Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + hack: `"Hack Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + inconsolata: `"Inconsolata Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "intel-one-mono": `"Intel One Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "jetbrains-mono": `"JetBrains Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "meslo-lgs": `"Meslo LGS Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "roboto-mono": `"Roboto Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "source-code-pro": `"Source Code Pro Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "ubuntu-mono": `"Ubuntu Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, +} + +export function monoFontFamily(font: string | undefined) { + return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font] +} + +export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ + name: "Settings", + init: () => { + const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) + + createEffect(() => { + if (typeof document === "undefined") return + document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) + }) + + return { + ready, + get current() { + return store + }, + general: { + autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave), + setAutoSave(value: boolean) { + setStore("general", "autoSave", value) + }, + }, + appearance: { + fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize), + setFontSize(value: number) { + setStore("appearance", "fontSize", value) + }, + font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font), + setFont(value: string) { + setStore("appearance", "font", value) + }, + }, + keybinds: { + get: (action: string) => store.keybinds?.[action], + set(action: string, keybind: string) { + setStore("keybinds", action, keybind) + }, + reset(action: string) { + setStore("keybinds", action, undefined!) + }, + resetAll() { + setStore("keybinds", reconcile({})) + }, + }, + permissions: { + autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove), + setAutoApprove(value: boolean) { + setStore("permissions", "autoApprove", value) + }, + }, + notifications: { + agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent), + setAgent(value: boolean) { + setStore("notifications", "agent", value) + }, + permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions), + setPermissions(value: boolean) { + setStore("notifications", "permissions", value) + }, + errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors), + setErrors(value: boolean) { + setStore("notifications", "errors", value) + }, + }, + sounds: { + agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent), + setAgent(value: string) { + setStore("sounds", "agent", value) + }, + permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions), + setPermissions(value: string) { + setStore("sounds", "permissions", value) + }, + errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors), + setErrors(value: string) { + setStore("sounds", "errors", value) + }, + }, + } + }, +}) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 709d7b899ac..5732114b46b 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -38,6 +38,22 @@ function createTerminalSession(sdk: ReturnType, dir: string, sess }), ) + const unsub = sdk.event.on("pty.exited", (event) => { + const id = event.properties.id + if (!store.all.some((x) => x.id === id)) return + batch(() => { + setStore( + "all", + store.all.filter((x) => x.id !== id), + ) + if (store.active === id) { + const remaining = store.all.filter((x) => x.id !== id) + setStore("active", remaining[0]?.id) + } + }) + }) + onCleanup(unsub) + return { ready, all: createMemo(() => Object.values(store.all)), diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 98b0c7b2ae8..5d3536a48ed 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -5,37 +5,40 @@ import { createSignal, For, Match, + on, onCleanup, onMount, ParentProps, Show, Switch, untrack, + type Accessor, type JSX, } from "solid-js" -import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" +import { Persist, persisted } from "@/utils/persist" import { base64Decode, base64Encode } from "@opencode-ai/util/encode" -import { AsciiLogo, AsciiMark } from "@opencode-ai/ui/logo" -import { ThemePicker } from "@/components/theme-picker" -import { FontPicker } from "@/components/font-picker" -import { Select } from "@opencode-ai/ui/select" import { Avatar } from "@opencode-ai/ui/avatar" +import { AsciiLogo, AsciiMark } from "@opencode-ai/ui/logo" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { MessageNav } from "@opencode-ai/ui/message-nav" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" - +import { Dialog } from "@opencode-ai/ui/dialog" import { getFilename } from "@opencode-ai/util/path" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Session } from "@opencode-ai/sdk/v2/client" +import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client" import { usePlatform, isPWA } from "@/context/platform" +import { useSettings } from "@/context/settings" import { createStore, produce, reconcile } from "solid-js/store" import { DragDropProvider, @@ -54,34 +57,40 @@ import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { PullToRefresh } from "@/components/pull-to-refresh" import { retry } from "@opencode-ai/util/retry" +import { playSound, soundSrc } from "@/utils/sound" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { FontPicker } from "@/components/font-picker" +import { ThemePicker, DialogSelectTheme } from "@/components/theme-picker" import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogCreateProject } from "@/components/dialog-create-project" -import { DialogSessionRenameGlobal } from "@/components/dialog-session-rename-global" - -import { DialogSelectTheme } from "@/components/theme-picker" -import { DialogEditProject } from "@/components/dialog-edit-project" -import { applyTheme } from "@/theme/apply-theme" import { DialogSelectServer } from "@/components/dialog-select-server" +import { DialogSettings } from "@/components/dialog-settings" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { navStart } from "@/utils/perf" +import { DialogCreateProject } from "@/components/dialog-create-project" +import { DialogSessionRenameGlobal } from "@/components/dialog-session-rename-global" +import { DialogEditProject } from "@/components/dialog-edit-project" +import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" +import { applyTheme } from "@/theme/apply-theme" export default function Layout(props: ParentProps) { - const [store, setStore] = createStore({ - lastSession: {} as { [directory: string]: string }, - activeDraggable: undefined as string | undefined, - mobileProjectsExpanded: {} as Record, - }) + const [store, setStore, , ready] = persisted( + Persist.global("layout.page", ["layout.page.v1"]), + createStore({ + lastSession: {} as { [directory: string]: string }, + activeProject: undefined as string | undefined, + activeWorkspace: undefined as string | undefined, + workspaceOrder: {} as Record, + workspaceName: {} as Record, + workspaceBranchName: {} as Record>, + workspaceExpanded: {} as Record, + }), + ) - const mobileProjects = { - expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true, - expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true), - collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false), - } + const pageReady = createMemo(() => ready()) let scrollContainerRef: HTMLDivElement | undefined const xlQuery = window.matchMedia("(min-width: 1280px)") @@ -91,10 +100,13 @@ export default function Layout(props: ParentProps) { onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange)) const params = useParams() + const [autoselect, setAutoselect] = createSignal(!params.dir) const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() const layout = useLayout() + const layoutReady = createMemo(() => layout.ready()) const platform = usePlatform() + const settings = useSettings() const server = useServer() const notification = useNotification() const permission = usePermission() @@ -103,6 +115,7 @@ export default function Layout(props: ParentProps) { const dialog = useDialog() const command = useCommand() const theme = useTheme() + const initialDir = params.dir const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeLabel: Record = { @@ -111,6 +124,129 @@ export default function Layout(props: ParentProps) { dark: "Dark", } + const [editor, setEditor] = createStore({ + active: "" as string, + value: "", + }) + const [busyWorkspaces, setBusyWorkspaces] = createSignal>(new Set()) + const setBusy = (directory: string, value: boolean) => { + const key = workspaceKey(directory) + setBusyWorkspaces((prev) => { + const next = new Set(prev) + if (value) next.add(key) + else next.delete(key) + return next + }) + } + const isBusy = (directory: string) => busyWorkspaces().has(workspaceKey(directory)) + const editorRef = { current: undefined as HTMLInputElement | undefined } + + const autoselecting = createMemo(() => { + if (params.dir) return false + if (initialDir) return false + if (!autoselect()) return false + if (!pageReady()) return true + if (!layoutReady()) return true + const list = layout.projects.list() + if (list.length === 0) return false + return true + }) + + const editorOpen = (id: string) => editor.active === id + const editorValue = () => editor.value + + const openEditor = (id: string, value: string) => { + if (!id) return + setEditor({ active: id, value }) + } + + const closeEditor = () => setEditor({ active: "", value: "" }) + + const saveEditor = (callback: (next: string) => void) => { + const next = editor.value.trim() + if (!next) { + closeEditor() + return + } + closeEditor() + callback(next) + } + + const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => { + if (event.key === "Enter") { + event.preventDefault() + saveEditor(callback) + return + } + if (event.key === "Escape") { + event.preventDefault() + closeEditor() + } + } + + const InlineEditor = (props: { + id: string + value: Accessor + onSave: (next: string) => void + class?: string + displayClass?: string + editing?: boolean + stopPropagation?: boolean + openOnDblClick?: boolean + }) => { + const isEditing = () => props.editing ?? editorOpen(props.id) + const stopEvents = () => props.stopPropagation ?? false + const allowDblClick = () => props.openOnDblClick ?? true + const stopPropagation = (event: Event) => { + if (!stopEvents()) return + event.stopPropagation() + } + const handleDblClick = (event: MouseEvent) => { + if (!allowDblClick()) return + stopPropagation(event) + openEditor(props.id, props.value()) + } + + return ( + + {props.value()} + + } + > + { + editorRef.current = el + requestAnimationFrame(() => el.focus()) + }} + value={editorValue()} + class={props.class} + onInput={(event) => setEditor("value", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + editorKeyDown(event, props.onSave) + }} + onBlur={() => closeEditor()} + onPointerDown={stopPropagation} + onClick={stopPropagation} + onDblClick={stopPropagation} + onMouseDown={stopPropagation} + onMouseUp={stopPropagation} + onTouchStart={stopPropagation} + /> + + ) + } + function cycleTheme(direction = 1) { const ids = availableThemeEntries().map(([id]) => id) if (ids.length === 0) return @@ -173,64 +309,76 @@ export default function Layout(props: ParentProps) { onCleanup(() => clearInterval(interval)) }) - const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) - const sessions = createMemo(() => { - const dir = currentDirectory() - if (!dir) return [] - return globalSync.child(dir)[0].session ?? [] - }) - const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) - const currentSessionId = createMemo(() => currentSession()?.id) - const otherSessions = createMemo(() => sessions().filter((s) => s.id !== currentSessionId())) - onMount(() => { + const alerts = { + "permission.asked": { + title: "Permission required", + icon: "checklist" as const, + description: (sessionTitle: string, projectName: string) => + `${sessionTitle} in ${projectName} needs permission`, + }, + "question.asked": { + title: "Question", + icon: "bubble-5" as const, + description: (sessionTitle: string, projectName: string) => `${sessionTitle} in ${projectName} has a question`, + }, + } + const toastBySession = new Map() const alertedAtBySession = new Map() - const permissionAlertCooldownMs = 5000 + const cooldownMs = 5000 const unsub = globalSDK.event.listen((e) => { - if (e.details?.type !== "permission.asked") return + if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return + const config = alerts[e.details.type] const directory = e.name - const perm = e.details.properties - if (permission.autoResponds(perm, directory)) return + const props = e.details.properties + if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return const [store] = globalSync.child(directory) - const session = store.session.find((s) => s.id === perm.sessionID) - const sessionKey = `${directory}:${perm.sessionID}` + const session = store.session.find((s) => s.id === props.sessionID) + const sessionKey = `${directory}:${props.sessionID}` const sessionTitle = session?.title ?? "New session" const projectName = getFilename(directory) - const description = `${sessionTitle} in ${projectName} needs permission` - const href = `/${base64Encode(directory)}/session/${perm.sessionID}` + const description = config.description(sessionTitle, projectName) + const href = `/${base64Encode(directory)}/session/${props.sessionID}` const now = Date.now() const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0 - if (now - lastAlerted < permissionAlertCooldownMs) return + if (now - lastAlerted < cooldownMs) return alertedAtBySession.set(sessionKey, now) - void platform.notify("Permission required", description, href) + if (e.details.type === "permission.asked") { + playSound(soundSrc(settings.sounds.permissions())) + if (settings.notifications.permissions()) { + void platform.notify(config.title, description, href) + } + } + + if (e.details.type === "question.asked") { + if (settings.notifications.agent()) { + void platform.notify(config.title, description, href) + } + } const currentDir = params.dir ? base64Decode(params.dir) : undefined const currentSession = params.id - if (directory === currentDir && perm.sessionID === currentSession) return + if (directory === currentDir && props.sessionID === currentSession) return if (directory === currentDir && session?.parentID === currentSession) return const existingToastId = toastBySession.get(sessionKey) - if (existingToastId !== undefined) { - toaster.dismiss(existingToastId) - } + if (existingToastId !== undefined) toaster.dismiss(existingToastId) const toastId = showToast({ persistent: true, - icon: "checklist", - title: "Permission required", + icon: config.icon, + title: config.title, description, actions: [ { label: "Go to session", - onClick: () => { - navigate(href) - }, + onClick: () => navigate(href), }, { label: "Dismiss", @@ -267,28 +415,6 @@ export default function Layout(props: ParentProps) { }) }) - function flattenSessions(sessions: Session[]): Session[] { - const childrenMap = new Map() - for (const session of sessions) { - if (session.parentID) { - const children = childrenMap.get(session.parentID) ?? [] - children.push(session) - childrenMap.set(session.parentID, children) - } - } - const result: Session[] = [] - function visit(session: Session) { - result.push(session) - for (const child of childrenMap.get(session.id) ?? []) { - visit(child) - } - } - for (const session of sessions) { - if (!session.parentID) visit(session) - } - return result - } - function sortSessions(a: Session, b: Session) { const now = Date.now() const oneMinuteAgo = now - 60 * 1000 @@ -302,31 +428,179 @@ export default function Layout(props: ParentProps) { return bUpdated - aUpdated } - function scrollToSession(sessionId: string) { + const [scrollSessionKey, setScrollSessionKey] = createSignal(undefined) + + function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return + if (scrollSessionKey() === sessionKey) return const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) - if (element) { - element.scrollIntoView({ block: "center", behavior: "smooth" }) + if (!element) return + const containerRect = scrollContainerRef.getBoundingClientRect() + const elementRect = element.getBoundingClientRect() + if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) { + setScrollSessionKey(sessionKey) + return } + setScrollSessionKey(sessionKey) + element.scrollIntoView({ block: "nearest", behavior: "smooth" }) } const currentProject = createMemo(() => { const directory = params.dir ? base64Decode(params.dir) : undefined if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + + const projects = layout.projects.list() + + const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) + if (sandbox) return sandbox + + const direct = projects.find((p) => p.worktree === directory) + if (direct) return direct + + const [child] = globalSync.child(directory) + const id = child.project + if (!id) return + + const meta = globalSync.data.project.find((p) => p.id === id) + const root = meta?.worktree + if (!root) return + + return projects.find((p) => p.worktree === root) }) - function projectSessions(project: LocalProject | undefined) { - if (!project) return [] - const dirs = [project.worktree, ...(project.sandboxes ?? [])] - const stores = dirs.map((dir) => globalSync.child(dir)[0]) - const sessions = stores - .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory)) - .toSorted(sortSessions) - return sessions.filter((s) => !s.parentID) + createEffect( + on( + () => ({ ready: pageReady(), project: currentProject() }), + (value) => { + if (!value.ready) return + const project = value.project + if (!project) return + const last = server.projects.last() + if (last === project.worktree) return + server.projects.touch(project.worktree) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }), + (value) => { + if (!value.ready) return + if (!value.layoutReady) return + if (!autoselect()) return + if (initialDir) return + if (value.dir) return + if (value.list.length === 0) return + + const last = server.projects.last() + const next = value.list.find((project) => project.worktree === last) ?? value.list[0] + if (!next) return + setAutoselect(false) + openProject(next.worktree, false) + navigateToProject(next.worktree) + }, + { defer: true }, + ), + ) + + const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") + + const workspaceName = (directory: string, projectId?: string, branch?: string) => { + const key = workspaceKey(directory) + const direct = store.workspaceName[key] ?? store.workspaceName[directory] + if (direct) return direct + if (!projectId) return + if (!branch) return + return store.workspaceBranchName[projectId]?.[branch] + } + + const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => { + const key = workspaceKey(directory) + setStore("workspaceName", (prev) => ({ ...(prev ?? {}), [key]: next })) + if (!projectId) return + if (!branch) return + setStore("workspaceBranchName", projectId, (prev) => ({ ...(prev ?? {}), [branch]: next })) } - const currentSessions = createMemo(() => projectSessions(currentProject())) + const workspaceLabel = (directory: string, branch?: string, projectId?: string) => + workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) + + const isWorkspaceEditing = () => editor.active.startsWith("workspace:") + + const workspaceSetting = createMemo(() => { + const project = currentProject() + if (!project) return false + return layout.sidebar.workspaces(project.worktree)() + }) + + createEffect(() => { + if (!pageReady()) return + if (!layoutReady()) return + const project = currentProject() + if (!project) return + + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const existing = store.workspaceOrder[project.worktree] + if (!existing) { + setStore("workspaceOrder", project.worktree, dirs) + return + } + + const keep = existing.filter((d) => dirs.includes(d)) + const missing = dirs.filter((d) => !existing.includes(d)) + const merged = [...keep, ...missing] + + if (merged.length !== existing.length) { + setStore("workspaceOrder", project.worktree, merged) + return + } + + if (merged.some((d, i) => d !== existing[i])) { + setStore("workspaceOrder", project.worktree, merged) + } + }) + + createEffect(() => { + if (!pageReady()) return + if (!layoutReady()) return + const projects = layout.projects.list() + for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { + if (!expanded) continue + const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + if (!project) continue + if (layout.sidebar.workspaces(project.worktree)()) continue + setStore("workspaceExpanded", directory, false) + } + }) + + const currentSessions = createMemo(() => { + const project = currentProject() + if (!project) return [] as Session[] + if (workspaceSetting()) { + const dirs = workspaceIds(project) + const activeDir = params.dir ? base64Decode(params.dir) : "" + const result: Session[] = [] + for (const dir of dirs) { + const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree + const active = dir === activeDir + if (!expanded && !active) continue + const [dirStore] = globalSync.child(dir, { bootstrap: true }) + const dirSessions = dirStore.session + .filter((session) => session.directory === dirStore.path.directory) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted(sortSessions) + result.push(...dirSessions) + } + return result + } + const [projectStore] = globalSync.child(project.worktree) + return projectStore.session + .filter((session) => session.directory === projectStore.path.directory) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted(sortSessions) + }) type PrefetchQueue = { inflight: Set @@ -335,7 +609,7 @@ export default function Layout(props: ParentProps) { running: number } - const prefetchChunk = 200 + const prefetchChunk = 600 const prefetchConcurrency = 1 const prefetchPendingLimit = 6 const prefetchToken = { value: 0 } @@ -366,7 +640,7 @@ export default function Layout(props: ParentProps) { return created } - const prefetchMessages = (directory: string, sessionID: string, token: number) => { + async function prefetchMessages(directory: string, sessionID: string, token: number) { const [, setStore] = globalSync.child(directory) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) @@ -469,89 +743,44 @@ export default function Layout(props: ParentProps) { }) function navigateSessionByOffset(offset: number) { - const projects = layout.projects.list() - if (projects.length === 0) return - - const project = currentProject() - const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1 - - if (projectIndex === -1) { - const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1] - if (targetProject) navigateToProject(targetProject.worktree) - return - } - const sessions = currentSessions() + if (sessions.length === 0) return + const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 let targetIndex: number if (sessionIndex === -1) { targetIndex = offset > 0 ? 0 : sessions.length - 1 } else { - targetIndex = sessionIndex + offset - } - - if (targetIndex >= 0 && targetIndex < sessions.length) { - const session = sessions[targetIndex] - const next = sessions[targetIndex + 1] - const prev = sessions[targetIndex - 1] - - if (offset > 0) { - if (next) prefetchSession(next, "high") - if (prev) prefetchSession(prev) - } - - if (offset < 0) { - if (prev) prefetchSession(prev, "high") - if (next) prefetchSession(next) - } - - if (import.meta.env.DEV) { - navStart({ - dir: base64Encode(session.directory), - from: params.id, - to: session.id, - trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", - }) - } - navigateToSession(session) - queueMicrotask(() => scrollToSession(session.id)) - return + targetIndex = (sessionIndex + offset + sessions.length) % sessions.length } - const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1) - const nextProject = projects[nextProjectIndex] - if (!nextProject) return - - const nextProjectSessions = projectSessions(nextProject) - if (nextProjectSessions.length === 0) { - navigateToProject(nextProject.worktree) - return - } + const session = sessions[targetIndex] + if (!session) return - const index = offset > 0 ? 0 : nextProjectSessions.length - 1 - const targetSession = nextProjectSessions[index] - const nextSession = nextProjectSessions[index + 1] - const prevSession = nextProjectSessions[index - 1] + const next = sessions[(targetIndex + 1) % sessions.length] + const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length] if (offset > 0) { - if (nextSession) prefetchSession(nextSession, "high") + if (next) prefetchSession(next, "high") + if (prev) prefetchSession(prev) } if (offset < 0) { - if (prevSession) prefetchSession(prevSession, "high") + if (prev) prefetchSession(prev, "high") + if (next) prefetchSession(next) } if (import.meta.env.DEV) { navStart({ - dir: base64Encode(targetSession.directory), + dir: base64Encode(session.directory), from: params.id, - to: targetSession.id, + to: session.id, trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", }) } - navigateToSession(targetSession) - queueMicrotask(() => scrollToSession(targetSession.id)) + navigateToSession(session) + queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) } async function archiveSession(session: Session) { @@ -589,8 +818,6 @@ export default function Layout(props: ParentProps) { keybind: "mod+b", onSelect: () => layout.sidebar.toggle(), }, - - { id: "provider.connect", title: "Connect provider", @@ -644,15 +871,42 @@ export default function Layout(props: ParentProps) { keybind: "mod+shift+t", onSelect: () => cycleTheme(1), }, - { - id: "theme.scheme.cycle", - title: "Cycle color scheme", - category: "Theme", - keybind: "mod+shift+s", - onSelect: () => cycleColorScheme(1), - }, ] + for (const [id, definition] of availableThemeEntries()) { + commands.push({ + id: `theme.set.${id}`, + title: `Use theme: ${definition.name ?? id}`, + category: "Theme", + onSelect: () => theme.commitPreview(), + onHighlight: () => { + theme.previewTheme(id) + return () => theme.cancelPreview() + }, + }) + } + + commands.push({ + id: "theme.scheme.cycle", + title: "Cycle color scheme", + category: "Theme", + keybind: "mod+shift+s", + onSelect: () => cycleColorScheme(1), + }) + + for (const scheme of colorSchemeOrder) { + commands.push({ + id: `theme.scheme.${scheme}`, + title: `Use color scheme: ${colorSchemeLabel[scheme]}`, + category: "Theme", + onSelect: () => theme.commitPreview(), + onHighlight: () => { + theme.previewColorScheme(scheme) + return () => theme.cancelPreview() + }, + }) + } + return commands }) @@ -664,12 +918,17 @@ export default function Layout(props: ParentProps) { dialog.show(() => ) } + function openSettings() { + dialog.show(() => ) + } + function createProject() { dialog.show(() => ) } function navigateToProject(directory: string | undefined) { if (!directory) return + server.projects.touch(directory) const lastSession = store.lastSession[directory] navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) layout.mobileSidebar.hide() @@ -686,6 +945,31 @@ export default function Layout(props: ParentProps) { if (navigate) navigateToProject(directory) } + const displayName = (project: LocalProject) => project.name || getFilename(project.worktree) + + async function renameProject(project: LocalProject, next: string) { + if (!project.id) return + const current = displayName(project) + if (next === current) return + const name = next === getFilename(project.worktree) ? "" : next + await globalSDK.client.project.update({ projectID: project.id, name }) + } + + async function renameSession(session: Session, next: string) { + if (next === session.title) return + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + title: next, + }) + } + + const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => { + const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) + if (current === next) return + setWorkspaceName(directory, next, projectId, branch) + } + function closeProject(directory: string) { const index = layout.projects.list().findIndex((x) => x.worktree === directory) const next = layout.projects.list()[index + 1] @@ -694,617 +978,1315 @@ export default function Layout(props: ParentProps) { else navigate("/") } - createEffect(() => { - if (!params.dir || !params.id) return - const directory = base64Decode(params.dir) - const id = params.id - setStore("lastSession", directory, id) - notification.session.markViewed(id) - const project = currentProject() - untrack(() => layout.projects.expand(project?.worktree ?? directory)) - requestAnimationFrame(() => scrollToSession(id)) - }) - - createEffect(() => { - const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 - document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) - }) - function getDraggableId(event: unknown): string | undefined { - if (typeof event !== "object" || event === null) return undefined - if (!("draggable" in event)) return undefined - const draggable = (event as { draggable?: { id?: unknown } }).draggable - if (!draggable) return undefined - return typeof draggable.id === "string" ? draggable.id : undefined + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return "Request failed" } - function handleDragStart(event: unknown) { - const id = getDraggableId(event) - if (!id) return - setStore("activeDraggable", id) - } + const deleteWorkspace = async (directory: string) => { + const current = currentProject() + if (!current) return + if (directory === current.worktree) return - function handleDragOver(event: DragEvent) { - const { draggable, droppable } = event - if (draggable && droppable) { - const projects = layout.projects.list() - const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) - const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) - if (fromIndex !== toIndex && toIndex !== -1) { - layout.projects.move(draggable.id.toString(), toIndex) - } + setBusy(directory, true) + + const result = await globalSDK.client.worktree + .remove({ directory: current.worktree, worktreeRemoveInput: { directory } }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: "Failed to delete workspace", + description: errorMessage(err), + }) + return false + }) + + setBusy(directory, false) + + if (!result) return + + layout.projects.close(directory) + layout.projects.open(current.worktree) + + if (params.dir && base64Decode(params.dir) === directory) { + navigateToProject(current.worktree) } } - function handleDragEnd() { - setStore("activeDraggable", undefined) - } + const resetWorkspace = async (directory: string) => { + const current = currentProject() + if (!current) return + if (directory === current.worktree) return + + setBusy(directory, true) + + const sessions = await globalSDK.client.session + .list({ directory }) + .then((x) => x.data ?? []) + .catch(() => []) + + const result = await globalSDK.client.worktree + .reset({ directory: current.worktree, worktreeResetInput: { directory } }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: "Failed to reset workspace", + description: errorMessage(err), + }) + return false + }) - const ProjectAvatar = (props: { - project: LocalProject - class?: string - expandable?: boolean - notify?: boolean - }): JSX.Element => { - const notification = useNotification() - const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" - const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + if (!result) { + setBusy(directory, false) + return + } - return ( -
- 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined - } - /> - - - 0 && props.notify}> -
- -
+ const archivedAt = Date.now() + await Promise.all( + sessions + .filter((session) => session.time.archived === undefined) + .map((session) => + globalSDK.client.session + .update({ + sessionID: session.id, + directory: session.directory, + time: { archived: archivedAt }, + }) + .catch(() => undefined), + ), ) - } - const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => { - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const current = createMemo(() => base64Decode(params.dir ?? "")) - return ( - - - - - - - - - ) + await globalSDK.client.instance.dispose({ directory }).catch(() => undefined) + + setBusy(directory, false) + + const href = `/${base64Encode(directory)}/session` + navigate(href) + layout.mobileSidebar.hide() + + showToast({ + title: "Workspace reset", + description: "Workspace now matches the default branch.", + }) } - const SessionItem = (props: { - session: Session - slug: string - project: LocalProject - mobile?: boolean - depth?: number - allSessions: Session[] + function DialogDeleteWorkspace(props: { directory: string }) { + const name = createMemo(() => getFilename(props.directory)) + const [data, setData] = createStore({ + status: "loading" as "loading" | "ready" | "error", + dirty: false, + }) + + onMount(() => { + const current = currentProject() + if (!current) { + setData({ status: "error", dirty: false }) + return + } + + globalSDK.client.file + .status({ directory: props.directory }) + .then((x) => { + const files = x.data ?? [] + const dirty = files.length > 0 + setData({ status: "ready", dirty }) + }) + .catch(() => { + setData({ status: "error", dirty: false }) + }) + }) + + const handleDelete = async () => { + await deleteWorkspace(props.directory) + dialog.close() + } + + const description = () => { + if (data.status === "loading") return "Checking for unmerged changes..." + if (data.status === "error") return "Unable to verify git status." + if (!data.dirty) return "No unmerged changes detected." + return "Unmerged changes detected in this workspace." + } + + return ( + +
+
+ Delete workspace "{name()}"? + {description()} +
+
+ + +
+
+
+ ) + } + + function DialogResetWorkspace(props: { directory: string }) { + const name = createMemo(() => getFilename(props.directory)) + const [state, setState] = createStore({ + status: "loading" as "loading" | "ready" | "error", + dirty: false, + sessions: [] as Session[], + }) + + const refresh = async () => { + const sessions = await globalSDK.client.session + .list({ directory: props.directory }) + .then((x) => x.data ?? []) + .catch(() => []) + const active = sessions.filter((session) => session.time.archived === undefined) + setState({ sessions: active }) + } + + onMount(() => { + const current = currentProject() + if (!current) { + setState({ status: "error", dirty: false }) + return + } + + globalSDK.client.file + .status({ directory: props.directory }) + .then((x) => { + const files = x.data ?? [] + const dirty = files.length > 0 + setState({ status: "ready", dirty }) + void refresh() + }) + .catch(() => { + setState({ status: "error", dirty: false }) + }) + }) + + const handleReset = () => { + dialog.close() + void resetWorkspace(props.directory) + } + + const archivedCount = () => state.sessions.length + + const description = () => { + if (state.status === "loading") return "Checking for unmerged changes..." + if (state.status === "error") return "Unable to verify git status." + if (!state.dirty) return "No unmerged changes detected." + return "Unmerged changes detected in this workspace." + } + + const archivedLabel = () => { + const count = archivedCount() + if (count === 0) return "No active sessions will be archived." + const label = count === 1 ? "1 session" : `${count} sessions` + return `${label} will be archived.` + } + + return ( + +
+
+ Reset workspace "{name()}"? + + {description()} {archivedLabel()} This will reset the workspace to match the default branch. + +
+
+ + +
+
+
+ ) + } + + createEffect( + on( + () => ({ ready: pageReady(), dir: params.dir, id: params.id }), + (value) => { + if (!value.ready) return + const dir = value.dir + const id = value.id + if (!dir || !id) return + const directory = base64Decode(dir) + setStore("lastSession", directory, id) + notification.session.markViewed(id) + const expanded = untrack(() => store.workspaceExpanded[directory]) + if (expanded === false) { + setStore("workspaceExpanded", directory, true) + } + requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`)) + }, + { defer: true }, + ), + ) + + createEffect(() => { + const project = currentProject() + if (!project) return + + if (workspaceSetting()) { + const activeDir = params.dir ? base64Decode(params.dir) : "" + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + for (const directory of dirs) { + const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree + const active = directory === activeDir + if (!expanded && !active) continue + globalSync.project.loadSessions(directory) + } + return + } + + globalSync.project.loadSessions(project.worktree) + }) + + function getDraggableId(event: unknown): string | undefined { + if (typeof event !== "object" || event === null) return undefined + if (!("draggable" in event)) return undefined + const draggable = (event as { draggable?: { id?: unknown } }).draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined + } + + function handleDragStart(event: unknown) { + const id = getDraggableId(event) + if (!id) return + setStore("activeProject", id) + } + + function handleDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (draggable && droppable) { + const projects = layout.projects.list() + const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) + const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) + if (fromIndex !== toIndex && toIndex !== -1) { + layout.projects.move(draggable.id.toString(), toIndex) + } + } + } + + function handleDragEnd() { + setStore("activeProject", undefined) + } + + function workspaceIds(project: LocalProject | undefined) { + if (!project) return [] + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const active = currentProject() + const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined + const next = directory && directory !== project.worktree && !dirs.includes(directory) ? [...dirs, directory] : dirs + + const existing = store.workspaceOrder[project.worktree] + if (!existing) return next + + const keep = existing.filter((d) => next.includes(d)) + const missing = next.filter((d) => !existing.includes(d)) + return [...keep, ...missing] + } + + function handleWorkspaceDragStart(event: unknown) { + const id = getDraggableId(event) + if (!id) return + setStore("activeWorkspace", id) + } + + function handleWorkspaceDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const project = currentProject() + if (!project) return + + const ids = workspaceIds(project) + const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) + const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) + if (fromIndex === -1 || toIndex === -1) return + if (fromIndex === toIndex) return + + const result = ids.slice() + const [item] = result.splice(fromIndex, 1) + if (!item) return + result.splice(toIndex, 0, item) + setStore("workspaceOrder", project.worktree, result) + } + + function handleWorkspaceDragEnd() { + setStore("activeWorkspace", undefined) + } + + const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { + const notification = useNotification() + const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) + const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)" + const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + + return ( +
+
+ 0 && props.notify + ? { "-webkit-mask-image": mask, "mask-image": mask } + : undefined + } + /> +
+ 0 && props.notify}> +
+ +
+ ) + } + + const SessionItem = (props: { + session: Session + slug: string + mobile?: boolean + dense?: boolean + popover?: boolean }): JSX.Element => { const notification = useNotification() - const depth = () => props.depth ?? 0 - const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const [sessionStore] = globalSync.child(props.session.directory) - const childSessions = createMemo(() => - props.allSessions.filter((s) => s.parentID === props.session.id).toSorted(sortSessions), - ) - const hasChildren = createMemo(() => childSessions().length > 0) - // TODO: Re-enable layout.sessions.isExpanded when layout context is merged - const isExpanded = createMemo(() => true) const hasPermissions = createMemo(() => { const permissions = sessionStore.permission?.[props.session.id] ?? [] if (permissions.length > 0) return true - for (const child of childSessions()) { + const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id) + for (const child of childSessions) { const childPermissions = sessionStore.permission?.[child.id] ?? [] if (childPermissions.length > 0) return true } return false }) const isWorking = createMemo(() => { - if (props.session.id === params.id) return false if (hasPermissions()) return false const status = sessionStore.session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) + + const tint = createMemo(() => { + const messages = sessionStore.message[props.session.id] + if (!messages) return undefined + const user = messages + .slice() + .reverse() + .find((m) => m.role === "user") + if (!user?.agent) return undefined + + const agent = sessionStore.agent.find((a) => a.name === user.agent) + return agent?.color + }) + + const hoverMessages = createMemo(() => + sessionStore.message[props.session.id]?.filter((message) => message.role === "user"), + ) + const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) + const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened()) + const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) + const isActive = createMemo(() => props.session.id === params.id) + + const messageLabel = (message: Message) => { + const parts = sessionStore.part[message.id] ?? [] + const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) + return text?.text + } + + const item = ( + prefetchSession(props.session, "high")} + onFocus={() => prefetchSession(props.session, "high")} + > +
+
+ }> + + + + +
+ + +
+ + 0}> +
+ + +
+ props.session.title} + onSave={(next) => renameSession(props.session, next)} + class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" + displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" + stopPropagation + /> + + {(summary) => ( +
+ +
+ )} +
+
+
+ ) + return ( - <> -
+ + {item} + + } > - - prefetchSession(props.session, "high")} - onFocus={() => prefetchSession(props.session, "high")} - > -
- - {props.session.title} - - - ) } - const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { - const sortable = createSortable(props.project.worktree) - const slug = createMemo(() => base64Encode(props.project.worktree)) - const defaultWorktree = createMemo(() => base64Encode(props.project.worktree)) - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const [store, setProjectStore] = globalSync.child(props.project.worktree) - const stores = createMemo(() => - [props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]), + const SessionSkeleton = (props: { count?: number }): JSX.Element => { + const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) + return ( +
+ + {() =>
} + +
+ ) + } + + const ProjectDragOverlay = (): JSX.Element => { + const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) + return ( + + {(p) => ( +
+ +
+ )} +
+ ) + } + + const WorkspaceDragOverlay = (): JSX.Element => { + const label = createMemo(() => { + const project = currentProject() + if (!project) return + const directory = store.activeWorkspace + if (!directory) return + + const [workspaceStore] = globalSync.child(directory) + const kind = directory === project.worktree ? "local" : "sandbox" + const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) + return `${kind} : ${name}` + }) + + return ( + + {(value) => ( +
{value()}
+ )} +
) + } + + const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { + const sortable = createSortable(props.directory) + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) + const [menuOpen, setMenuOpen] = createSignal(false) + const [pendingRename, setPendingRename] = createSignal(false) + const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => - stores() - .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory)) + workspaceStore.session + .filter((session) => session.directory === workspaceStore.path.directory) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) - const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) - const hasMoreSessions = createMemo(() => store.session.length >= store.limit) - const loadMoreSessions = async () => { - setProjectStore("limit", (limit) => limit + 5) - await globalSync.project.loadSessions(props.project.worktree) - } - const isExpanded = createMemo(() => - props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded, - ) - const isActive = createMemo(() => { + const local = createMemo(() => props.directory === props.project.worktree) + const active = createMemo(() => { const current = params.dir ? base64Decode(params.dir) : "" - return props.project.worktree === current || props.project.sandboxes?.includes(current) + return current === props.directory + }) + const workspaceValue = createMemo(() => { + const branch = workspaceStore.vcs?.branch + const name = branch ?? getFilename(props.directory) + return workspaceName(props.directory, props.project.id, branch) ?? name }) - const handleOpenChange = (open: boolean) => { - if (open) layout.projects.expand(props.project.worktree) - else layout.projects.collapse(props.project.worktree) + const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local()) + const boot = createMemo(() => open() || active()) + const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0) + const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length) + const busy = createMemo(() => isBusy(props.directory)) + const loadMore = async () => { + if (!local()) return + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.directory) + } + + const workspaceEditActive = createMemo(() => editorOpen(`workspace:${props.directory}`)) + + const openWrapper = (value: boolean) => { + setStore("workspaceExpanded", props.directory, value) + if (value) return + if (editorOpen(`workspace:${props.directory}`)) closeEditor() } - const expanded = () => props.mobile || layout.sidebar.opened() + + createEffect(() => { + if (!boot()) return + globalSync.child(props.directory, { bootstrap: true }) + }) + + const header = () => ( +
+
+ }> + + +
+ {local() ? "local" : "sandbox"} : + + {workspaceStore.vcs?.branch ?? getFilename(props.directory)} + + } + > + { + const trimmed = next.trim() + if (!trimmed) return + renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch) + setEditor("value", workspaceValue()) + }} + class="text-14-medium text-text-base min-w-0 truncate" + displayClass="text-14-medium text-text-base min-w-0 truncate" + editing={workspaceEditActive()} + stopPropagation={false} + openOnDblClick={false} + /> + + +
+ ) return ( - // @ts-ignore -
- - - - - - + + +
+ ) + } + + const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { + const sortable = createSortable(props.project.worktree) + const selected = createMemo(() => { + const current = params.dir ? base64Decode(params.dir) : "" + return props.project.worktree === current || props.project.sandboxes?.includes(current) + }) + + const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) + const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)()) + const [open, setOpen] = createSignal(false) + + const label = (directory: string) => { + const [data] = globalSync.child(directory) + const kind = directory === props.project.worktree ? "local" : "sandbox" + const name = workspaceLabel(directory, data.vcs?.branch, props.project.id) + return `${kind} : ${name}` + } + + const sessions = (directory: string) => { + const [data] = globalSync.child(directory) + return data.session + .filter((session) => session.directory === data.path.directory) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted(sortSessions) + .slice(0, 2) + } + + const projectSessions = () => { + const [data] = globalSync.child(props.project.worktree) + return data.session + .filter((session) => session.directory === data.path.directory) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted(sortSessions) + .slice(0, 2) + } + + const trigger = ( + + ) + + return ( + // @ts-ignore +
+ +
+
{displayName(props.project)}
+
Recent sessions
+
+ {(session) => ( )} - -
-
-
- - -
- - New session - -
-
-
+ } + > + + {(directory) => ( +
+
+
+
+ {label(directory)}
+ + {(session) => ( + + )} +
- - -
- -
-
- - - - - - - - - - + )} +
+ +
+
+ +
+
+
) } - const ProjectDragOverlay = (): JSX.Element => { - const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable)) + const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree) + const slug = createMemo(() => base64Encode(props.project.worktree)) + const sessions = createMemo(() => + workspaceStore.session + .filter((session) => session.directory === workspaceStore.path.directory) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted(sortSessions), + ) + const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0) + const hasMore = createMemo(() => workspaceStore.sessionTotal > workspaceStore.session.length) + const loadMore = async () => { + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.project.worktree) + } + return ( - - {(p) => ( -
- -
- )} -
+
{ + if (!props.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar" + style={{ "overflow-anchor": "none" }} + > + +
) } const SidebarContent = (sidebarProps: { mobile?: boolean }) => { const expanded = () => sidebarProps.mobile || layout.sidebar.opened() + + const sync = useGlobalSync() + const project = createMemo(() => currentProject()) + const projectName = createMemo(() => { + const current = project() + if (!current) return "" + return current.name || getFilename(current.worktree) + }) + const projectId = createMemo(() => project()?.id ?? "") + const workspaces = createMemo(() => workspaceIds(project())) + + const createWorkspace = async () => { + const current = project() + if (!current) return + + const created = await globalSDK.client.worktree + .create({ directory: current.worktree }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: "Failed to create workspace", + description: errorMessage(err), + }) + return undefined + }) + + if (!created?.directory) return + + globalSync.child(created.directory) + navigate(`/${base64Encode(created.directory)}/session`) + } + + command.register(() => [ + { + id: "workspace.new", + title: "New workspace", + category: "Workspace", + keybind: "mod+shift+w", + disabled: !layout.sidebar.workspaces(project()?.worktree ?? "")(), + onSelect: createWorkspace, + }, + ]) + + const homedir = createMemo(() => sync.data.path.home) + return ( -
-
- +
+ +
+
+
+ + + +
+ p.worktree)}> + + {(project) => } + + + + + +
+ + + +
+
+
+ + + + + platform.openLink("https://opencode.ai/desktop-feedback")} + /> + +
+
+ + - -
- - - + +
+
+ +
+ + } + > + <> +
+ + + +
+
+ + + +
{ + if (!sidebarProps.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar" + style={{ "overflow-anchor": "none" }} + > + + + {(directory) => ( + + )} + + +
+ + + +
+
+ - - + + )} - - - -
{ - 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 && expanded()}> -
-
-
Getting started
-
OpenCode includes free models so you can start immediately.
-
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
-
- + 0 && providers.paid().length === 0}> +
+
+
+
Getting started
+
OpenCode includes free models so you can start immediately.
+
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
+
- +
- - 0}> - +
+
+
- - - - - - - - - - - - - - -
- - -
-
- -
- v{__APP_VERSION__} ({__COMMIT_HASH__}) + + +
+ + +
+
+ v{__APP_VERSION__} ({__COMMIT_HASH__}) +
+
- -
+
+
+
) } return ( -
+
+
-
+
@@ -1313,7 +2295,7 @@ export default function Layout(props: ParentProps) {
e.stopPropagation()} > -
-
- -
- - {props.children} - -
+
+ }> + +
+ {props.children} +
+
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4b06473f941..f40c18973ba 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -434,6 +434,19 @@ export default function Page() { terminal.new() }) + createEffect( + on( + () => terminal.all().length, + (count, prevCount) => { + if (prevCount !== undefined && prevCount > 0 && count === 0) { + if (view().terminal.opened()) { + view().terminal.toggle() + } + } + }, + ), + ) + createEffect( on( () => visibleUserMessages().at(-1)?.id, @@ -505,7 +518,7 @@ export default function Page() { title: "New terminal", description: "Create a new terminal tab", category: "Terminal", - keybind: "ctrl+shift+`", + keybind: "ctrl+alt+t", onSelect: () => terminal.new(), }, { diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts new file mode 100644 index 00000000000..e8db0bf7b9a --- /dev/null +++ b/packages/app/src/utils/sound.ts @@ -0,0 +1,44 @@ +import nope01 from "@opencode-ai/ui/audio/nope-01.aac" +import nope02 from "@opencode-ai/ui/audio/nope-02.aac" +import nope03 from "@opencode-ai/ui/audio/nope-03.aac" +import nope04 from "@opencode-ai/ui/audio/nope-04.aac" +import nope05 from "@opencode-ai/ui/audio/nope-05.aac" +import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac" +import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac" +import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac" +import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac" +import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac" +import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac" +import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac" + +export const SOUND_OPTIONS = [ + { id: "staplebops-01", label: "Boopy", src: staplebops01 }, + { id: "staplebops-02", label: "Beepy", src: staplebops02 }, + { id: "staplebops-03", label: "Staplebops 03", src: staplebops03 }, + { id: "staplebops-04", label: "Staplebops 04", src: staplebops04 }, + { id: "staplebops-05", label: "Staplebops 05", src: staplebops05 }, + { id: "staplebops-06", label: "Staplebops 06", src: staplebops06 }, + { id: "staplebops-07", label: "Staplebops 07", src: staplebops07 }, + { id: "nope-01", label: "Nope 01", src: nope01 }, + { id: "nope-02", label: "Nope 02", src: nope02 }, + { id: "nope-03", label: "Oopsie", src: nope03 }, + { id: "nope-04", label: "Nope 04", src: nope04 }, + { id: "nope-05", label: "Nope 05", src: nope05 }, +] as const + +export type SoundOption = (typeof SOUND_OPTIONS)[number] +export type SoundID = SoundOption["id"] + +const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record + +export function soundSrc(id: string | undefined) { + if (!id) return + if (!(id in soundById)) return + return soundById[id as SoundID] +} + +export function playSound(src: string | undefined) { + if (typeof Audio === "undefined") return + if (!src) return + void new Audio(src).play().catch(() => undefined) +} diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 7638b54d2db..211eb8e1e74 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.27", + "version": "1.1.28", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f5620a54f38..d2f48fc58f7 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.1.27", + "version": "1.1.28", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 0a79603fdc4..4e3e4e34916 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.27", + "version": "1.1.28", "$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 fd1f5702d5e..1f39b81387c 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.27", + "version": "1.1.28", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9024a6b6100..0fb77dd6121 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@shuvcode/desktop", "private": true, - "version": "1.1.27", + "version": "1.1.28", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 02f23ec772d..f24667e2ccf 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -1,5 +1,7 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu" import { type as ostype } from "@tauri-apps/plugin-os" +import { invoke } from "@tauri-apps/api/core" +import { relaunch } from "@tauri-apps/plugin-process" import { runUpdater, UPDATER_ENABLED } from "./updater" import { installCli } from "./cli" @@ -24,6 +26,17 @@ export async function createMenu() { action: () => installCli(), text: "Install CLI...", }), + await MenuItem.new({ + action: async () => window.location.reload(), + text: "Reload Webview", + }), + await MenuItem.new({ + action: async () => { + await invoke("kill_sidecar").catch(() => undefined) + await relaunch().catch(() => undefined) + }, + text: "Restart", + }), await PredefinedMenuItem.new({ item: "Separator", }), diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index c96acbadda5..f7f890119e7 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.27", + "version": "1.1.28", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index d7762cf1375..a3ec52e4600 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.1.27" +version = "1.1.28" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.27/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.27/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.27/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.27/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.27/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 7f581dcb5f2..58fe9593441 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.27", + "version": "1.1.28", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d5809a7eb1d..26b3e1ccf3a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.27", + "version": "1.1.28", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index ba2155cb692..3ea850d1abd 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises" +import path from "node:path" + const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd() const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session" const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e" @@ -7,7 +10,21 @@ const providerID = parts[0] ?? "opencode" const modelID = parts[1] ?? "gpt-5-nano" const now = Date.now() +const seedModelsCache = async () => { + const modelsPath = process.env.MODELS_DEV_API_JSON + if (!modelsPath) return + + const file = Bun.file(modelsPath) + if (!(await file.exists())) return + + const { Global } = await import("../src/global") + await fs.mkdir(Global.Path.cache, { recursive: true }) + const target = path.join(Global.Path.cache, "models.json") + await Bun.write(target, await file.text()) +} + const seed = async () => { + await seedModelsCache() const { Instance } = await import("../src/project/instance") const { InstanceBootstrap } = await import("../src/project/bootstrap") const { Session } = await import("../src/session") diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 818d3e828a9..fa57e3c76cd 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -12,6 +12,7 @@ import { type PermissionOption, type PlanEntry, type PromptRequest, + type Role, type SetSessionModelRequest, type SetSessionModeRequest, type SetSessionModeResponse, @@ -687,7 +688,8 @@ export namespace ACP { break } } else if (part.type === "text") { - if (part.text && !part.ignored) { + if (part.text) { + const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined await this.connection .sessionUpdate({ sessionId, @@ -696,6 +698,7 @@ export namespace ACP { content: { type: "text", text: part.text, + ...(audience && { annotations: { audience } }), }, }, }) @@ -968,14 +971,20 @@ export namespace ACP { const agent = session.modeId ?? (await AgentModule.defaultAgent()) const parts: Array< - { type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string } + | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } + | { type: "file"; url: string; filename: string; mime: string } > = [] for (const part of params.prompt) { switch (part.type) { case "text": + const audience = part.annotations?.audience + const forAssistant = audience?.length === 1 && audience[0] === "assistant" + const forUser = audience?.length === 1 && audience[0] === "user" parts.push({ type: "text" as const, text: part.text, + ...(forAssistant && { synthetic: true }), + ...(forUser && { ignored: true }), }) break case "image": { 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 b6f78ad801a..02a86072538 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -547,16 +547,22 @@ export function Prompt(props: PromptProps) { } else if ( inputText.startsWith("/") && iife(() => { - const command = inputText.split(" ")[0].slice(1) - console.log(command) + const firstLine = inputText.split("\n")[0] + const command = firstLine.split(" ")[0].slice(1) return sync.data.command.some((x) => x.name === command) }) ) { - let [command, ...args] = inputText.split(" ") + // Parse command from first line, preserve multi-line content in arguments + const firstLineEnd = inputText.indexOf("\n") + const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) + const [command, ...firstLineArgs] = firstLine.split(" ") + const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) + const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") + sdk.client.session.command({ sessionID, command: command.slice(1), - arguments: args.join(" "), + arguments: args, agent: local.agent.current().name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, messageID, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 08e913d5009..eed307cebcd 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -22,6 +22,7 @@ import { ScrollBoxRenderable, addDefaultParsers, MacOSScrollAccel, + MouseEvent, type ScrollAcceleration, TextAttributes, RGBA, @@ -139,6 +140,11 @@ export function Session() { const dimensions = useTerminalDimensions() const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "hide") const [sidebarOpen, setSidebarOpen] = createSignal(false) + const sidebarHandleWidth = 2 + const sidebarMinWidth = 20 + const sidebarMaxWidth = 80 + const clampSidebarWidth = (value: number) => Math.max(sidebarMinWidth, Math.min(sidebarMaxWidth, value)) + const [sidebarWidth, setSidebarWidth] = createSignal(clampSidebarWidth(kv.get("sidebar_width", 42))) const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") @@ -147,6 +153,11 @@ export function Session() { const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) + const [draggingSidebar, setDraggingSidebar] = createSignal(false) + const [sidebarDragStartX, setSidebarDragStartX] = createSignal(0) + const [sidebarDragStartWidth, setSidebarDragStartWidth] = createSignal(0) + const [sidebarHandleHover, setSidebarHandleHover] = createSignal(false) + const showSidebarHandle = createMemo(() => draggingSidebar() || sidebarHandleHover()) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -156,7 +167,46 @@ export function Session() { return false }) const showTimestamps = createMemo(() => timestamps() === "show") - const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) + const contentWidth = createMemo(() => { + if (!sidebarVisible() || !wide()) return dimensions().width - 4 + return dimensions().width - sidebarWidth() - sidebarHandleWidth - 4 + }) + const overlaySidebarWidth = createMemo(() => Math.min(sidebarWidth(), Math.max(0, dimensions().width - 4))) + + createEffect( + on( + () => kv.get("sidebar_width", 42), + (value) => { + if (!kv.ready) return + setSidebarWidth(clampSidebarWidth(value)) + }, + ), + ) + + const saveSidebarWidth = () => { + kv.set("sidebar_width", sidebarWidth()) + } + + const startSidebarDrag = (x: number) => { + setDraggingSidebar(true) + setSidebarDragStartX(x) + setSidebarDragStartWidth(sidebarWidth()) + } + + const updateSidebarDrag = (x: number) => { + if (!draggingSidebar()) return + setSidebarWidth(clampSidebarWidth(sidebarDragStartWidth() + (sidebarDragStartX() - x))) + } + + const endSidebarDrag = () => { + if (!draggingSidebar()) return + setDraggingSidebar(false) + saveSidebarWidth() + } + + createEffect(() => { + if (!sidebarVisible() || !wide()) setDraggingSidebar(false) + }) const scrollAcceleration = createMemo(() => { const tui = sync.data.config.tui @@ -1107,7 +1157,22 @@ export function Session() { - + <> + startSidebarDrag(event.x)} + onMouseDrag={(event: MouseEvent) => updateSidebarDrag(event.x)} + onMouseUp={endSidebarDrag} + onMouseDragEnd={endSidebarDrag} + onMouseOver={() => setSidebarHandleHover(true)} + onMouseOut={() => setSidebarHandleHover(false)} + /> + + - + diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0e91cca6a0a..0ed15ef1f0f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,7 +12,13 @@ import { lazy } from "../util/lazy" import { NamedError } from "@opencode-ai/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" -import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" +import { + type ParseError as JsoncParseError, + applyEdits, + modify, + parse as parseJsonc, + printParseErrorCode, +} from "jsonc-parser" import { Instance } from "../project/instance" import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" @@ -20,6 +26,8 @@ import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" import { existsSync } from "fs" import { Bus } from "@/bus" +import { GlobalBus } from "@/bus/global" +import { Event } from "../server/event" export namespace Config { const log = Log.create({ service: "config" }) @@ -1262,6 +1270,10 @@ export namespace Config { return state().then((x) => x.config) } + export async function getGlobal() { + return global() + } + export async function update(config: Info) { const filepath = path.join(Instance.directory, "config.json") const existing = await loadFile(filepath) @@ -1269,6 +1281,100 @@ export namespace Config { await Instance.dispose() } + function globalConfigFile() { + const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => + path.join(Global.Path.config, file), + ) + for (const file of candidates) { + if (existsSync(file)) return file + } + return candidates[0] + } + + function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) + } + + function patchJsonc(input: string, patch: unknown, path: string[] = []): string { + if (!isRecord(patch)) { + const edits = modify(input, path, patch, { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + }, + }) + return applyEdits(input, edits) + } + + return Object.entries(patch).reduce((result, [key, value]) => { + if (value === undefined) return result + return patchJsonc(result, value, [...path, key]) + }, input) + } + + function parseConfig(text: string, filepath: string): Info { + const errors: JsoncParseError[] = [] + const data = parseJsonc(text, errors, { allowTrailingComma: true }) + if (errors.length) { + const lines = text.split("\n") + const errorDetails = errors + .map((e) => { + const beforeOffset = text.substring(0, e.offset).split("\n") + const line = beforeOffset.length + const column = beforeOffset[beforeOffset.length - 1].length + 1 + const problemLine = lines[line - 1] + + const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` + if (!problemLine) return error + + return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` + }) + .join("\n") + + throw new JsonError({ + path: filepath, + message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`, + }) + } + + const parsed = Info.safeParse(data) + if (parsed.success) return parsed.data + + throw new InvalidError({ + path: filepath, + issues: parsed.error.issues, + }) + } + + export async function updateGlobal(config: Info) { + const filepath = globalConfigFile() + const before = await Bun.file(filepath) + .text() + .catch((err) => { + if (err.code === "ENOENT") return "{}" + throw new JsonError({ path: filepath }, { cause: err }) + }) + + if (!filepath.endsWith(".jsonc")) { + const existing = parseConfig(before, filepath) + await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2)) + } else { + const next = patchJsonc(before, config) + parseConfig(next, filepath) + await Bun.write(filepath, next) + } + + global.reset() + await Instance.disposeAll() + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }) + } + export async function directories() { return state().then((x) => x.directories) } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 44f8a0a3a4a..c4a4747777e 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -32,11 +32,16 @@ export namespace FileWatcher { ), } - const watcher = lazy(() => { - const binding = require( - `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`, - ) - return createWrapper(binding) as typeof import("@parcel/watcher") + const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { + try { + const binding = require( + `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`, + ) + return createWrapper(binding) as typeof import("@parcel/watcher") + } catch (error) { + log.error("failed to load watcher binding", { error }) + return + } }) const state = Instance.state( @@ -54,6 +59,10 @@ export namespace FileWatcher { return {} } log.info("watcher backend", { platform: process.platform, backend }) + + const w = watcher() + if (!w) return {} + const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { if (err) return for (const evt of evts) { @@ -67,7 +76,7 @@ export namespace FileWatcher { const cfgIgnores = cfg.watcher?.ignore ?? [] if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - const pending = watcher().subscribe(Instance.directory, subscribe, { + const pending = w.subscribe(Instance.directory, subscribe, { ignore: [...FileIgnore.PATTERNS, ...cfgIgnores], backend, }) @@ -89,7 +98,7 @@ export namespace FileWatcher { if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { const gitDirContents = await readdir(vcsDir).catch(() => []) const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") - const pending = watcher().subscribe(vcsDir, subscribe, { + const pending = w.subscribe(vcsDir, subscribe, { ignore: ignoreList, backend, }) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index cbf64294ed3..bd4fccc2c28 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -795,6 +795,11 @@ export namespace MCP { // The SDK has already added the state parameter to the authorization URL // We just need to open the browser log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState }) + + // Register the callback BEFORE opening the browser to avoid race condition + // when the IdP has an active SSO session and redirects immediately + const callbackPromise = McpOAuthCallback.waitForCallback(oauthState) + try { const subprocess = await open(authorizationUrl) // The open package spawns a detached process and returns immediately. @@ -822,8 +827,8 @@ export namespace MCP { Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }) } - // Wait for callback using the OAuth state parameter - const code = await McpOAuthCallback.waitForCallback(oauthState) + // Wait for callback using the already-registered promise + const code = await callbackPromise // Validate and clear the state const storedState = await McpAuth.getOAuthState(mcpName) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index ba4fd5b8f95..e9490756811 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -201,6 +201,9 @@ export namespace Pty { log.info("session exited", { id, exitCode }) session.info.status = "exited" Bus.publish(Event.Exited, { id, exitCode }) + for (const ws of session.subscribers) { + ws.close() + } state().delete(id) }) Bus.publish(Event.Created, { info }) diff --git a/packages/opencode/src/server/event.ts b/packages/opencode/src/server/event.ts new file mode 100644 index 00000000000..49325b2bb63 --- /dev/null +++ b/packages/opencode/src/server/event.ts @@ -0,0 +1,7 @@ +import { BusEvent } from "@/bus/bus-event" +import z from "zod" + +export const Event = { + Connected: BusEvent.define("server.connected", z.object({})), + Disposed: BusEvent.define("global.disposed", z.object({})), +} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 50f262553d3..0c34923aa4d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -182,21 +182,21 @@ export namespace SessionProcessor { const match = toolcalls[value.toolCallId] log.info("tool-result", { toolCallId: value.toolCallId, tool: match?.tool }) if (match && match.state.status === "running") { - await Session.updatePart({ - ...match, - state: { - status: "completed", - input: value.output.modifiedInput ?? value.input, - output: value.output.output, - metadata: value.output.metadata, - title: value.output.title, - time: { - start: match.state.time.start, - end: Date.now(), + await Session.updatePart({ + ...match, + state: { + status: "completed", + input: value.output.modifiedInput ?? value.input ?? match.state.input, + output: value.output.output, + metadata: value.output.metadata, + title: value.output.title, + time: { + start: match.state.time.start, + end: Date.now(), + }, + attachments: value.output.attachments, }, - attachments: value.output.attachments, - }, - }) + }) delete toolcalls[value.toolCallId] } @@ -210,7 +210,7 @@ export namespace SessionProcessor { ...match, state: { status: "error", - input: value.input, + input: value.input ?? match.state.input, error: (value.error as any).toString(), time: { start: match.state.time.start, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ee2d786d2fa..36e845f8047 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1734,8 +1734,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (position === last) return args.slice(argIndex).join(" ") return args[argIndex] }) + const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS") let template = withArgs.replaceAll("$ARGUMENTS", input.arguments) + // If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders) + // but user provided arguments, append them to the template + if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) { + template = template + "\n\n" + input.arguments + } + const shell = ConfigMarkdown.shell(template) if (shell.length > 0) { const results = await Promise.all( diff --git a/packages/opencode/src/session/prompt/codex_header.txt b/packages/opencode/src/session/prompt/codex_header.txt index daad8237758..b4cf311caca 100644 --- a/packages/opencode/src/session/prompt/codex_header.txt +++ b/packages/opencode/src/session/prompt/codex_header.txt @@ -40,7 +40,13 @@ Exception: If working within an existing website or design system, preserve the You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. - Default: be very concise; friendly coding teammate tone. -- Ask only when needed; suggest ideas; mirror the user's style. +- Default: do the work without asking questions. Treat short tasks as sufficient direction; infer missing details by reading the codebase and following existing conventions. +- Questions: only ask when you are truly blocked after checking relevant context AND you cannot safely pick a reasonable default. This usually means one of: + * The request is ambiguous in a way that materially changes the result and you cannot disambiguate by reading the repo. + * The action is destructive/irreversible, touches production, or changes billing/security posture. + * You need a secret/credential/value that cannot be inferred (API key, account id, etc.). +- If you must ask: do all non-blocked work first, then ask exactly one targeted question, include your recommended default, and state what would change based on the answer. +- Never ask permission questions like "Should I proceed?" or "Do you want me to run tests?"; proceed with the most reasonable option and mention what you did. - For substantial work, summarize clearly; follow final‑answer formatting. - Skip heavy formatting for simple confirmations. - Don't dump large files you've written; reference paths only. diff --git a/packages/opencode/test/tool/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json index 7f55e04a568..95970ad394d 100644 --- a/packages/opencode/test/tool/fixtures/models-api.json +++ b/packages/opencode/test/tool/fixtures/models-api.json @@ -33449,5 +33449,29 @@ "limit": { "context": 131072, "output": 32768 } } } + }, + "gitlab": { + "id": "gitlab", + "env": ["GITLAB_API_KEY"], + "npm": "@gitlab/gitlab-ai-provider", + "name": "GitLab", + "doc": "https://docs.gitlab.com/ee/user/gitlab_duo/", + "models": { + "gitlab-duo": { + "id": "gitlab-duo", + "name": "GitLab Duo", + "family": "gitlab-duo", + "attachment": false, + "reasoning": false, + "tool_call": true, + "temperature": true, + "release_date": "2024-02-01", + "last_updated": "2024-02-01", + "modalities": { "input": ["text"], "output": ["text"] }, + "open_weights": false, + "cost": { "input": 0, "output": 0 }, + "limit": { "context": 8192, "output": 2048 } + } + } } } diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 5cc3191e1e2..370e124e7c3 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.1.27", + "version": "1.1.28", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 45de3bcb99f..08a8170269f 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -3,7 +3,8 @@ "name": "@opencode-ai/script", "license": "MIT", "devDependencies": { - "@types/bun": "catalog:" + "@types/bun": "catalog:", + "@types/semver": "catalog:" }, "exports": { ".": "./src/index.ts" diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index daad073d493..901af8470a5 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -1,5 +1,6 @@ import { $ } from "bun" import path from "path" +import { satisfies } from "semver" const rootPkgPath = path.resolve(import.meta.dir, "../../../package.json") const rootPkg = await Bun.file(rootPkgPath).json() @@ -9,8 +10,11 @@ if (!expectedBunVersion) { throw new Error("packageManager field not found in root package.json") } -if (process.versions.bun !== expectedBunVersion) { - throw new Error(`This script requires bun@${expectedBunVersion}, but you are using bun@${process.versions.bun}`) +// relax version requirement +const expectedBunVersionRange = `^${expectedBunVersion}` + +if (!satisfies(process.versions.bun, expectedBunVersionRange)) { + throw new Error(`This script requires bun@${expectedBunVersionRange}, but you are using bun@${process.versions.bun}`) } const env = { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 93d498490b0..6bfe22655df 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.1.27", + "version": "1.1.28", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a217fb7a574..4ef2bb0e32d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -48,6 +48,20 @@ export type EventServerInstanceDisposed = { } } +export type EventServerConnected = { + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -965,25 +979,13 @@ export type EventPtyDeleted = { } } -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventProjectUpdated | EventServerInstanceDisposed + | EventServerConnected + | EventGlobalDisposed | EventLspClientDiagnostics | EventLspUpdated | EventFileEdited @@ -1022,8 +1024,6 @@ export type Event = | EventPtyUpdated | EventPtyExited | EventPtyDeleted - | EventGlobalDisposed - | EventServerConnected export type GlobalEvent = { directory: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c1be820f262..14008f32307 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5863,6 +5863,34 @@ }, "required": ["type", "properties"] }, + "Event.server.connected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.connected" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["type", "properties"] + }, + "Event.global.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "global.disposed" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["type", "properties"] + }, "Event.lsp.client.diagnostics": { "type": "object", "properties": { @@ -8075,34 +8103,6 @@ }, "required": ["type", "properties"] }, - "Event.global.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "global.disposed" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, - "Event.server.connected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.connected" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, "Event": { "anyOf": [ { @@ -8117,6 +8117,12 @@ { "$ref": "#/components/schemas/Event.server.instance.disposed" }, + { + "$ref": "#/components/schemas/Event.server.connected" + }, + { + "$ref": "#/components/schemas/Event.global.disposed" + }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" }, @@ -8218,12 +8224,6 @@ }, { "$ref": "#/components/schemas/Event.pty.deleted" - }, - { - "$ref": "#/components/schemas/Event.global.disposed" - }, - { - "$ref": "#/components/schemas/Event.server.connected" } ] }, diff --git a/packages/slack/package.json b/packages/slack/package.json index c3ae3ea91cc..de50bea5bbb 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.27", + "version": "1.1.28", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index e940631468f..be9f8b923e2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.27", + "version": "1.1.28", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/accordion.css b/packages/ui/src/components/accordion.css index 5724307cd2a..7bf287fe549 100644 --- a/packages/ui/src/components/accordion.css +++ b/packages/ui/src/components/accordion.css @@ -2,7 +2,7 @@ display: flex; flex-direction: column; align-items: flex-start; - gap: 0px; + gap: 8px; align-self: stretch; [data-slot="accordion-item"] { @@ -10,7 +10,6 @@ display: flex; flex-direction: column; align-items: flex-start; - gap: 0px; align-self: stretch; overflow: clip; @@ -34,6 +33,7 @@ background-color: var(--surface-base); border: 1px solid var(--border-weak-base); + border-radius: var(--radius-md); overflow: clip; color: var(--text-strong); transition: background-color 0.15s ease; @@ -59,12 +59,9 @@ } &[data-expanded] { - margin-top: 8px; - margin-bottom: 8px; - [data-slot="accordion-trigger"] { - border-top-left-radius: var(--radius-md); - border-top-right-radius: var(--radius-md); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } [data-slot="accordion-content"] { @@ -73,68 +70,11 @@ border-bottom-left-radius: var(--radius-md); border-bottom-right-radius: var(--radius-md); } - - & + [data-slot="accordion-item"] { - margin-top: 8px; - - [data-slot="accordion-trigger"] { - border-top-left-radius: var(--radius-md); - border-top-right-radius: var(--radius-md); - } - } - } - - &:has(+ [data-slot="accordion-item"][data-expanded]) { - margin-bottom: 8px; - - &[data-closed] { - border-bottom-left-radius: var(--radius-md); - border-bottom-right-radius: var(--radius-md); - - [data-slot="accordion-trigger"] { - border-bottom-left-radius: var(--radius-md); - border-bottom-right-radius: var(--radius-md); - } - } - } - - &[data-closed] + &[data-closed] { - [data-slot="accordion-trigger"] { - border-top: none; - } - } - - &:first-child { - margin-top: 0px; - - &[data-closed] { - [data-slot="accordion-trigger"] { - border-top-left-radius: var(--radius-md); - border-top-right-radius: var(--radius-md); - } - } - } - - &:last-child { - margin-bottom: 0px; - - &[data-closed] { - [data-slot="accordion-trigger"] { - border-bottom-left-radius: var(--radius-md); - border-bottom-right-radius: var(--radius-md); - } - } } [data-slot="accordion-content"] { overflow: hidden; width: 100%; - - /* animation: slideUp 250ms cubic-bezier(0.87, 0, 0.13, 1); */ - /**/ - /* &[data-expanded] { */ - /* animation: slideDown 250ms cubic-bezier(0.87, 0, 0.13, 1); */ - /* } */ } } } diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index b62835a1b8d..d553eecf6e2 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -30,6 +30,7 @@ flex-direction: column; align-items: center; justify-items: start; + overflow: visible; &[data-size="sm"] { width: min(calc(100vw - 16px), 360px); @@ -54,18 +55,21 @@ width: 100%; max-height: 100%; min-height: 280px; + overflow: auto; + + /* Hide scrollbar */ + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } /* padding: 8px; */ /* padding: 8px 8px 0 8px; */ - border: 1px solid - light-dark( - color-mix(in oklch, var(--border-base) 30%, transparent), - color-mix(in oklch, var(--border-base) 50%, transparent) - ); border-radius: var(--radius-xl); background: var(--surface-raised-stronger-non-alpha); background-clip: padding-box; - box-shadow: var(--shadow-lg); + box-shadow: var(--shadow-lg-border-base); /* animation: contentHide 300ms ease-in forwards; */ /**/ @@ -123,7 +127,7 @@ display: flex; flex-direction: column; flex: 1; - overflow-y: auto; + overflow: hidden; &:focus-visible { outline: none; @@ -144,6 +148,16 @@ } } } + + &[data-size="large"] [data-slot="dialog-container"] { + width: min(calc(100vw - 32px), 800px); + height: min(calc(100vh - 32px), 600px); + } + + &[data-size="x-large"] [data-slot="dialog-container"] { + width: min(calc(100vw - 32px), 960px); + height: min(calc(100vh - 32px), 600px); + } } @keyframes overlayShow { diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 432b24dc3ec..2f1f75f5158 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -6,18 +6,23 @@ export interface DialogProps extends ParentProps { title?: JSXElement description?: JSXElement action?: JSXElement - size?: "sm" | "md" | "lg" + size?: "sm" | "md" | "lg" | "normal" | "large" | "x-large" class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] fit?: boolean } export function Dialog(props: DialogProps) { + const containerSize = props.size === "sm" || props.size === "md" || props.size === "lg" ? props.size : undefined + const dialogSize = + props.size === "large" || props.size === "x-large" || props.size === "normal" ? props.size : "normal" + return ( -
-
+
+
`, "cloud-upload": ``, trash: ``, + sliders: ``, + keyboard: ``, + selector: ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 6929f6b7347..2815805adf0 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -133,7 +133,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const index = selected ? all.indexOf(selected) : -1 props.onKeyEvent?.(e, selected) - if (e.key === "Enter") { + if (e.key === "Enter" && !e.isComposing) { e.preventDefault() if (selected) handleSelect(selected, index) } else { diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index a5dbdf36d06..137e6a4d77a 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -32,7 +32,7 @@ justify-content: center; border-radius: 6px; overflow: hidden; - background: var(--surface-base); + background: var(--surface-weak); border: 1px solid var(--border-weak-base); transition: border-color 0.15s ease; @@ -76,7 +76,8 @@ white-space: pre-wrap; word-break: break-word; overflow: hidden; - background: var(--surface-base); + background: var(--surface-weak); + border: 1px solid var(--border-weak-base); padding: 8px 12px; border-radius: 4px; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 24b1ee39326..401613ff588 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -992,6 +992,7 @@ ToolRegistry.register({ render(props) { const diffComponent = useDiffComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) + const filename = () => getFilename(props.input.filePath ?? "") return (
-
Edit
-
- +
Edit {filename()}
+ +
{getDirectory(props.input.filePath!)} - - {getFilename(props.input.filePath ?? "")} -
+
+
@@ -1041,6 +1041,7 @@ ToolRegistry.register({ render(props) { const codeComponent = useCodeComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) + const filename = () => getFilename(props.input.filePath ?? "") return (
-
Write
-
- +
Write {filename()}
+ +
{getDirectory(props.input.filePath!)} - - {getFilename(props.input.filePath ?? "")} -
+
+
{/* */}
diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index afa88c5f2ec..efcda4e50a4 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -47,21 +47,62 @@ } } } + + &[data-trigger-style="settings"] { + [data-slot="select-select-trigger"] { + padding: 6px 6px 6px 12px; + box-shadow: none; + border-radius: 6px; + min-width: 160px; + height: 32px; + justify-content: flex-end; + gap: 12px; + background-color: transparent; + + [data-slot="select-select-trigger-value"] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + } + [data-slot="select-select-trigger-icon"] { + width: 16px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-weak); + background-color: var(--surface-raised-base); + border-radius: 4px; + transition: transform 0.1s ease-in-out; + } + + &[data-slot="select-select-trigger"]:hover:not(:disabled), + &[data-slot="select-select-trigger"][data-expanded], + &[data-slot="select-select-trigger"][data-expanded]:hover:not(:disabled) { + background-color: var(--input-base); + box-shadow: var(--shadow-xs-border-base); + } + + &:not([data-expanded]):focus { + background-color: transparent; + box-shadow: none; + } + } + } } [data-component="select-content"] { - min-width: 4rem; + min-width: 104px; max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); background-color: var(--surface-raised-stronger-non-alpha); - padding: 2px; + padding: 4px; box-shadow: var(--shadow-xs-border); - z-index: 50; - - &[data-closed] { - animation: select-close 0.15s ease-out; - } + z-index: 60; &[data-expanded] { animation: select-open 0.15s ease-out; @@ -84,16 +125,14 @@ } } - /* [data-slot="select-section"] { */ - /* } */ - [data-slot="select-select-item"] { position: relative; display: flex; align-items: center; - padding: 0 6px 0 6px; + padding: 2px 8px; gap: 12px; - border-radius: var(--radius-sm); + border-radius: 4px; + cursor: default; /* text-12-medium */ font-family: var(--font-family-sans); @@ -135,6 +174,26 @@ } } +[data-component="select-content"][data-trigger-style="settings"] { + min-width: 160px; + border-radius: 8px; + padding: 0; + + [data-slot="select-select-content-list"] { + padding: 4px; + } + + [data-slot="select-select-item"] { + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } +} + @keyframes select-open { from { opacity: 0; @@ -145,14 +204,3 @@ transform: scale(1); } } - -@keyframes select-close { - from { - opacity: 1; - transform: scale(1); - } - to { - opacity: 0; - transform: scale(0.95); - } -} diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 538a7ca1a99..4ae2c9043b0 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,5 +1,5 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, splitProps, type ComponentProps, type JSX } from "solid-js" +import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" @@ -12,15 +12,18 @@ export type SelectProps = Omit>, "value" | " label?: (x: T) => string groupBy?: (x: T) => string onSelect?: (value: T | undefined) => void + onHighlight?: (value: T | undefined) => (() => void) | void class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] /** Classes applied to the root Kobalte element for flex layout sizing (e.g., min-w-0, grow, max-w-*) */ rootClass?: ComponentProps<"div">["class"] rootClassList?: ComponentProps<"div">["classList"] children?: (item: T | undefined) => JSX.Element + triggerStyle?: JSX.CSSProperties + triggerVariant?: "settings" } -export function Select(props: SelectProps & ButtonProps) { +export function Select(props: SelectProps & Omit) { const [local, others] = splitProps(props, [ "class", "classList", @@ -33,8 +36,42 @@ export function Select(props: SelectProps & ButtonProps) { "label", "groupBy", "onSelect", + "onHighlight", + "onOpenChange", "children", + "triggerStyle", + "triggerVariant", ]) + + const state = { + key: undefined as string | undefined, + cleanup: undefined as (() => void) | void, + } + + const stop = () => { + state.cleanup?.() + state.cleanup = undefined + state.key = undefined + } + + const keyFor = (item: T) => (local.value ? local.value(item) : (item as string)) + + const move = (item: T | undefined) => { + if (!local.onHighlight) return + if (!item) { + stop() + return + } + + const key = keyFor(item) + if (state.key === key) return + state.cleanup?.() + state.cleanup = local.onHighlight(item) + state.key = key + } + + onCleanup(stop) + const grouped = createMemo(() => { const result = pipe( local.options, @@ -55,7 +92,9 @@ export function Select(props: SelectProps & ButtonProps) { ...(local.rootClassList ?? {}), [local.rootClass ?? ""]: !!local.rootClass, }} - placement="bottom-start" + data-trigger-style={local.triggerVariant} + placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"} + gutter={4} value={local.current} options={grouped()} optionValue={(x) => (local.value ? local.value(x) : (x as string))} @@ -67,12 +106,14 @@ export function Select(props: SelectProps & ButtonProps) { )} itemComponent={(itemProps) => ( move(itemProps.item.rawValue)} + onPointerMove={() => move(itemProps.item.rawValue)} > {local.children @@ -88,6 +129,11 @@ export function Select(props: SelectProps & ButtonProps) { )} onChange={(v) => { local.onSelect?.(v ?? undefined) + stop() + }} + onOpenChange={(open) => { + local.onOpenChange?.(open) + if (!open) stop() }} > (props: SelectProps & ButtonProps) { as={Button} size={props.size} variant={props.variant} + style={local.triggerStyle} classList={{ ...(local.classList ?? {}), [local.class ?? ""]: !!local.class, @@ -110,7 +157,7 @@ export function Select(props: SelectProps & ButtonProps) { }} - + @@ -120,6 +167,7 @@ export function Select(props: SelectProps & ButtonProps) { [local.class ?? ""]: !!local.class, }} data-component="select-content" + data-trigger-style={local.triggerVariant} >
diff --git a/packages/ui/src/components/switch.css b/packages/ui/src/components/switch.css index c01e45d5f79..89e84473220 100644 --- a/packages/ui/src/components/switch.css +++ b/packages/ui/src/components/switch.css @@ -1,4 +1,5 @@ [data-component="switch"] { + position: relative; display: flex; align-items: center; gap: 8px; diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 3ec7ece904e..9af141306b6 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -215,24 +215,36 @@ height: 100%; overflow-x: hidden; overflow-y: auto; + padding: 8px; + gap: 4px; + background-color: var(--background-base); + border-right: 1px solid var(--border-weak-base); &::after { - width: 100%; - height: auto; - flex-grow: 1; - border-bottom: none; - border-right: 1px solid var(--border-weak-base); + display: none; } } [data-slot="tabs-trigger-wrapper"] { width: 100%; - height: auto; - border-bottom: none; - border-right: 1px solid var(--border-weak-base); + height: 32px; + border: none; + border-radius: 8px; + background-color: transparent; + + [data-slot="tabs-trigger"] { + padding: 0 8px; + gap: 8px; + justify-content: flex-start; + } + + &:hover:not(:disabled) { + background-color: var(--surface-raised-base-hover); + } &:has([data-selected]) { - border-right-color: transparent; + background-color: var(--surface-raised-base-active); + color: var(--text-strong); } } @@ -243,32 +255,105 @@ &[data-variant="alt"] { [data-slot="tabs-list"] { - padding-left: 0; - padding-right: 0; - padding-top: 24px; - padding-bottom: 24px; - border-bottom: none; - border-right: 1px solid var(--border-weak-base); + padding: 8px; + gap: 4px; + border: none; &::after { + display: none; + } + } + + [data-slot="tabs-trigger-wrapper"] { + height: 32px; + border: none; + border-radius: 8px; + + [data-slot="tabs-trigger"] { border: none; + padding: 0 8px; + gap: 8px; + justify-content: flex-start; + } + + &:hover:not(:disabled) { + background-color: var(--surface-raised-base-hover); + } + + &:has([data-selected]) { + background-color: var(--surface-raised-base-hover); + color: var(--text-strong); } } + } + + &[data-variant="settings"] { + [data-slot="tabs-list"] { + width: 200px; + min-width: 200px; + padding: 12px; + gap: 0; + background-color: var(--background-base); + border-right: 1px solid var(--border-weak-base); + + &::after { + display: none; + } + } + + [data-slot="tabs-section-title"] { + width: 100%; + padding: 0 0 0 4px; + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + color: var(--text-weak); + } [data-slot="tabs-trigger-wrapper"] { - border-bottom: none; - border-right-width: 2px; - border-right-style: solid; - border-right-color: transparent; + height: 32px; + border: none; + border-radius: var(--radius-md); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); [data-slot="tabs-trigger"] { - border-bottom: none; + border: none; + padding: 0 8px; + gap: 12px; + justify-content: flex-start; + width: 100%; + } + + [data-component="icon"] { + color: var(--icon-base); + } + + &:hover:not(:disabled) { + background-color: var(--surface-raised-base-hover); } &:has([data-selected]) { - border-right-color: var(--icon-strong-base); + background-color: var(--surface-raised-base-active); + color: var(--text-strong); + + [data-component="icon"] { + color: var(--icon-strong-base); + } + + &:hover:not(:disabled) { + background-color: var(--surface-raised-base-active); + } } } + + [data-slot="tabs-content"] { + background-color: var(--surface-raised-stronger-non-alpha); + } } } } diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 8c892a6e53f..825bfa85949 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -1,9 +1,9 @@ import { Tabs as Kobalte } from "@kobalte/core/tabs" import { Show, splitProps, type JSX } from "solid-js" -import type { ComponentProps, ParentProps } from "solid-js" +import type { ComponentProps, ParentProps, Component } from "solid-js" export interface TabsProps extends ComponentProps { - variant?: "normal" | "alt" + variant?: "normal" | "alt" | "settings" orientation?: "horizontal" | "vertical" } export interface TabsListProps extends ComponentProps {} @@ -106,8 +106,13 @@ function TabsContent(props: ParentProps) { ) } +const TabsSectionTitle: Component = (props) => { + return
{props.children}
+} + export const Tabs = Object.assign(TabsRoot, { List: TabsList, Trigger: TabsTrigger, Content: TabsContent, + SectionTitle: TabsSectionTitle, }) diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index 54e17537920..64eace0c3ae 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -77,7 +77,7 @@ export function useFilteredList(props: FilteredListProps) { } const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter" || event.key === "Tab") { + if ((event.key === "Enter" && !event.isComposing) || event.key === "Tab") { event.preventDefault() const selectedIndex = flat().findIndex((x) => props.key(x) === list.active()) const selected = flat()[selectedIndex] diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index ba41712df9f..84195a324cc 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -73,6 +73,10 @@ 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak, #f1f0f0), 0 0 0 3px var(--border-selected, rgba(0, 74, 255, 0.99)); + --shadow-lg-border-base: + 0 0 0 1px var(--border-weak-base, rgba(0, 0, 0, 0.07)), 0 36px 80px 0 rgba(0, 0, 0, 0.03), + 0 13.141px 29.201px 0 rgba(0, 0, 0, 0.04), 0 6.38px 14.177px 0 rgba(0, 0, 0, 0.05), + 0 3.127px 6.95px 0 rgba(0, 0, 0, 0.06), 0 1.237px 2.748px 0 rgba(0, 0, 0, 0.09); color-scheme: light; --text-mix-blend-mode: multiply; diff --git a/packages/ui/src/theme/themes/ayu.json b/packages/ui/src/theme/themes/ayu.json index 944241450ac..eac9e0491fc 100644 --- a/packages/ui/src/theme/themes/ayu.json +++ b/packages/ui/src/theme/themes/ayu.json @@ -5,20 +5,21 @@ "light": { "seeds": { "neutral": "#fdfaf4", - "primary": "#55b4d4", - "success": "#6ac782", - "warning": "#f2ae49", - "error": "#f05f65", - "info": "#36a3d9", - "interactive": "#55b4d4", - "diffAdd": "#b8df8a", - "diffDelete": "#f05f65" + "primary": "#4aa8c8", + "success": "#5fb978", + "warning": "#ea9f41", + "error": "#e6656a", + "info": "#2f9bce", + "interactive": "#4aa8c8", + "diffAdd": "#b1d780", + "diffDelete": "#e6656a" }, "overrides": { "background-base": "#fdfaf4", - "background-weak": "#f6f0e7", - "background-strong": "#f1ebe2", - "background-stronger": "#ece4da", + "background-weak": "#fcf9f3", + "background-strong": "#fbf8f2", + "background-stronger": "#faf7f1", + "surface-raised-base-hover": "#f4f0e9", "border-weak-base": "#e6ddcf", "border-weak-hover": "#dcd3c5", "border-weak-active": "#d1c9ba", @@ -31,101 +32,102 @@ "border-selected": "#9e9383", "border-disabled": "#efe5d8", "border-focus": "#b09f8f", - "border-strong-base": "#8f806f", - "border-strong-hover": "#837465", - "border-strong-active": "#77685a", - "border-strong-selected": "#6b5d51", + "border-strong-base": "#837765", + "border-strong-hover": "#7a6f5f", + "border-strong-active": "#716655", + "border-strong-selected": "#685e4e", "border-strong-disabled": "#d8cabc", - "border-strong-focus": "#7c6d5e", + "border-strong-focus": "#766b5c", "surface-diff-add-base": "#eef5e4", "surface-diff-delete-base": "#fde5e5", "surface-diff-hidden-base": "#e3edf3", - "text-base": "#5c6773", - "text-weak": "#8a939f", - "text-strong": "#2a3038", - "syntax-string": "#86b300", - "syntax-primitive": "#f28779", - "syntax-property": "#55b4d4", - "syntax-type": "#f29e32", - "syntax-constant": "#36a3d9", - "syntax-info": "#36a3d9", - "markdown-heading": "#55b4d4", - "markdown-text": "#5c6773", - "markdown-link": "#55b4d4", - "markdown-link-text": "#36a3d9", - "markdown-code": "#86b300", - "markdown-block-quote": "#f29e32", - "markdown-emph": "#f29e32", - "markdown-strong": "#f28779", + "text-base": "#4f5964", + "text-weak": "#77818d", + "text-strong": "#1b232b", + "syntax-string": "#7fad00", + "syntax-primitive": "#ef7d71", + "syntax-property": "#4aa8c8", + "syntax-type": "#ed982e", + "syntax-constant": "#2f9bce", + "syntax-info": "#2f9bce", + "markdown-heading": "#4aa8c8", + "markdown-text": "#4f5964", + "markdown-link": "#4aa8c8", + "markdown-link-text": "#2f9bce", + "markdown-code": "#7fad00", + "markdown-block-quote": "#ed982e", + "markdown-emph": "#ed982e", + "markdown-strong": "#f07f72", "markdown-horizontal-rule": "#d7cec0", - "markdown-list-item": "#55b4d4", - "markdown-list-enumeration": "#36a3d9", - "markdown-image": "#55b4d4", - "markdown-image-text": "#36a3d9", - "markdown-code-block": "#55b4d4" + "markdown-list-item": "#4aa8c8", + "markdown-list-enumeration": "#2f9bce", + "markdown-image": "#4aa8c8", + "markdown-image-text": "#2f9bce", + "markdown-code-block": "#4aa8c8" } }, "dark": { "seeds": { "neutral": "#0f1419", - "primary": "#39bae6", - "success": "#7fd962", - "warning": "#ebb062", - "error": "#ff8f77", - "info": "#73d0ff", - "interactive": "#39bae6", - "diffAdd": "#5cc885", - "diffDelete": "#ff8f77" + "primary": "#3fb7e3", + "success": "#78d05c", + "warning": "#e4a75c", + "error": "#f58572", + "info": "#66c6f1", + "interactive": "#3fb7e3", + "diffAdd": "#59c57c", + "diffDelete": "#f58572" }, "overrides": { "background-base": "#0f1419", - "background-weak": "#121920", - "background-strong": "#0d1116", - "background-stronger": "#0a0e13", - "border-weak-base": "#262c34", - "border-weak-hover": "#2b323d", - "border-weak-active": "#303746", - "border-weak-selected": "#363d50", - "border-weak-disabled": "#080b0f", - "border-weak-focus": "#323a48", - "border-base": "#3d4555", - "border-hover": "#454d61", - "border-active": "#4c556d", - "border-selected": "#545d79", - "border-disabled": "#0e1218", - "border-focus": "#495368", - "border-strong-base": "#626c81", - "border-strong-hover": "#6c7690", - "border-strong-active": "#76819f", - "border-strong-selected": "#808bae", - "border-strong-disabled": "#151b23", - "border-strong-focus": "#6f7a96", - "surface-diff-add-base": "#102922", - "surface-diff-delete-base": "#2b1718", - "surface-diff-hidden-base": "#182028", - "text-base": "#ced0d6", - "text-weak": "#8f9aa5", - "text-strong": "#f6f7f9", - "syntax-string": "#b8cc52", - "syntax-primitive": "#f59074", - "syntax-property": "#39bae6", - "syntax-type": "#ebb062", - "syntax-constant": "#73d0ff", - "syntax-info": "#73d0ff", - "markdown-heading": "#39bae6", - "markdown-text": "#ced0d6", - "markdown-link": "#39bae6", - "markdown-link-text": "#73d0ff", - "markdown-code": "#b8cc52", - "markdown-block-quote": "#ebb062", - "markdown-emph": "#ebb062", - "markdown-strong": "#f59074", - "markdown-horizontal-rule": "#1f2630", - "markdown-list-item": "#39bae6", - "markdown-list-enumeration": "#73d0ff", - "markdown-image": "#39bae6", - "markdown-image-text": "#73d0ff", - "markdown-code-block": "#ced0d6" + "background-weak": "#18222c", + "background-strong": "#0b1015", + "background-stronger": "#080c10", + "surface-raised-base-hover": "#0f1419", + "border-weak-base": "#2b3440", + "border-weak-hover": "#323c49", + "border-weak-active": "#394454", + "border-weak-selected": "#415063", + "border-weak-disabled": "#0a0e12", + "border-weak-focus": "#374453", + "border-base": "#475367", + "border-hover": "#515f75", + "border-active": "#5d6b83", + "border-selected": "#687795", + "border-disabled": "#11161d", + "border-focus": "#56647c", + "border-strong-base": "#73819b", + "border-strong-hover": "#7f8da8", + "border-strong-active": "#8b99b5", + "border-strong-selected": "#98a6c3", + "border-strong-disabled": "#1b222c", + "border-strong-focus": "#8391ad", + "surface-diff-add-base": "#132f27", + "surface-diff-delete-base": "#361d20", + "surface-diff-hidden-base": "#1b2632", + "text-base": "#d6dae0", + "text-weak": "#a3adba", + "text-strong": "#fbfbfd", + "syntax-string": "#b1c74a", + "syntax-primitive": "#f2856f", + "syntax-property": "#3fb7e3", + "syntax-type": "#e4a75c", + "syntax-constant": "#66c6f1", + "syntax-info": "#66c6f1", + "markdown-heading": "#3fb7e3", + "markdown-text": "#d6dae0", + "markdown-link": "#3fb7e3", + "markdown-link-text": "#66c6f1", + "markdown-code": "#b1c74a", + "markdown-block-quote": "#e4a75c", + "markdown-emph": "#e4a75c", + "markdown-strong": "#f2856f", + "markdown-horizontal-rule": "#2b3542", + "markdown-list-item": "#3fb7e3", + "markdown-list-enumeration": "#66c6f1", + "markdown-image": "#3fb7e3", + "markdown-image-text": "#66c6f1", + "markdown-code-block": "#d6dae0" } } } diff --git a/packages/ui/src/theme/themes/oc-1.json b/packages/ui/src/theme/themes/oc-1.json index 2c767385015..fe04b190113 100644 --- a/packages/ui/src/theme/themes/oc-1.json +++ b/packages/ui/src/theme/themes/oc-1.json @@ -30,11 +30,11 @@ "surface-inset-base-hover": "var(--smoke-light-alpha-3)", "surface-inset-strong": "#1f000017", "surface-inset-strong-hover": "#1f000017", - "surface-raised-base": "var(--smoke-light-alpha-1)", + "surface-raised-base": "var(--smoke-light-alpha-2)", "surface-float-base": "var(--smoke-dark-1)", "surface-float-base-hover": "var(--smoke-dark-2)", - "surface-raised-base-hover": "var(--smoke-light-alpha-2)", - "surface-raised-base-active": "var(--smoke-light-alpha-3)", + "surface-raised-base-hover": "var(--smoke-light-alpha-3)", + "surface-raised-base-active": "var(--smoke-light-alpha-4)", "surface-raised-strong": "var(--smoke-light-1)", "surface-raised-strong-hover": "var(--white)", "surface-raised-stronger": "var(--white)", diff --git a/packages/util/package.json b/packages/util/package.json index 299244dd6aa..d4b11aa2bb4 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.27", + "version": "1.1.28", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 15f3bd42eb2..fc10bcaabd8 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.27", + "version": "1.1.28", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index ce3e3deb86c..c9a3225b80f 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -42,6 +42,10 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax | | [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity | | [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms | +| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence | +| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete | +| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install | +| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode | --- @@ -58,6 +62,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | | [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | | [OpenWork](https://github.com/different-ai/openwork) | An open-source alternative to Claude Cowork, powered by OpenCode | +| [ocx](https://github.com/kdcokenny/ocx) | OpenCode extension manager with portable, isolated profiles. | --- diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 1a2bb4f325a..c7f0abe2ded 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,11 +2,11 @@ "name": "shuvcode", "displayName": "shuvcode", "description": "shuvcode for VS Code", - "version": "1.1.27", - "publisher": "latitudes-dev" + "version": "1.1.28", + "publisher": "latitudes-dev", "repository": { "type": "git", - "url": "https://github.com/anomalyco/opencode" + "url": "https://github.com/Latitudes-Dev/shuvcode" }, "license": "MIT", "icon": "images/icon.png",