diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000000..03a319d00489 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,5 @@ +reviews: + auto_review: + enabled: false + high_level_summary: true + review_status: false diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index da82bde2e12d..8bb936664e3b 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,66 +1,22 @@ name: Bug report -description: Report an issue that should be fixed (avoid pasting giant AI generated summaries or your issue may be closed/ignored) +description: Report an issue that should be fixed +title: "[bug] " +labels: ["bug"] body: - - type: textarea - id: description - attributes: - label: Description - description: Describe the bug you encountered - placeholder: What happened? - validations: - required: true - - type: input - id: plugins + id: version attributes: - label: Plugins - description: What plugins are you using? - validations: - required: false - - - type: input - id: opencode-version - attributes: - label: OpenCode version - description: What version of OpenCode are you using? + label: ocv version + description: What version of ocv are you using? + placeholder: run ocv --version validations: required: false - type: textarea - id: reproduce - attributes: - label: Steps to reproduce - description: How can we reproduce this issue? - placeholder: | - 1. - 2. - 3. - validations: - required: false - - - type: textarea - id: screenshot-or-link - attributes: - label: Screenshot and/or share link - description: Run `/share` to get a share link, or attach a screenshot - placeholder: Paste link or drag and drop screenshot here - validations: - required: false - - - type: input - id: os - attributes: - label: Operating System - description: what OS are you using? - placeholder: e.g., macOS 26.0.1, Ubuntu 22.04, Windows 11 - validations: - required: false - - - type: input - id: terminal + id: description attributes: - label: Terminal - description: what terminal are you using? - placeholder: e.g., iTerm2, Ghostty, Alacritty, Windows Terminal + label: Description + description: Describe the bug you encountered + placeholder: What happened? validations: - required: false + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9501a1be6516..7e322eb5a7a7 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: false +blank_issues_enabled: true contact_links: - name: 💬 Discord Community url: https://discord.gg/opencode diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 42f1d3c51a34..60a95c5b87f1 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,19 +1,18 @@ -name: 🚀 Feature Request -description: Suggest an idea, feature, or enhancement -title: "[FEATURE]:" - +name: Feature request +description: Suggest a new feature +title: "[feature] " +labels: ["feature"] body: - - type: checkboxes - id: verified + - type: markdown attributes: - label: Feature hasn't been suggested before. - options: - - label: I have verified this feature I'm about to request hasn't been suggested before. - required: true + value: | + Check [planned features](https://github.com/leohenon/opencode-vim/issues/5) first. If your request is already listed, comment there instead. - type: textarea + id: description attributes: - label: Describe the enhancement you want to request - description: What do you want to change or add? What are the benefits of implementing this? Try to be detailed so we can understand your request better :) + label: Description + description: What do you want to change or add? What are the benefits? + placeholder: What do you want to see? validations: required: true diff --git a/.github/ocv-track b/.github/ocv-track new file mode 100644 index 000000000000..8d39259a80f0 --- /dev/null +++ b/.github/ocv-track @@ -0,0 +1 @@ +4.10 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 393bf90518e9..000000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,29 +0,0 @@ -### Issue for this PR - -Closes # - -### Type of change - -- [ ] Bug fix -- [ ] New feature -- [ ] Refactor / code improvement -- [ ] Documentation - -### What does this PR do? - -Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. - -**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** - -### How did you verify your code works? - -### Screenshots / recordings - -_If this is a UI change, please include a screenshot or recording._ - -### Checklist - -- [ ] I have tested my changes locally -- [ ] I have not included unrelated changes in this PR - -_If you do not follow this template your PR will be automatically rejected._ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000000..edb938ea06e8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: + push: + branches: [ocv] + pull_request: + branches: [ocv] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-bun + - name: Run vim tests + run: bun test ./test/cli/tui/vim-*.test.ts + working-directory: packages/tui diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index ef977a93bd2d..000000000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: deploy - -on: - push: - branches: - - dev - - production - workflow_dispatch: - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -permissions: - contents: read - id-token: write - -jobs: - deploy: - if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production') - runs-on: ubuntu-latest - environment: ${{ github.ref_name }} - steps: - - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - - uses: ./.github/actions/setup-bun - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "24" - - - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 - with: - role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} - role-session-name: opencode-${{ github.run_id }} - aws-region: us-east-1 - - - run: bun sst deploy --stage=${{ github.ref_name }} - env: - GITHUB_TOKEN: ${{ github.token }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} - PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} - STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} - HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ vars.SENTRY_ORG }} - SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} - SENTRY_RELEASE: web@${{ github.sha }} - VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} - VITE_SENTRY_RELEASE: web@${{ github.sha }} diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index ce1d9237fde5..fbcf80fa95bc 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -6,7 +6,7 @@ permissions: on: workflow_dispatch: push: - branches: [dev, beta] + branches: [ocv] paths: - "bun.lock" - "package.json" @@ -18,21 +18,22 @@ on: - ".github/workflows/nix-hashes.yml" concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: nix-hashes-${{ github.ref }} cancel-in-progress: true jobs: # Native runners required: bun install cross-compilation flags (--os/--cpu) # do not produce byte-identical node_modules as native installs. compute-hash: + if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip nix-hashes]') strategy: fail-fast: false matrix: include: - system: x86_64-linux - runner: blacksmith-4vcpu-ubuntu-2404 + runner: ubuntu-24.04 - system: aarch64-linux - runner: blacksmith-4vcpu-ubuntu-2404-arm + runner: ubuntu-24.04-arm - system: x86_64-darwin runner: macos-15-intel - system: aarch64-darwin @@ -50,36 +51,7 @@ jobs: id: hash env: SYSTEM: ${{ matrix.system }} - run: | - set -euo pipefail - - BUILD_LOG=$(mktemp) - trap 'rm -f "$BUILD_LOG"' EXIT - - HASH="" - MAX_ATTEMPTS=3 - for ((ATTEMPT = 1; ATTEMPT <= MAX_ATTEMPTS; ATTEMPT++)); do - # Build with fakeHash to trigger hash mismatch and reveal correct hash - nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true - - HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" - - [ -n "$HASH" ] && break - - if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then - echo "::warning::Attempt ${ATTEMPT}/${MAX_ATTEMPTS} produced no hash for ${SYSTEM}; retrying in $((ATTEMPT * 10))s" - sleep $((ATTEMPT * 10)) - fi - done - - if [ -z "$HASH" ]; then - echo "::error::Failed to compute hash for ${SYSTEM} after ${MAX_ATTEMPTS} attempts" - cat "$BUILD_LOG" - exit 1 - fi - - echo "$HASH" > hash.txt - echo "Computed hash for ${SYSTEM}: $HASH" + run: nix/scripts/compute-node-modules-hash.sh - name: Upload hash uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -91,60 +63,81 @@ jobs: update-hashes: needs: compute-hash if: github.event_name != 'pull_request' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - persist-credentials: false fetch-depth: 0 ref: ${{ github.ref_name }} - - name: Setup git committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + - name: Configure git committer + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Pull latest changes run: | git pull --rebase --autostash origin "$GITHUB_REF_NAME" + - name: Check branch revision + id: revision + run: | + set -euo pipefail + git fetch origin "+refs/heads/$GITHUB_REF_NAME:refs/remotes/origin/$GITHUB_REF_NAME" + if [ "$(git rev-parse "origin/$GITHUB_REF_NAME")" != "$GITHUB_SHA" ] && ! git diff --quiet "$GITHUB_SHA" "origin/$GITHUB_REF_NAME" -- \ + bun.lock \ + package.json \ + 'packages/*/package.json' \ + flake.lock \ + nix/node_modules.nix \ + nix/scripts \ + patches; then + echo "Branch hash inputs changed since this run started; skipping stale hash update." + echo "current=false" >> "$GITHUB_OUTPUT" + echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY" + echo "Status: skipped because branch hash inputs changed" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "current=true" >> "$GITHUB_OUTPUT" + - name: Download hash artifacts + if: steps.revision.outputs.current == 'true' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: hashes pattern: hash-* - name: Update hashes.json - run: | - set -euo pipefail - - HASH_FILE="nix/hashes.json" - - [ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE" - - for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do - FILE="hashes/hash-${SYSTEM}/hash.txt" - if [ -f "$FILE" ]; then - HASH="$(tr -d '[:space:]' < "$FILE")" - echo "${SYSTEM}: ${HASH}" - jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json - mv tmp.json "$HASH_FILE" - else - echo "::warning::Missing hash for ${SYSTEM}" - fi - done - - cat "$HASH_FILE" + if: steps.revision.outputs.current == 'true' + run: nix/scripts/update-node-modules-hashes-json.sh hashes nix/hashes.json warn - name: Commit changes + if: steps.revision.outputs.current == 'true' run: | set -euo pipefail HASH_FILE="nix/hashes.json" + git fetch origin "+refs/heads/$GITHUB_REF_NAME:refs/remotes/origin/$GITHUB_REF_NAME" + if [ "$(git rev-parse "origin/$GITHUB_REF_NAME")" != "$(git rev-parse HEAD)" ]; then + if ! git diff --quiet "$GITHUB_SHA" "origin/$GITHUB_REF_NAME" -- \ + bun.lock \ + package.json \ + 'packages/*/package.json' \ + flake.lock \ + nix/node_modules.nix \ + nix/scripts \ + patches; then + echo "Branch hash inputs changed before commit; skipping stale hash update." + echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY" + echo "Status: skipped because branch hash inputs changed" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + git pull --rebase --autostash origin "$GITHUB_REF_NAME" + fi + if [ -z "$(git status --short -- "$HASH_FILE")" ]; then echo "No changes to commit" echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY" @@ -153,9 +146,7 @@ jobs: fi git add "$HASH_FILE" - git commit -m "chore: update nix node_modules hashes" - - git pull --rebase --autostash origin "$GITHUB_REF_NAME" + git commit -m "chore(deps): update nix node_modules hashes" git push origin HEAD:"$GITHUB_REF_NAME" echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ocv-pages.yml b/.github/workflows/ocv-pages.yml new file mode 100644 index 000000000000..a36f4be51807 --- /dev/null +++ b/.github/workflows/ocv-pages.yml @@ -0,0 +1,36 @@ +name: pages + +on: + push: + branches: + - ocv + paths: + - docs/** + workflow_dispatch: + +concurrency: + group: pages + cancel-in-progress: false + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: docs + + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml index 06838089d354..8d5ad6c2d719 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/pr-standards.yml @@ -71,10 +71,10 @@ jobs: repo: context.repo.repo, issue_number: pr.number }); - + const existing = comments.find(c => c.body.includes(markerText)); if (existing) return; - + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/publish-ocv.yml b/.github/workflows/publish-ocv.yml new file mode 100644 index 000000000000..257f5491f18e --- /dev/null +++ b/.github/workflows/publish-ocv.yml @@ -0,0 +1,554 @@ +name: Release ocv + +on: + workflow_dispatch: + inputs: + version: + description: "Version (e.g. 1.2.20)" + required: true + type: string + force_nix_hash_refresh: + description: "Force Nix hash refresh" + required: false + type: boolean + default: false + +permissions: + contents: write + issues: write + pull-requests: read + +concurrency: + group: release-ocv-${{ github.ref }} + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + branch: ${{ steps.nix.outputs.branch }} + sha: ${{ steps.nix.outputs.sha }} + nix_hashes_current: ${{ steps.nix.outputs.current }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Validate version + shell: bash + run: | + VERSION="${{ inputs.version }}" + TRACK=$(tr -d '[:space:]' < .github/ocv-track) + if ! printf '%s' "$TRACK" | grep -Eq '^[0-9]+\.[0-9]+$'; then + echo "Invalid ocv track: '$TRACK'" + exit 1 + fi + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-ocv\.[0-9]+\.[0-9]+$'; then + echo "Invalid ocv version: '$VERSION'" + exit 1 + fi + if [ "${VERSION##*-ocv.}" != "$TRACK" ]; then + echo "Version track '${VERSION##*-ocv.}' does not match pinned track '$TRACK'" + exit 1 + fi + + - name: Detect Nix hash state + id: nix + shell: bash + run: | + set -euo pipefail + + SHA=$(git rev-parse HEAD) + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + + if [ "${{ github.ref_type }}" != "branch" ]; then + echo "branch=" >> "$GITHUB_OUTPUT" + echo "current=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "branch=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + + if [ "${{ inputs.force_nix_hash_refresh }}" = "true" ]; then + echo "Nix hash refresh forced" + echo "current=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + HASH_BASE=$(git log -n 1 --format=%H -- nix/hashes.json || true) + + if [ -n "$HASH_BASE" ] && git diff --quiet "$HASH_BASE"..HEAD -- \ + bun.lock \ + package.json \ + 'packages/*/package.json' \ + flake.lock \ + nix/node_modules.nix \ + nix/scripts \ + patches; then + echo "Nix hashes are current" + echo "current=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Nix hashes are stale" + echo "current=false" >> "$GITHUB_OUTPUT" + + compute-nix-hash: + needs: prepare + if: needs.prepare.outputs.branch != '' && needs.prepare.outputs.nix_hashes_current != 'true' + strategy: + fail-fast: false + matrix: + include: + - system: x86_64-linux + runner: ubuntu-24.04 + - system: aarch64-linux + runner: ubuntu-24.04-arm + - system: x86_64-darwin + runner: macos-15-intel + - system: aarch64-darwin + runner: macos-latest + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.sha }} + + - name: Setup Nix + uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + + - name: Compute node_modules hash + env: + SYSTEM: ${{ matrix.system }} + run: nix/scripts/compute-node-modules-hash.sh + + - name: Upload hash + uses: actions/upload-artifact@v4 + with: + name: hash-${{ matrix.system }} + path: hash.txt + retention-days: 1 + + update-nix-hashes: + needs: + - prepare + - compute-nix-hash + if: always() && needs.prepare.result == 'success' + runs-on: ubuntu-24.04 + outputs: + sha: ${{ steps.final.outputs.sha }} + + steps: + - name: Require successful Nix hash computation + if: needs.prepare.outputs.branch != '' && needs.prepare.outputs.nix_hashes_current != 'true' && needs.compute-nix-hash.result != 'success' + run: | + echo "::error::Nix hash computation failed" + exit 1 + + - name: Checkout repository + if: needs.prepare.outputs.branch != '' && needs.prepare.outputs.nix_hashes_current != 'true' + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.sha }} + fetch-depth: 0 + + - name: Configure git committer + if: needs.prepare.outputs.branch != '' && needs.prepare.outputs.nix_hashes_current != 'true' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Download hash artifacts + if: needs.prepare.outputs.branch != '' && needs.prepare.outputs.nix_hashes_current != 'true' + uses: actions/download-artifact@v4 + with: + path: hashes + pattern: hash-* + + - name: Update hashes.json + if: needs.prepare.outputs.branch != '' && needs.prepare.outputs.nix_hashes_current != 'true' + run: nix/scripts/update-node-modules-hashes-json.sh + + - name: Commit Nix hashes + if: needs.prepare.outputs.branch != '' && needs.prepare.outputs.nix_hashes_current != 'true' + shell: bash + run: | + set -euo pipefail + + BRANCH="${{ needs.prepare.outputs.branch }}" + PREPARED_SHA="${{ needs.prepare.outputs.sha }}" + HASH_FILE="nix/hashes.json" + + for attempt in 1 2 3; do + git fetch origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" + REMOTE_SHA="$(git rev-parse "origin/$BRANCH")" + + if [ "$REMOTE_SHA" != "$PREPARED_SHA" ]; then + # The branch advanced while we were computing hashes. It is only + # safe to continue if the hash inputs are unchanged (e.g. a + # concurrent nix/hashes.json commit); otherwise our hashes are + # stale and a fresh release must recompute them. + if ! git diff --quiet "$PREPARED_SHA" "$REMOTE_SHA" -- \ + bun.lock \ + package.json \ + 'packages/*/package.json' \ + flake.lock \ + nix/node_modules.nix \ + nix/scripts \ + patches; then + echo "::error::Hash inputs changed on $BRANCH while preparing; rerun the release" + exit 1 + fi + # Re-anchor onto the advanced branch before re-applying hashes. + git checkout -f -B "$BRANCH" "$REMOTE_SHA" + fi + + # Re-apply the freshly computed hashes from the downloaded artifacts. + nix/scripts/update-node-modules-hashes-json.sh + + if [ -z "$(git status --short -- "$HASH_FILE")" ]; then + echo "Nix hashes already current on $BRANCH" + git rev-parse HEAD > final-sha.txt + exit 0 + fi + + git add "$HASH_FILE" + git commit -m "chore(deps): update nix node_modules hashes" + if git push origin HEAD:"$BRANCH"; then + git rev-parse HEAD > final-sha.txt + exit 0 + fi + + echo "Push race on $BRANCH; retrying (attempt $attempt)..." + git reset --hard "$REMOTE_SHA" + done + + echo "::error::Failed to push Nix hashes after multiple attempts" + exit 1 + + - name: Set final release SHA + id: final + shell: bash + run: | + set -euo pipefail + + if [ "${{ needs.prepare.outputs.branch }}" = "" ] || [ "${{ needs.prepare.outputs.nix_hashes_current }}" = "true" ]; then + echo "sha=${{ needs.prepare.outputs.sha }}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "sha=$(cat final-sha.txt)" >> "$GITHUB_OUTPUT" + + release: + needs: + - update-nix-hashes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.update-nix-hashes.outputs.sha }} + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Add release contributors + if: github.ref_type == 'branch' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ inputs.version }}" + PREV_TAG=$(git tag --list 'v*-ocv.*' --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-ocv\.[0-9]+\.[0-9]+$' | grep -vx "v$VERSION" | head -n 1 || true) + if [ -z "$PREV_TAG" ]; then + echo "No previous ocv release found, skipping contributor update" + exit 0 + fi + + bun script/update-contributors.ts --from "$PREV_TAG" --to HEAD --repo "$GITHUB_REPOSITORY" --base ocv + if git diff --quiet README.md; then + exit 0 + fi + + git add README.md assets/contributors + git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" commit -m "chore(release): update contributors" + git push origin HEAD:${{ github.ref_name }} + + - name: Build CLI + run: bun run packages/opencode/script/build.ts --single=false + env: + OPENCODE_VERSION: ${{ inputs.version }} + + - name: Package binaries + shell: bash + run: | + DIST="packages/opencode/dist" + mkdir -p "$DIST/release" + + for dir in $DIST/opencode-*/; do + [ -d "$dir" ] || continue + name=$(basename "$dir") + target="${name/opencode/ocv}" + bin="$dir/bin/opencode" + if [ ! -f "$bin" ]; then + bin="$dir/bin/opencode.exe" + fi + + if [[ "$name" == *windows* ]]; then + cp "$bin" "$DIST/release/$target.exe" + else + cp "$bin" "$DIST/release/$target" + fi + + if [[ "$name" == *linux* ]]; then + tar -czf "$DIST/release/$target.tar.gz" -C "$dir/bin" . + else + (cd "$dir/bin" && zip -r "../../release/$target.zip" .) + fi + done + + ls -lh "$DIST/release/" + + - name: Generate release notes + id: notes + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ inputs.version }}" + BASE="${VERSION%%-ocv.*}" + PATCH="${VERSION##*-ocv.}" + NOTES_FILE="$RUNNER_TEMP/ocv-release-notes.txt" + PRS_FILE="$RUNNER_TEMP/ocv-release-prs.txt" + PREV_TAG=$(git tag --list 'v*-ocv.*' --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-ocv\.[0-9]+\.[0-9]+$' | grep -vx "v$VERSION" | head -n 1 || true) + PREV_PATCH="" + + if [ -n "$PREV_TAG" ]; then + PREV_PATCH="${PREV_TAG##*-ocv.}" + git log --first-parent --reverse --format='%H' "$PREV_TAG"..HEAD | while read -r sha; do + gh api "repos/${{ github.repository }}/commits/$sha/pulls" -q '.[] | select(.base.ref == "ocv") | "\(.number)\t\(.title)\t\(.user.login // "")"' || true + done | awk -F '\t' '!seen[$1]++' | while IFS=$'\t' read -r num title author; do + [ -n "$num" ] || continue + if [ -n "$author" ]; then + echo "$title - #$num (@$author)" + continue + fi + echo "$title - #$num" + done > "$PRS_FILE" + else + : > "$PRS_FILE" + fi + + echo "based on opencode v$BASE" > "$NOTES_FILE" + + if [ -s "$PRS_FILE" ] || { [ -n "$PREV_PATCH" ] && [ "$PATCH" != "$PREV_PATCH" ]; }; then + { + echo + echo "ocv patch $PATCH" + cat "$PRS_FILE" + } >> "$NOTES_FILE" + fi + + echo "file=$NOTES_FILE" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + shell: bash + run: | + VERSION="${{ inputs.version }}" + DIST="packages/opencode/dist/release" + NOTES_FILE="${{ steps.notes.outputs.file }}" + if gh release view "v$VERSION" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "v$VERSION" --repo "$GITHUB_REPOSITORY" --clobber "$DIST"/* + gh release edit "v$VERSION" --repo "$GITHUB_REPOSITORY" --title "v$VERSION" --notes-file "$NOTES_FILE" + else + gh release create "v$VERSION" \ + --title "v$VERSION" \ + --notes-file "$NOTES_FILE" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$(git rev-parse HEAD)" \ + "$DIST"/* + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + + - name: Compute checksums + id: sum + shell: bash + run: | + DIST="packages/opencode/dist/release" + echo "darwin_arm64=$(shasum -a 256 "$DIST/ocv-darwin-arm64.zip" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" + echo "darwin_x64=$(shasum -a 256 "$DIST/ocv-darwin-x64.zip" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" + echo "linux_arm64=$(shasum -a 256 "$DIST/ocv-linux-arm64.tar.gz" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" + echo "linux_x64=$(shasum -a 256 "$DIST/ocv-linux-x64.tar.gz" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" + + - name: Checkout tap + id: checkout_tap + continue-on-error: true + uses: actions/checkout@v4 + with: + repository: leohenon/homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + + - name: Update formula + id: update_formula + if: steps.checkout_tap.outcome == 'success' + continue-on-error: true + shell: bash + run: | + VERSION="${{ inputs.version }}" + DARWIN_ARM64="${{ steps.sum.outputs.darwin_arm64 }}" + DARWIN_X64="${{ steps.sum.outputs.darwin_x64 }}" + LINUX_ARM64="${{ steps.sum.outputs.linux_arm64 }}" + LINUX_X64="${{ steps.sum.outputs.linux_x64 }}" + cat > tap/Formula/ocv.rb < "ocv" + end + + test do + assert_match version.to_s, shell_output("\#{bin}/ocv --version", 2) + end + end + EOF + + - name: Push formula + id: push_formula + if: steps.checkout_tap.outcome == 'success' && steps.update_formula.outcome == 'success' + continue-on-error: true + shell: bash + run: | + cd tap + git add Formula/ocv.rb + if git diff --cached --quiet; then + echo "Formula unchanged, skipping push" + exit 0 + fi + git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" commit -m "chore(release): update ocv formula v${{ inputs.version }}" + git push + + - name: Close Homebrew failure issues + if: steps.checkout_tap.outcome == 'success' && steps.update_formula.outcome == 'success' && steps.push_formula.outcome == 'success' + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NUMBERS=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --limit 100 --search "Homebrew tap update failed in:title" --json number,title --jq '.[] | select(.title | test("^\\[ci\\]: Homebrew tap update failed for v[0-9]+\\.[0-9]+\\.[0-9]+-ocv\\.[0-9]+\\.[0-9]+$")) | .number') + if [ -z "$NUMBERS" ]; then + echo "No open Homebrew failure issue found" + exit 0 + fi + while read -r NUMBER; do + [ -n "$NUMBER" ] || continue + gh issue close "$NUMBER" --repo "$GITHUB_REPOSITORY" --comment "Closed automatically after a successful Homebrew tap update for v${{ inputs.version }}: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + done <<< "$NUMBERS" + + - name: Create Homebrew failure issue + if: steps.checkout_tap.outcome == 'failure' || steps.update_formula.outcome == 'failure' || steps.push_formula.outcome == 'failure' + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TITLE="[ci]: Homebrew tap update failed for v${{ inputs.version }}" + BODY_FILE=$(mktemp) + cat > "$BODY_FILE" < "$BODY_FILE" < "$tmp/ours.json" + git show :3:nix/hashes.json > "$tmp/theirs.json" + python3 - "$tmp/ours.json" "$tmp/theirs.json" <<'PY' + import json + import sys + + systems = {"x86_64-linux", "aarch64-linux", "aarch64-darwin", "x86_64-darwin"} + for path in sys.argv[1:]: + with open(path) as f: + data = json.load(f) + if set(data) != {"nodeModules"}: + raise SystemExit(f"Unsupported nix/hashes.json schema in {path}") + hashes = data["nodeModules"] + if set(hashes) != systems: + raise SystemExit(f"Unsupported nix/hashes.json systems in {path}") + for system, value in hashes.items(): + if not isinstance(value, str) or not value.startswith("sha256-"): + raise SystemExit(f"Unsupported nix hash for {system} in {path}") + PY + rm -rf "$tmp" + + echo "Resolving nix/hashes.json with current OCV hashes; native hash refresh will run after push." + git checkout --ours -- nix/hashes.json + git add nix/hashes.json + nix_hash_conflict_resolved=true + fi + fi + + if git rev-parse -q --verify MERGE_HEAD >/dev/null && git cat-file -e HEAD:AGENTS.md 2>/dev/null; then + echo "Keeping OCV AGENTS.md and ignoring upstream changes." + git checkout HEAD -- AGENTS.md + git add AGENTS.md + fi + + remaining_conflicts=$(git diff --name-only --diff-filter=U) + if [ -n "$remaining_conflicts" ]; then + printf '%s\n' "$remaining_conflicts" + exit 1 + fi + + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + git commit -m "chore(upstream): sync upstream dev" + fi + + after=$(git rev-parse HEAD) + if [ "$before" != "$after" ] && ! git diff --quiet "$before" "$after" -- \ + bun.lock \ + package.json \ + 'packages/*/package.json' \ + flake.lock \ + nix/node_modules.nix \ + nix/scripts \ + patches; then + nix_hash_inputs_changed=true + fi + + # Dependency-input changes pushed to ocv already trigger nix-hashes.yml via + # its push paths. Only dispatch manually for a resolved hashes.json conflict + # that would otherwise be invisible to that push trigger. + nix_hashes_needed=false + if [ "$nix_hash_conflict_resolved" = true ] && [ "$nix_hash_inputs_changed" != true ]; then + nix_hashes_needed=true + fi + + echo "nix_hashes_needed=$nix_hashes_needed" >> "$GITHUB_OUTPUT" + echo "nix_hash_inputs_changed=$nix_hash_inputs_changed" >> "$GITHUB_OUTPUT" + + - name: Bump ocv track for ocv PRs + env: + GH_TOKEN: ${{ secrets.SYNC_PAT }} + run: | + previous=$(gh release list --repo ${{ github.repository }} --limit 200 --json tagName -q 'map(.tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+-ocv\\.[0-9]+\\.[0-9]+$"))) | .[0] // ""') + if [ -z "$previous" ]; then + echo "No previous ocv release found, skipping track bump" + exit 0 + fi + git rev-parse --verify --quiet "$previous^{commit}" >/dev/null || git fetch origin "refs/tags/$previous:refs/tags/$previous" + + track=$(tr -d '[:space:]' < .github/ocv-track) + if ! printf '%s' "$track" | grep -Eq '^[0-9]+\.[0-9]+$'; then + echo "Invalid ocv track: '$track'" + exit 1 + fi + + previous_track="${previous##*-ocv.}" + if [ "$track" != "$previous_track" ]; then + echo "ocv track is already bumped from $previous_track to $track" + exit 0 + fi + + prs=$(mktemp) + git fetch origin ocv:refs/remotes/origin/ocv + scan_ref=$(git rev-parse --verify --quiet origin/ocv >/dev/null && printf origin/ocv || printf HEAD) + git log --first-parent --reverse --format='%H' "$previous..$scan_ref" | while read -r sha; do + if pull_requests=$(gh api "repos/${{ github.repository }}/commits/$sha/pulls" -q '.[] | select(.base.ref == "ocv") | "#\(.number) \(.title)"' 2>/dev/null); then + if [ -n "$pull_requests" ]; then + printf '%s\n' "$pull_requests" + fi + fi + done | awk '!seen[$1]++' > "$prs" + + if [ ! -s "$prs" ]; then + echo "No ocv PRs merged since $previous" + exit 0 + fi + + next="${track%.*}.$((10#${track#*.} + 1))" + printf '%s\n' "$next" > .github/ocv-track + git add .github/ocv-track + git commit -m "chore(release): bump ocv track" + echo "Bumped ocv track from $track to $next for ocv PRs merged since $previous:" + cat "$prs" + + - name: Check for new upstream version + id: version + env: + GH_TOKEN: ${{ secrets.SYNC_PAT }} + run: | + upstream=$(gh release list --repo sst/opencode --exclude-drafts --exclude-pre-releases --limit 200 --json tagName -q 'map(.tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))) | .[0] // ""' | sed 's/^v//') + current=$(gh release list --repo ${{ github.repository }} --limit 200 --json tagName -q 'map(.tagName | sub("^v"; "")) | map(select(test("^[0-9]+\\.[0-9]+\\.[0-9]+-ocv\\.[0-9]+\\.[0-9]+$"))) | .[0] // ""') + track=$(tr -d '[:space:]' < .github/ocv-track) + upstream_base=$(echo "$upstream" | sed -E 's/-(vim|ocv)\..*$//') + current_base=$(echo "$current" | sed -E 's/-(vim|ocv)\..*$//') + if ! printf '%s' "$track" | grep -Eq '^[0-9]+\.[0-9]+$'; then + echo "Invalid ocv track: '$track'" + exit 1 + fi + release="${upstream_base}-ocv.${track}" + exists=$(gh release view "v$release" --repo ${{ github.repository }} >/dev/null 2>&1 && printf true || printf false) + echo "upstream=$upstream" + echo "current=$current" + echo "track=$track" + echo "upstream_base=$upstream_base" + echo "current_base=$current_base" + echo "release=$release" + echo "exists=$exists" + echo "upstream=$upstream" >> "$GITHUB_OUTPUT" + echo "current=$current" >> "$GITHUB_OUTPUT" + echo "track=$track" >> "$GITHUB_OUTPUT" + echo "upstream_base=$upstream_base" >> "$GITHUB_OUTPUT" + echo "current_base=$current_base" >> "$GITHUB_OUTPUT" + echo "release=$release" >> "$GITHUB_OUTPUT" + echo "exists=$exists" >> "$GITHUB_OUTPUT" + + - name: Mark release-managed Nix refresh + if: >- + steps.merge.outputs.nix_hash_inputs_changed == 'true' && + steps.version.outputs.upstream_base != '' && + steps.version.outputs.release != steps.version.outputs.current && + steps.version.outputs.exists != 'true' + run: | + git commit --amend -m "$(git log -1 --format=%B) + + [skip nix-hashes]" + + - name: Push + run: git push origin ocv + + - name: Disable upstream workflows + env: + GH_TOKEN: ${{ secrets.SYNC_PAT }} + run: | + keep="ci.yml publish-ocv.yml sync-upstream.yml nix-hashes.yml ocv-pages.yml" + gh api repos/${{ github.repository }}/actions/workflows --paginate -q '.workflows[] | "\(.id) \(.path)"' | while read id path; do + name=$(basename "$path") + if echo "$keep" | grep -qw "$name"; then + continue + fi + gh api -X PUT "repos/${{ github.repository }}/actions/workflows/$id/disable" 2>/dev/null || true + done + + - name: Trigger Nix hash refresh + if: >- + steps.merge.outputs.nix_hashes_needed == 'true' && + (steps.version.outputs.upstream_base == '' || + steps.version.outputs.release == steps.version.outputs.current || + steps.version.outputs.exists == 'true') + env: + GH_TOKEN: ${{ secrets.SYNC_PAT }} + run: gh workflow run nix-hashes.yml --repo ${{ github.repository }} --ref ocv + + - name: Trigger release + if: steps.version.outputs.upstream_base != '' && steps.version.outputs.release != steps.version.outputs.current && steps.version.outputs.exists != 'true' + env: + GH_TOKEN: ${{ secrets.SYNC_PAT }} + FORCE_NIX_HASH_REFRESH: ${{ steps.merge.outputs.nix_hashes_needed }} + run: gh workflow run publish-ocv.yml --repo ${{ github.repository }} --ref ocv -f version=${{ steps.version.outputs.release }} -f force_nix_hash_refresh="$FORCE_NIX_HASH_REFRESH" + + - name: Close failure issue on success + if: success() + env: + GH_TOKEN: ${{ github.token }} + run: | + issues=$(gh issue list --repo ${{ github.repository }} --state open --limit 1000 --json number,title -q '.[] | select(.title == "[ci]: sync upstream failed" or .title == "[actions]: sync upstream failed") | .number') + if [ -z "$issues" ]; then + exit 0 + fi + printf '%s\n' "$issues" | while read -r number; do + gh issue close "$number" --repo ${{ github.repository }} --comment "Closed automatically after a successful sync run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + done + + - name: Notify on failure + if: failure() + env: + GH_TOKEN: ${{ github.token }} + run: | + gh label create ci --repo ${{ github.repository }} --color D73A4A --description "Continuous integration" 2>/dev/null || true + issues=$(gh issue list --repo ${{ github.repository }} --state open --limit 1000 --json number,title -q '.[] | select(.title == "[ci]: sync upstream failed" or .title == "[actions]: sync upstream failed") | [.number, .title] | @tsv') + tracker=$(printf '%s\n' "$issues" | awk -F '\t' '$2 == "[ci]: sync upstream failed" { print $1; exit }') + if [ -z "$tracker" ]; then + tracker=$(printf '%s\n' "$issues" | awk -F '\t' 'NF { print $1; exit }') + if [ -n "$tracker" ]; then + gh issue edit "$tracker" --repo ${{ github.repository }} --title "[ci]: sync upstream failed" --add-label ci + fi + fi + if [ -z "$tracker" ]; then + gh issue create --repo ${{ github.repository }} \ + --title "[ci]: sync upstream failed" \ + --label ci \ + --body "Sync workflow failed. Likely a merge conflict. [View run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" + exit 0 + fi + gh issue edit "$tracker" --repo ${{ github.repository }} --add-label ci + printf '%s\n' "$issues" | awk -F '\t' -v tracker="$tracker" 'NF && $1 != tracker { print $1 }' | while read -r number; do + gh issue close "$number" --repo ${{ github.repository }} --comment "Closing duplicate; tracking this failure in #$tracker." + done diff --git a/.gitignore b/.gitignore index 006cab8c276c..5602a3ea6577 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,6 @@ target # Local dev files opencode-dev -UPCOMING_CHANGELOG.md logs/ *.bun-build tsconfig.tsbuildinfo diff --git a/.husky/pre-push b/.husky/pre-push index 5d3cc53411be..ffc3ee0755a3 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -17,4 +17,9 @@ if (process.versions.bun !== expectedBunVersion) { console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`); } ' + +cd packages/opencode bun typecheck + +cd ../tui +bun test ./test/cli/tui/vim-*.test.ts diff --git a/AGENTS.md b/AGENTS.md index cd2327e88811..e9310f301198 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,161 +1,18 @@ -- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. -- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. -- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. -- The default branch in this repo is `dev`. -- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. +# Agent Instructions -## Branch Names +This repository is the ocv fork of OpenCode. Treat most code as upstream; keep fork changes focused on Vim mode and copy mode. -Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`. +## Priorities -Examples: `session-recovery`, `fix-scroll-state`, `regenerate-sdk`. +- Prefer stability, simplicity, and maintainability over broad rewrites. +- Keep changes surgical and scoped to the requested behavior. -## Commits and PR Titles +## Vim/copy layout -Use conventional commit-style messages and PR titles: `type(scope): summary`. +- Prompt Vim behavior lives under `packages/tui/src/component/vim/`; entry wiring is `packages/tui/src/component/prompt/vim.ts`. +- Copy mode lives in `packages/tui/src/routes/session/copy-mode.ts`; shared Vim integration is `packages/tui/src/component/vim/copy-adapter.ts`. +- Reuse existing motion/operator/repeat state helpers in `component/vim/` instead of duplicating prompt vs copy behavior. +- Update `packages/tui/test/cli/tui/vim-motions.test.ts` for Vim/copy changes. +- Update `README.md` Prompt controls or Copy mode tables when adding user-visible keys. -Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes are optional; use the affected package or area when helpful, e.g. `core`, `opencode`, `tui`, `app`, `desktop`, `sdk`, or `plugin`. - -Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`. - -## Style Guide - -### General Principles - -- Keep things in one function unless composable or reusable -- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller. -- Avoid `try`/`catch` where possible -- Avoid using the `any` type -- Use Bun APIs when possible, like `Bun.file()` -- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity -- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream -- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module. -- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`. - -Reduce total variable count by inlining when a value is only used once. - -```ts -// Good -const journal = await Bun.file(path.join(dir, "journal.json")).json() - -// Bad -const journalPath = path.join(dir, "journal.json") -const journal = await Bun.file(journalPath).json() -``` - -### Destructuring - -Avoid unnecessary destructuring. Use dot notation to preserve context. - -```ts -// Good -obj.a -obj.b - -// Bad -const { a, b } = obj -``` - -### Imports - -- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`. -- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`. -- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`. -- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading. - -### Variables - -Prefer `const` over `let`. Use ternaries or early returns instead of reassignment. - -```ts -// Good -const foo = condition ? 1 : 2 - -// Bad -let foo -if (condition) foo = 1 -else foo = 2 -``` - -### Control Flow - -Avoid `else` statements. Prefer early returns. - -```ts -// Good -function foo() { - if (condition) return 1 - return 2 -} - -// Bad -function foo() { - if (condition) return 1 - else return 2 -} -``` - -### Complex Logic - -When a function has several validation branches or supporting details, make the main function read as the happy path and move supporting details into small helpers below it. - -```ts -// Good -export function loadThing(input: unknown) { - const config = requireConfig(input) - const metadata = readMetadata(input) - return createThing({ config, metadata }) -} - -function requireConfig(input: unknown) { - ... -} -``` - -- Keep helpers close to the code they support, below the main export when that improves readability. -- Do not over-abstract simple expressions into many single-use helpers; extract only when it names a real concept like `requireConfig` or `readMetadata`. -- Do not return `Effect` from helpers unless they actually perform effectful work. Synchronous parsing, validation, and option building should stay synchronous. -- Prefer Effect schema helpers such as `Schema.UnknownFromJsonString` and `Schema.decodeUnknownOption` over manual `JSON.parse` wrapped in `Effect.try` when parsing untrusted JSON strings. -- Add comments for non-obvious constraints and surprising behavior, not for obvious assignments or control flow. - -### Schema Definitions (Drizzle) - -Use snake_case for field names so column names don't need to be redefined as strings. - -```ts -// Good -const table = sqliteTable("session", { - id: text().primaryKey(), - project_id: text().notNull(), - created_at: integer().notNull(), -}) - -// Bad -const table = sqliteTable("session", { - id: text("id").primaryKey(), - projectID: text("project_id").notNull(), - createdAt: integer("created_at").notNull(), -}) -``` - -## Testing - -- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option. -- Test actual implementation, do not duplicate logic into tests -- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. - -## Type Checking - -- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly. - -## V2 Session Core - -- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries. -- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry. -- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op. -- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. -- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. -- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. -- Keep EventV2 replay owner claims separate from clustered Session execution ownership. -- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. +Read [CONTRIBUTING.md](./CONTRIBUTING.md#commit-messages). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9c545efb896..08f2763bebe5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,272 +1,51 @@ -# Contributing to OpenCode +# Contributing -We want to make it easy for you to contribute to OpenCode. Here are the most common type of changes that get merged: +PRs welcome. Open an issue first for large changes. -- Bug fixes -- Additional LSPs / Formatters -- Improvements to LLM performance -- Support for new providers -- Fixes for environment-specific quirks -- Missing standard behavior -- Documentation improvements +`ocv` tracks OpenCode. Keep changes focused on Vim mode/copy mode. -However, any UI or core product feature must go through a design review with the core team before implementation. +## Commit messages -If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels: - -- [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted) -- [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) -- [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug) -- [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22) - -> [!NOTE] -> PRs that ignore these guardrails will likely be closed. - -Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on. - -## Adding New Providers - -New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to: -https://github.com/anomalyco/models.dev - -## Developing OpenCode - -- Requirements: Bun 1.3+ -- Install dependencies and start the dev server from the repo root: - - ```bash - bun install - bun dev - ``` - -### Running against a different directory - -By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository: - -```bash -bun dev -``` - -To run OpenCode in the root of the opencode repo itself: - -```bash -bun dev . -``` - -### Building a "localcode" - -To compile a standalone executable: - -```bash -./packages/opencode/script/build.ts --single -``` - -Then run it with: - -```bash -./packages/opencode/dist/opencode-/bin/opencode -``` - -Replace `` with your platform (e.g., `darwin-arm64`, `linux-x64`). - -- Core pieces: - - `packages/opencode`: OpenCode core business logic & server. - - `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui) - - `packages/app`: The shared web UI components, written in SolidJS - - `packages/desktop`: The native desktop app, built with Electron (wraps `packages/app`) - - `packages/plugin`: Source for `@opencode-ai/plugin` - -### Understanding bun dev vs opencode - -During development, `bun dev` is the local equivalent of the built `opencode` command. Both run the same CLI interface: - -```bash -# Development (from project root) -bun dev --help # Show all available commands -bun dev serve # Start headless API server -bun dev web # Start server + open web interface -bun dev # Start TUI in specific directory - -# Production -opencode --help # Show all available commands -opencode serve # Start headless API server -opencode web # Start server + open web interface -opencode # Start TUI in specific directory -``` - -### Running the API Server - -To start the OpenCode headless API server: - -```bash -bun dev serve -``` - -This starts the headless server on port 4096 by default. You can specify a different port: - -```bash -bun dev serve --port 8080 +```text +type(scope): summary ``` -### Running the Web App - -To test UI changes during development: - -1. **First, start the OpenCode server** (see [Running the API Server](#running-the-api-server) section above) -2. **Then run the web app:** +Types: -```bash -bun run --cwd packages/app dev -``` +- `feat` +- `fix` +- `refactor` +- `test` +- `docs` +- `ci` +- `chore` -This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here, but the server must be running for full functionality. +Preferred scopes: -### Running the Desktop App +- `copy` +- `prompt` +- `tui` +- `config` +- `keymap` +- `vim` +- `upstream` +- `deps` +- `release` -The desktop app is an Electron application that wraps the web UI. +## Development -To run the desktop app in development: +Use [Bun](https://bun.sh) matching `package.json`. ```bash -bun run --cwd packages/desktop dev +bun install +cd packages/opencode +bun typecheck +cd ../tui +bun test ./test/cli/tui/vim-*.test.ts ``` -To create a production build and package the app: +## Running locally ```bash -bun run --cwd packages/desktop build -bun run --cwd packages/desktop package +bun dev . ``` - -> [!NOTE] -> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files. - -Please try to follow the [style guide](./AGENTS.md) - -### Setting up a Debugger - -Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points. - -The most reliable way to debug OpenCode is to run it manually in a terminal via `bun run --inspect= dev ...` and attach -your debugger via that URL. Other methods can result in breakpoints being mapped incorrectly, at least in VSCode (YMMV). - -Caveats: - -- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of - the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there. -- If `spawn` does not work for you, you can debug the server separately: - - Debug server: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts serve --port 4096`, - then attach TUI with `opencode attach http://localhost:4096` - - Debug TUI: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts` - -Other tips and tricks: - -- You might want to use `--inspect-wait` or `--inspect-brk` instead of `--inspect`, depending on your workflow -- Specifying `--inspect=ws://localhost:6499/` on every invocation can be tiresome, you may want to `export BUN_OPTIONS=--inspect=ws://localhost:6499/` instead - -#### VSCode Setup - -If you use VSCode, you can use our example configurations [.vscode/settings.example.json](.vscode/settings.example.json) and [.vscode/launch.example.json](.vscode/launch.example.json). - -Some debug methods that can be problematic: - -- Debug configurations with `"request": "launch"` can have breakpoints incorrectly mapped and thus unusable -- The same problem arises when running OpenCode in the VSCode `JavaScript Debug Terminal` - -With that said, you may want to try these methods, as they might work for you. - -## Pull Request Expectations - -### Issue First Policy - -**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review. - -- Use `Fixes #123` or `Closes #123` in your PR description to link the issue -- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem - -### General Requirements - -- Keep pull requests small and focused -- Explain the issue and why your change fixes it -- Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase - -### UI Changes - -If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback. - -### Logic Changes - -For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**: - -- What did you test? -- How can a reviewer reproduce/confirm the fix? - -### No AI-Generated Walls of Text - -Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time: - -- Write short, focused descriptions -- Explain what changed and why in your own words -- If you can't explain it briefly, your PR might be too large - -### PR Titles - -PR titles should follow conventional commit standards: - -- `feat:` new feature or functionality -- `fix:` bug fix -- `docs:` documentation or README changes -- `chore:` maintenance tasks, dependency updates, etc. -- `refactor:` code refactoring without changing behavior -- `test:` adding or updating tests - -You can optionally include a scope to indicate which package is affected: - -- `feat(app):` feature in the app package -- `fix(desktop):` bug fix in the desktop package -- `chore(opencode):` maintenance in the opencode package - -Examples: - -- `docs: update contributing guidelines` -- `fix: resolve crash on startup` -- `feat: add dark mode support` -- `feat(app): add dark mode support` -- `fix(desktop): resolve crash on startup` -- `chore: bump dependency versions` - -### Style Preferences - -These are not strictly enforced, they are just general guidelines: - -- **Functions:** Keep logic within a single function unless breaking it out adds clear reuse or composition benefits. -- **Destructuring:** Do not do unnecessary destructuring of variables. -- **Control flow:** Avoid `else` statements. -- **Error handling:** Prefer `.catch(...)` instead of `try`/`catch` when possible. -- **Types:** Reach for precise types and avoid `any`. -- **Variables:** Stick to immutable patterns and avoid `let`. -- **Naming:** Choose concise single-word identifiers when they remain descriptive. -- **Runtime APIs:** Use Bun helpers such as `Bun.file()` when they fit the use case. - -## Feature Requests - -For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly. - -## Issue Requirements - -All issues **must** use one of our issue templates: - -- **Bug report** — for reporting bugs (requires a description) -- **Feature request** — for suggesting enhancements (requires verification checkbox and description) -- **Question** — for asking questions (requires the question) - -Blank issues are not allowed. When a new issue is opened, an automated check verifies that it follows a template and meets our contributing guidelines. If an issue doesn't meet the requirements, you'll receive a comment explaining what needs to be fixed and have **2 hours** to edit the issue. After that, it will be automatically closed. - -Issues may be flagged for: - -- Not using a template -- Required fields left empty or filled with placeholder text -- AI-generated walls of text -- Missing meaningful content - -If you believe your issue was incorrectly flagged, let a maintainer know. diff --git a/README.md b/README.md index b5a4c8ddd979..fa9881e6154f 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,365 @@ -

- - - - - OpenCode logo - - -

-

The open source AI coding agent.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Installation +
+ +# OpenCode Vim + +[![npm version](https://img.shields.io/npm/v/@leohenon/ocv?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@leohenon/ocv) [![CI](https://img.shields.io/github/actions/workflow/status/leohenon/opencode-vim/ci.yml?branch=ocv&style=flat-square&logo=github&logoColor=white&label=CI)](https://github.com/leohenon/opencode-vim/actions/workflows/ci.yml) [![Upstream sync](https://img.shields.io/github/actions/workflow/status/leohenon/opencode-vim/sync-upstream.yml?branch=ocv&style=flat-square&logo=github&logoColor=white&label=upstream%20sync)](https://github.com/leohenon/opencode-vim/actions/workflows/sync-upstream.yml) [![Website](https://img.shields.io/badge/website-opencode--vim-7fa6a3?style=flat-square)](https://leohenon.github.io/opencode-vim/) [![Bun](https://img.shields.io/badge/runtime-Bun-f9f1e1?style=flat-square&logo=bun&logoColor=white)](https://bun.sh) + +Keyboard-first OpenCode with Vim controls across the TUI, kept in sync with upstream releases. + +
+ +## Installation ```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash +# curl +curl -fsSL https://raw.githubusercontent.com/leohenon/opencode-vim/ocv/install.sh | sh # Package managers -npm i -g opencode-ai@latest # or bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date) -brew install opencode # macOS and Linux (official brew formula, updated less) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # Any OS -nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch +npm i -g @leohenon/ocv +brew install leohenon/tap/ocv + +# Arch Linux AUR (community-maintained) +yay -S opencode-vim-bin ``` -> [!TIP] -> Remove versions older than 0.1.x before installing. +Curl installs to `~/.ocv/bin`. Set `OCV_INSTALL_DIR` to install elsewhere. -### Desktop App (BETA) +## Usage -OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/anomalyco/opencode/releases) or [opencode.ai/download](https://opencode.ai/download). +```bash +ocv +``` -| Platform | Download | -| --------------------- | ---------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | -| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, or `.AppImage` | +## Update ```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` +# built-in updater +ocv update -#### Installation Directory +# Package managers +npm i -g @leohenon/ocv@latest +brew upgrade ocv +``` -The install script respects the following priority order for the installation path: +## Prefer a plugin? -1. `$OPENCODE_INSTALL_DIR` - Custom installation directory -2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path -3. `$HOME/bin` - Standard user binary directory (if it exists or can be created) -4. `$HOME/.opencode/bin` - Default fallback +The prompt Vim mode is also available as a plugin for the official OpenCode, prompt editing only (no copy mode or session navigation): ```bash -# Examples -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash +opencode plugin @leohenon/opencode-vim-plugin --global +``` + +See [opencode-vim-plugin](https://github.com/leohenon/opencode-vim-plugin). + +## Features + +### Prompt controls + +Toggle via command palette > `Toggle vim mode`. + +> Unicode word boundaries are not supported. + +| Category | Keys | +| ------------------------------ | ------------------------------------------------------------------------------------------------------- | +| Character / word | `h`, `j`, `k`, `l`, `w`, `b`, `e`, `W`, `B`, `E` | +| Line / buffer | `0`, `^`, `_`, `$`, `gg`, `G` | +| Display line | `gj`, `gk`, `g`, `g`, `g0`, `g^`, `g$` | +| Matching / paragraph | `%`, `{`, `}` | +| Find / till | `f`, `F`, `t`, `T`, `;`, `,` | +| Scroll | `Ctrl+e`, `Ctrl+y`, `Ctrl+d`, `Ctrl+u`, `Ctrl+f`, `Ctrl+b` | +| Insert / replace | `i`, `I`, `a`, `A`, `o`, `O`, `R` | +| Character / line edit | `r`, `x`, `~`, `s`, `S`, `J`, `C`, `D`, `dd`, `cc` | +| Word changes | `cw`, `cb`, `ce`, `cW`, `cE`, `ciw`, `caw`, `ciW`, `caW` | +| Word deletes | `dw`, `db`, `de`, `dW`, `dE`, `diw`, `daw`, `diW`, `daW` | +| Quote changes | `ci"`, `ca"`, `ci'`, `ca'`, ``ci` ``, ``ca` `` | +| Quote deletes | `di"`, `da"`, `di'`, `da'`, ``di` ``, ``da` `` | +| Bracket changes | `ci(`, `ca(`, `ci[`, `ca[`, `ci{`, `ca{`, `ci<`, `ca<` | +| Bracket deletes | `di(`, `da(`, `di[`, `da[`, `di{`, `da{`, `di<`, `da<` | +| Find / till operators | `cf`, `cF`, `ct`, `cT`, `df`, `dF`, `dt`, `dT` | +| Matching / paragraph operators | `c%`, `d%`, `c}`, `c{`, `d}`, `d{` | +| Line boundary operators | `c0`, `c^`, `c$`, `d0`, `d^`, `d$`, `y0`, `y^`, `y$` | +| Display line operators | `cgj`, `cgk`, `cg0`, `cg^`, `cg$`, `dgj`, `dgk`, `dg0`, `dg^`, `dg$`, `ygj`, `ygk`, `yg0`, `yg^`, `yg$` | +| Line / word yanks | `yy`, `yw`, `ye`, `yW`, `yE`, `yiw`, `yaw`, `yiW`, `yaW` | +| Quote yanks | `yi"`, `ya"`, `yi'`, `ya'`, ``yi` ``, ``ya` `` | +| Bracket yanks | `yi(`, `ya(`, `yi[`, `ya[`, `yi{`, `ya{`, `yi<`, `ya<` | +| Matching / paragraph yanks | `y%`, `y}`, `y{` | +| Put / undo / repeat | `p`, `P`, `u`, `Ctrl+r`, `.` | +| Visual selection | `v`, `V` | +| Chat history search | `/`, `?` | +| Commands | `:`, `:q` | + +Numeric count prefixes are supported for motions and common operators. + +> [!NOTE] +> `y` copies the prompt selection when present; configure it with `keybinds.prompt_copy_selection`. +> For clipboard sync, see [System clipboard register](#system-clipboard-register). + +### Copy mode + +Text selection from the chat session view. + +> Copy mode collapses code diffs into a single column for easy copying. + +| Keys | Action | +| ------------------------------ | --------------------------------------------------------------------------------------------- | +| `v`, `Ctrl+W k` | Enter copy mode | +| `h`, `j`, `k`, `l`, arrow keys | Navigate | +| `v`, `V`, `Ctrl+V` | Start character-wise, line-wise, or block selection | +| `y`, `yy` | Yank to the vim register | +| `Enter` | Copy to the system clipboard, toggle expandable tool output, or open a selected subagent task | +| `Y` | Yank to the vim register and scroll to the bottom | +| `Shift+Enter` | Copy to the system clipboard and scroll to the bottom | +| `Escape` | Exit visual mode | +| `q` | Exit copy mode and scroll to the bottom | +| `Ctrl+W j` | Exit copy mode without scrolling | +| `Ctrl+W w` | Toggle copy mode | +| `i` | Focus the prompt input in insert mode without scrolling | +| `z`, `zt`, `zz`, `zb` | Adjust copy-mode scroll positioning | +| `H`, `M`, `L` | Jump to the top, middle, or bottom of the viewport | +| `/`, `?` | Search forward or backward in chat history | +| `n`, `N` | Repeat search in the same or opposite direction | + +When in search mode, `Enter` submits the search, and `Escape` clears search highlights before exiting copy mode. + +> Search uses smartcase, lowercase queries are case-insensitive, and queries containing uppercase letters are case-sensitive. + +> [!TIP] +> Configure the copy mode entry key with `keybinds.copy_mode`. + +### Dialog controls + +Searchable dialogs and custom question answer inputs use modal controls. + +| Area | Controls | +| ------------------ | ----------------------------------------------------------------------- | +| Input mode | `Escape` enters normal mode; `i`, `a`, `/`, `I`, `A` return to insert | +| Input editing | `h`, `l`, `w`, `b`, `e`, `0`, `$` move within the input; `dd` clears it | +| Searchable dialogs | `j`, `k`, `gg`, `G` move through dialog items | +| Question dialogs | `j`, `k` move through answers; `h`, `l` move between questions | + +> [!TIP] +> Disable modal dialog inputs with `vim_modal_input: false`. + +### Minimal UI + +Hides extra UI hints and tips. + +Toggle via command palette > `Toggle minimal ui`. + +## Configuration + +### Options + +| Option | Purpose | +| ------------------------------- | ---------------------------------------- | +| `prompt_max_height` | Set max prompt input height | +| `prompt_scrollbar` | Show the prompt scrollbar | +| `vim_initial_mode` | Start in `insert` (default) or `normal` | +| `vim_enter_submit` | Submit with Enter from insert mode | +| `vim_insert_after_submit` | Return to insert mode after submit | +| `vim_system_clipboard_register` | Use the system clipboard as Vim register | +| `vim_modal_input` | Enable Vim controls in dialogs | +| `vim_langmap` | Map non-English keys or simple aliases | +| `vim_line_motions` | Use wrapped display-line motions | +| `vim_showbreak` | Mark wrapped prompt rows | +| `vim_escape_sequence` | Use a two-key escape sequence like `jk` | + +### Initial mode + +Vim mode starts in insert mode by default. To start in normal mode instead: + +```json +{ + "vim_initial_mode": "normal" +} +``` + +### Mode-scoped keybinds + +Bind existing commands only in normal mode by nesting overrides under `vim.normal`. + +For example, use `` as the leader: + +```json +{ + "keybinds": { + "vim.normal": { + "leader": "space", + "session_list": "s" + } + } +} ``` -### Agents +When `vim.normal.leader` is set without a top-level `leader`, it replaces the implicit default global leader (`Ctrl+X`). Configure both to keep both leaders. -OpenCode includes two built-in agents you can switch between with the `Tab` key. +### Prompt input height -- **build** - Default, full-access agent for development work -- **plan** - Read-only agent for analysis and code exploration - - Denies file edits by default - - Asks permission before running bash commands - - Ideal for exploring unfamiliar codebases or planning changes +Configure prompt input height in `tui.json`: -Also included is a **general** subagent for complex searches and multistep tasks. -This is used internally and can be invoked using `@general` in messages. +```json +{ + "prompt_max_height": 35, + "prompt_scrollbar": true +} +``` + +> `prompt_max_height` above `40` is not recommended. + +### Submit behavior + +By default, insert mode uses `Enter` for newlines and normal mode uses `Enter` to submit. + +To submit from insert mode too: + +```json +{ + "vim_enter_submit": true +} +``` + +To always default to insert mode after a prompt submission: + +```json +{ + "vim_insert_after_submit": true +} +``` + +To keep newline available: + +```json +{ + "keybinds": { + "input_newline": "alt+return" + } +} +``` + +Or configure a separate submit key: + +```json +{ + "keybinds": { + "input_force_submit": "alt+return" + } +} +``` + +`input_force_submit` is unbound by default. + +### System clipboard register + +Use the system clipboard as Vim's register: + +```json +{ + "vim_system_clipboard_register": true +} +``` + +Yank and delete operations sync to the system clipboard, `p` / `P` paste from it. + +### Modal dialog inputs + +Disable modal controls in searchable dialogs and custom question answer inputs: -Learn more about [agents](https://opencode.ai/docs/agents). +```json +{ + "vim_modal_input": false +} +``` + +> [!NOTE] +> Terminal/OS clipboard shortcuts don’t preserve Vim linewise register state. External clipboard text is pasted as characterwise text. + +### Vim langmap + +Map non-English keyboard layout characters to Vim command keys. + +> Keys and values should be single characters. + +```json +{ + "vim_langmap": { + "р": "h", + "о": "j", + "л": "k", + "д": "l" + } +} +``` + +Or or for simple aliases: + +```json +{ + "vim_langmap": { + "H": "^", + "L": "$" + } +} +``` + +### Vim line motions + +Choose how motions handle wrapped lines in the prompt: -### Documentation +```json +{ + "vim_line_motions": "logical" +} +``` + +Values: + +| Value | Behavior | +| ------------------ | ----------------------------------------------------------- | +| `logical` | Vertical and boundary motions use logical lines | +| `display_vertical` | Vertical motions use display lines; boundaries stay logical | +| `display` | Vertical and boundary motions use display lines | + +### Vim showbreak + +Set a marker for wrapped rows in the prompt: + +```json +{ + "vim_showbreak": true +} +``` + +Shows `↪ ` marker in the prompt's left padding. + +### Vim escape sequence -For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs). +Set a two-character sequence to leave insert mode without pressing `Escape`: -### Contributing +```json +{ + "vim_escape_sequence": "jk" +} +``` + +### Neovim integration -If you're interested in contributing to OpenCode, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request. +Compatible with [`opencode.nvim`](https://github.com/nickjvandyke/opencode.nvim). Use the following server config: -### Building on OpenCode +```lua +local ocv_cmd = "bash -c 'exec -a opencode ocv --port'" -If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way. +vim.g.opencode_opts = { + server = { + start = function() + require("opencode.terminal").open(ocv_cmd) + end, + toggle = function() + require("opencode.terminal").toggle(ocv_cmd) + end, + }, +} +``` ---- +## Thanks to all contributors! -**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) +@reobin @BrettKulp @lamiphil @XPhyro @shaheislam @semi710 @bjschafer diff --git a/assets/contributors/BrettKulp.png b/assets/contributors/BrettKulp.png new file mode 100644 index 000000000000..0a4c976cc77c Binary files /dev/null and b/assets/contributors/BrettKulp.png differ diff --git a/assets/contributors/XPhyro.png b/assets/contributors/XPhyro.png new file mode 100644 index 000000000000..32c853810581 Binary files /dev/null and b/assets/contributors/XPhyro.png differ diff --git a/assets/contributors/bjschafer.png b/assets/contributors/bjschafer.png new file mode 100644 index 000000000000..2365b6466c4e Binary files /dev/null and b/assets/contributors/bjschafer.png differ diff --git a/assets/contributors/lamiphil.png b/assets/contributors/lamiphil.png new file mode 100644 index 000000000000..fb9e10e633c9 Binary files /dev/null and b/assets/contributors/lamiphil.png differ diff --git a/assets/contributors/reobin.png b/assets/contributors/reobin.png new file mode 100644 index 000000000000..76cc5b31e4ae Binary files /dev/null and b/assets/contributors/reobin.png differ diff --git a/assets/contributors/semi710.png b/assets/contributors/semi710.png new file mode 100644 index 000000000000..3119f9eed1a3 Binary files /dev/null and b/assets/contributors/semi710.png differ diff --git a/assets/contributors/shaheislam.png b/assets/contributors/shaheislam.png new file mode 100644 index 000000000000..77d2213be4ec Binary files /dev/null and b/assets/contributors/shaheislam.png differ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000000..77ac2dab0465 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,16 @@ +# GitHub Pages site + +This directory is the GitHub Pages publish root for opencode-vim. +It is separate from the product docs in `packages/web/src/content/docs`. + +```bash +bun run build:site +``` + +This runs `docs/build.ts` and writes the static landing page to `docs/index.html`. + +Preview: + +```bash +python3 -m http.server 8000 -d docs +``` diff --git a/docs/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 b/docs/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 000000000000..a6c77d653d2e Binary files /dev/null and b/docs/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/docs/assets/fonts/ibm-plex-mono-latin-500-normal.woff2 b/docs/assets/fonts/ibm-plex-mono-latin-500-normal.woff2 new file mode 100644 index 000000000000..99c261038d13 Binary files /dev/null and b/docs/assets/fonts/ibm-plex-mono-latin-500-normal.woff2 differ diff --git a/docs/assets/fonts/ibm-plex-mono-latin-600-normal.woff2 b/docs/assets/fonts/ibm-plex-mono-latin-600-normal.woff2 new file mode 100644 index 000000000000..59e4f521046a Binary files /dev/null and b/docs/assets/fonts/ibm-plex-mono-latin-600-normal.woff2 differ diff --git a/docs/assets/fonts/ibm-plex-mono-latin-700-normal.woff2 b/docs/assets/fonts/ibm-plex-mono-latin-700-normal.woff2 new file mode 100644 index 000000000000..262a671625ed Binary files /dev/null and b/docs/assets/fonts/ibm-plex-mono-latin-700-normal.woff2 differ diff --git a/docs/assets/ocv.mp4 b/docs/assets/ocv.mp4 new file mode 100644 index 000000000000..871fd5aa0313 Binary files /dev/null and b/docs/assets/ocv.mp4 differ diff --git a/docs/build.ts b/docs/build.ts new file mode 100644 index 000000000000..1260ae3f67ae --- /dev/null +++ b/docs/build.ts @@ -0,0 +1,328 @@ +#!/usr/bin/env bun + +const installCommands = { + npm: "npm i -g @leohenon/ocv", + homebrew: "brew install leohenon/tap/ocv", + curl: "curl -fsSL https://raw.githubusercontent.com/leohenon/opencode-vim/ocv/install.sh | sh", +} + +const html = ` + + + + + Codestin Search App + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

OpenCode Vim

+

Vim mode for OpenCode

+

An unofficial OpenCode fork with built-in Vim motions, navigation, and editing.

+ +
+
+ + + +
+
+
+ curl -fsSL https://raw.githubusercontent.com/leohenon/opencode-vim/ocv/install.sh | sh + +
+
+
ocv
+
+
+
+ +
+ +
+ +
+
+
+
[*]
Vim motions

Operators, text objects, visual selection, undo, repeat.

+
[*]
Session navigation

Move through session output with Vim controls.

+
[*]
Copy mode

Select and copy text from sessions.

+
[*]
Up to date

Updated daily to stay current with OpenCode releases.

+
+
+
+ +
+
+

Prefer a plugin?

+

The prompt Vim mode is also available as a plugin for the official OpenCode — no fork required. Motions, operators, text objects, registers, and more, in the prompt only.

+
+
+ opencode-vim-plugin + + + +
+
+
+ opencode plugin @leohenon/opencode-vim-plugin --global + +
+
+
+
+
+
+ +
+
MIT License
+
+
+ + + + +` + +await Bun.write("docs/index.html", html) +console.log("Generated docs/index.html") diff --git a/docs/favicon.png b/docs/favicon.png new file mode 100644 index 000000000000..15266d28f159 Binary files /dev/null and b/docs/favicon.png differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 000000000000..bd8637f13219 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,725 @@ + + + + + + Codestin Search App + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+

OpenCode Vim

+

Vim mode for OpenCode

+

An unofficial OpenCode fork with built-in Vim motions, navigation, and editing.

+ +
+
+ + + +
+
+
+ curl -fsSL https://raw.githubusercontent.com/leohenon/opencode-vim/ocv/install.sh | sh + +
+
+
ocv
+
+
+
+ +
+ +
+ +
+
+
+
+ [*] +
+ Vim motions +

Operators, text objects, visual selection, undo, repeat.

+
+
+
+ [*] +
+ Session navigation +

Move through session output with Vim controls.

+
+
+
+ [*] +
+ Copy mode +

Select and copy text from sessions.

+
+
+
+ [*] +
+ Up to date +

Updated daily to stay current with OpenCode releases.

+
+
+
+
+
+ +
+
+

Prefer a plugin?

+

+ Vim mode is also available as a plugin for the official OpenCode. Motions, operators, text objects, + registers, and more, in the prompt only. +

+
+
+ opencode-vim-plugin + + + +
+
+
+ opencode plugin @leohenon/opencode-vim-plugin --global + +
+
+
+
+
+
+ +
+
MIT License
+
+
+ + + + diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 000000000000..3d72fce2e2be --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://leohenon.github.io/opencode-vim/sitemap.xml diff --git a/docs/sitemap.xml b/docs/sitemap.xml new file mode 100644 index 000000000000..7f1af8e39f08 --- /dev/null +++ b/docs/sitemap.xml @@ -0,0 +1,8 @@ + + + + https://leohenon.github.io/opencode-vim/ + weekly + 1.0 + + diff --git a/install.sh b/install.sh new file mode 100755 index 000000000000..abd6c6801fab --- /dev/null +++ b/install.sh @@ -0,0 +1,121 @@ +#!/bin/sh +set -e + +REPO="leohenon/opencode-vim" +INSTALL_DIR="${OCV_INSTALL_DIR:-$HOME/.ocv/bin}" + +# Detect platform +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +case "$OS" in +linux) PLATFORM="linux" ;; +darwin) PLATFORM="darwin" ;; +*) echo "Unsupported OS: $OS" && exit 1 ;; +esac + +case "$ARCH" in +x86_64 | amd64) ARCH="x64" ;; +aarch64 | arm64) ARCH="arm64" ;; +*) echo "Unsupported architecture: $ARCH" && exit 1 ;; +esac + +TARGET="ocv-${PLATFORM}-${ARCH}" + +# Get latest version +if [ -z "$OCV_VERSION" ]; then + VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed 's/.*"v\(.*\)".*/\1/') +else + VERSION="$OCV_VERSION" +fi + +if [ -z "$VERSION" ]; then + echo "Could not determine latest version" + exit 1 +fi + +echo "Installing ocv v$VERSION ($PLATFORM/$ARCH)..." +mkdir -p "$INSTALL_DIR" + +# Download +URL="https://github.com/$REPO/releases/download/v$VERSION/$TARGET" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +if [ "$PLATFORM" = "linux" ]; then + URL="$URL.tar.gz" + curl -fSL "$URL" | tar -xz -C "$TMP" +else + URL="$URL.zip" + curl -fSL -o "$TMP/ocv.zip" "$URL" + unzip -q "$TMP/ocv.zip" -d "$TMP" +fi + +mv "$TMP/opencode" "$INSTALL_DIR/ocv" +chmod +x "$INSTALL_DIR/ocv" + +add_to_path() { + CONFIG_FILE="$1" + COMMAND="$2" + + if [ ! -f "$CONFIG_FILE" ]; then + return 1 + fi + + if grep -Fxq "$COMMAND" "$CONFIG_FILE"; then + return 0 + fi + + if [ -w "$CONFIG_FILE" ]; then + { + echo "" + echo "# OpenCode Vim" + echo "$COMMAND" + } >> "$CONFIG_FILE" + echo "Added $INSTALL_DIR to PATH in $CONFIG_FILE" + return 0 + fi + + return 1 +} + +if [ "${OCV_NO_MODIFY_PATH:-}" != "1" ] && [ "${OCV_INSTALL_DIR:-}" = "" ]; then + CURRENT_SHELL=$(basename "${SHELL:-sh}") + PATH_COMMAND="export PATH=\"$INSTALL_DIR:\$PATH\"" + + case "$CURRENT_SHELL" in + fish) + PATH_COMMAND="fish_add_path \"$INSTALL_DIR\"" + add_to_path "$HOME/.config/fish/config.fish" "$PATH_COMMAND" || true + ;; + zsh) + add_to_path "${ZDOTDIR:-$HOME}/.zshrc" "$PATH_COMMAND" || add_to_path "${ZDOTDIR:-$HOME}/.zshenv" "$PATH_COMMAND" || true + ;; + bash) + add_to_path "$HOME/.bashrc" "$PATH_COMMAND" || add_to_path "$HOME/.bash_profile" "$PATH_COMMAND" || add_to_path "$HOME/.profile" "$PATH_COMMAND" || true + ;; + *) + add_to_path "$HOME/.profile" "$PATH_COMMAND" || true + ;; + esac +fi + +case ":$PATH:" in +*":$INSTALL_DIR:"*) ;; +*) + echo "" + echo "Add ocv to your PATH:" + echo " export PATH=\"$INSTALL_DIR:\$PATH\"" + echo "" + echo "Then restart your shell or run the command above." + ;; +esac + +echo "Installed ocv to $INSTALL_DIR/ocv" +echo "Run 'ocv' to start." + +if [ "$INSTALL_DIR" != "/usr/local/bin" ] && [ -x "/usr/local/bin/ocv" ]; then + echo "" + echo "Note: /usr/local/bin/ocv also exists from a previous install." + echo "Remove it or make sure $INSTALL_DIR comes first in PATH." +fi diff --git a/nix/scripts/compute-node-modules-hash.sh b/nix/scripts/compute-node-modules-hash.sh new file mode 100755 index 000000000000..5d05ab47759f --- /dev/null +++ b/nix/scripts/compute-node-modules-hash.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SYSTEM="${SYSTEM:?SYSTEM is required}" +OUTPUT_FILE="${1:-hash.txt}" +BUILD_LOG=$(mktemp) +trap 'rm -f "$BUILD_LOG"' EXIT + +HASH="" +MAX_ATTEMPTS=3 +for ((ATTEMPT = 1; ATTEMPT <= MAX_ATTEMPTS; ATTEMPT++)); do + # Build with fakeHash to trigger hash mismatch and reveal the correct hash. + nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true + + HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" + + [ -n "$HASH" ] && break + + if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then + echo "::warning::Attempt ${ATTEMPT}/${MAX_ATTEMPTS} produced no hash for ${SYSTEM}; retrying in $((ATTEMPT * 10))s" + sleep $((ATTEMPT * 10)) + fi +done + +if [ -z "$HASH" ]; then + echo "::error::Failed to compute hash for ${SYSTEM} after ${MAX_ATTEMPTS} attempts" + cat "$BUILD_LOG" + exit 1 +fi + +echo "$HASH" > "$OUTPUT_FILE" +echo "Computed hash for ${SYSTEM}: $HASH" diff --git a/nix/scripts/update-node-modules-hashes-json.sh b/nix/scripts/update-node-modules-hashes-json.sh new file mode 100755 index 000000000000..9b77e0f8e8d1 --- /dev/null +++ b/nix/scripts/update-node-modules-hashes-json.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +HASH_DIR="${1:-hashes}" +HASH_FILE="${2:-nix/hashes.json}" +MISSING_MODE="${3:-error}" + +[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE" + +for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do + FILE="$HASH_DIR/hash-${SYSTEM}/hash.txt" + if [ -f "$FILE" ]; then + HASH="$(tr -d '[:space:]' < "$FILE")" + echo "${SYSTEM}: ${HASH}" + jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json + mv tmp.json "$HASH_FILE" + continue + fi + + if [ "$MISSING_MODE" = "warn" ]; then + echo "::warning::Missing hash for ${SYSTEM}" + continue + fi + + echo "::error::Missing hash for ${SYSTEM}" + exit 1 +done + +cat "$HASH_FILE" diff --git a/package.json b/package.json index a3f9544410da..87b7f3f5ff8b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "postinstall": "bun run --cwd packages/core fix-node-pty", "prepare": "husky", "random": "echo 'Random script'", + "hello": "echo 'Hello World!'", + "build:site": "bun run docs/build.ts", "sso": "aws sso login --sso-session=opencode --no-browser", "translate:app": "bun run script/translate-app.ts", "test": "echo 'do not run tests from root' && exit 1" diff --git a/packages/core/src/github-copilot/responses/tool/code-interpreter.ts b/packages/core/src/github-copilot/responses/tool/code-interpreter.ts index 909694ec7d69..cfa093a847d8 100644 --- a/packages/core/src/github-copilot/responses/tool/code-interpreter.ts +++ b/packages/core/src/github-copilot/responses/tool/code-interpreter.ts @@ -1,4 +1,4 @@ -import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" +import { createFactoryWithOutput } from "./factory" import { z } from "zod/v4" export const codeInterpreterInputSchema = z.object({ @@ -37,7 +37,7 @@ type CodeInterpreterArgs = { container?: string | { fileIds?: string[] } } -export const codeInterpreterToolFactory = createProviderToolFactoryWithOutputSchema< +export const codeInterpreterToolFactory = createFactoryWithOutput< { /** * The code to run, or null if not available. diff --git a/packages/core/src/github-copilot/responses/tool/factory.ts b/packages/core/src/github-copilot/responses/tool/factory.ts new file mode 100644 index 000000000000..b9c5f8c5284d --- /dev/null +++ b/packages/core/src/github-copilot/responses/tool/factory.ts @@ -0,0 +1,17 @@ +import * as util from "@ai-sdk/provider-utils" + +type Factory = typeof util.createProviderToolFactory +type OutputFactory = typeof util.createProviderToolFactoryWithOutputSchema + +const compat = util as typeof util & { + createProviderDefinedToolFactory?: Factory + createProviderDefinedToolFactoryWithOutputSchema?: OutputFactory +} + +export const createFactory = + compat.createProviderDefinedToolFactory ?? + util.createProviderToolFactory + +export const createFactoryWithOutput = + compat.createProviderDefinedToolFactoryWithOutputSchema ?? + util.createProviderToolFactoryWithOutputSchema diff --git a/packages/core/src/github-copilot/responses/tool/file-search.ts b/packages/core/src/github-copilot/responses/tool/file-search.ts index 12a490e19dd6..f298f33d78e7 100644 --- a/packages/core/src/github-copilot/responses/tool/file-search.ts +++ b/packages/core/src/github-copilot/responses/tool/file-search.ts @@ -1,4 +1,4 @@ -import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" +import { createFactoryWithOutput } from "./factory" import type { OpenAIResponsesFileSearchToolComparisonFilter, OpenAIResponsesFileSearchToolCompoundFilter, @@ -43,7 +43,7 @@ export const fileSearchOutputSchema = z.object({ .nullable(), }) -export const fileSearch = createProviderToolFactoryWithOutputSchema< +export const fileSearch = createFactoryWithOutput< {}, { /** diff --git a/packages/core/src/github-copilot/responses/tool/image-generation.ts b/packages/core/src/github-copilot/responses/tool/image-generation.ts index b67bb76f9ce4..e6a88be0b202 100644 --- a/packages/core/src/github-copilot/responses/tool/image-generation.ts +++ b/packages/core/src/github-copilot/responses/tool/image-generation.ts @@ -1,4 +1,4 @@ -import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" +import { createFactoryWithOutput } from "./factory" import { z } from "zod/v4" export const imageGenerationArgsSchema = z @@ -92,7 +92,7 @@ type ImageGenerationArgs = { size?: "auto" | "1024x1024" | "1024x1536" | "1536x1024" } -const imageGenerationToolFactory = createProviderToolFactoryWithOutputSchema< +const imageGenerationToolFactory = createFactoryWithOutput< {}, { /** diff --git a/packages/core/src/github-copilot/responses/tool/local-shell.ts b/packages/core/src/github-copilot/responses/tool/local-shell.ts index 45230d5ce55d..2c02e0784df7 100644 --- a/packages/core/src/github-copilot/responses/tool/local-shell.ts +++ b/packages/core/src/github-copilot/responses/tool/local-shell.ts @@ -1,4 +1,4 @@ -import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" +import { createFactoryWithOutput } from "./factory" import { z } from "zod/v4" export const localShellInputSchema = z.object({ @@ -16,7 +16,7 @@ export const localShellOutputSchema = z.object({ output: z.string(), }) -export const localShell = createProviderToolFactoryWithOutputSchema< +export const localShell = createFactoryWithOutput< { /** * Execute a shell command on the server. diff --git a/packages/core/src/github-copilot/responses/tool/web-search-preview.ts b/packages/core/src/github-copilot/responses/tool/web-search-preview.ts index 3d9a308d8ac0..bf1aedab33a2 100644 --- a/packages/core/src/github-copilot/responses/tool/web-search-preview.ts +++ b/packages/core/src/github-copilot/responses/tool/web-search-preview.ts @@ -1,4 +1,4 @@ -import { createProviderToolFactory } from "@ai-sdk/provider-utils" +import { createFactory } from "./factory" import { z } from "zod/v4" // Args validation schema @@ -40,7 +40,7 @@ export const webSearchPreviewArgsSchema = z.object({ .optional(), }) -export const webSearchPreview = createProviderToolFactory< +export const webSearchPreview = createFactory< { // Web search doesn't take input parameters - it's controlled by the prompt }, diff --git a/packages/core/src/github-copilot/responses/tool/web-search.ts b/packages/core/src/github-copilot/responses/tool/web-search.ts index e380bb13b62a..d3bbca4ace08 100644 --- a/packages/core/src/github-copilot/responses/tool/web-search.ts +++ b/packages/core/src/github-copilot/responses/tool/web-search.ts @@ -1,4 +1,4 @@ -import { createProviderToolFactory } from "@ai-sdk/provider-utils" +import { createFactory } from "./factory" import { z } from "zod/v4" export const webSearchArgsSchema = z.object({ @@ -21,7 +21,7 @@ export const webSearchArgsSchema = z.object({ .optional(), }) -export const webSearchToolFactory = createProviderToolFactory< +export const webSearchToolFactory = createFactory< { // Web search doesn't take input parameters - it's controlled by the prompt }, diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 30e12cff1206..a4559e878c75 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -2,8 +2,8 @@ export * as Npm from "./npm" import path from "path" import npa from "npm-package-arg" -import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" -import { NodeFileSystem } from "@effect/platform-node" +import semver from "semver" +import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect" import { FSUtil } from "./fs-util" import { Global } from "./global" import { EffectFlock } from "./util/effect-flock" @@ -12,6 +12,8 @@ import { filesystem } from "./effect/app-node-platform" import { LayerNode } from "./effect/layer-node" import { makeRuntime } from "./effect/runtime" import { NpmConfig } from "./npm-config" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { CrossSpawnSpawner } from "./cross-spawn-spawner" export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { add: Schema.Array(Schema.String).pipe(Schema.optional), @@ -35,6 +37,7 @@ export interface Interface { }[] }, ) => Effect.Effect + readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect readonly which: (pkg: string, bin?: string) => Effect.Effect } @@ -76,7 +79,33 @@ const layer = Layer.effect( const global = yield* Global.Service const fs = yield* FileSystem.FileSystem const flock = yield* EffectFlock.Service + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg)) + const runView = Effect.fnUntraced(function* (cmd: string[]) { + const handle = yield* spawner.spawn( + ChildProcess.make(cmd[0], cmd.slice(1), { + extendEnv: true, + }), + ) + const [stdout, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + if (code !== 0 || !stdout.trim()) { + return yield* Effect.fail(stderr || stdout || `Failed to run ${cmd.join(" ")}`) + } + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(stdout) + }, Effect.scoped) + const viewLatestVersion = Effect.fnUntraced(function* (pkg: string) { + return yield* runView(["npm", "view", pkg, "dist-tags.latest", "--json"]).pipe( + Effect.catch(() => + runView(["pnpm", "view", pkg, "dist-tags.latest", "--json"]).pipe( + Effect.catch(() => runView(["bun", "pm", "view", pkg, "dist-tags.latest", "--json"])), + ), + ), + ) + }) const reify = (input: { dir: string; add?: string[] }) => Effect.gen(function* () { yield* flock.acquire(`npm-install:${input.dir}`) @@ -112,18 +141,57 @@ const layer = Layer.effect( }), ) + const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) { + const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option) + if (Option.isNone(latestVersion)) { + return false + } + + const range = /[\s^~*xX<>|=]/.test(cachedVersion) + if (range) return !semver.satisfies(latestVersion.value, cachedVersion) + + return semver.lt(cachedVersion, latestVersion.value) + }) + const add = Effect.fn("Npm.add")(function* (pkg: string) { const dir = directory(pkg) - const name = (() => { + const parsed = (() => { try { - return npa(pkg).name ?? pkg - } catch { - return pkg - } + return npa(pkg) + } catch {} })() - - if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { - return resolveEntryPoint(name, path.join(dir, "node_modules", name)) + const name = parsed?.name ?? pkg + const target = path.join(dir, "node_modules", name) + + if (yield* afs.existsSafe(target)) { + if (parsed?.type !== "tag" || parsed.rawSpec !== "latest") return resolveEntryPoint(name, target) + const json = yield* afs.readJson(path.join(target, "package.json")).pipe(Effect.option) + if (Option.isSome(json) && json.value && typeof json.value === "object" && "version" in json.value) { + const version = json.value.version + if (typeof version === "string" && !(yield* outdated(name, version))) return resolveEntryPoint(name, target) + } + const backup = `${dir}.backup-${Date.now()}-${Math.random().toString(36).slice(2)}` + yield* fs.rename(dir, backup).pipe( + Effect.mapError((cause) => new InstallFailedError({ cause, add: [pkg], dir })), + ) + const tree = yield* reify({ dir, add: [pkg] }).pipe( + Effect.catch((error) => + fs.remove(dir, { recursive: true, force: true }).pipe( + Effect.catch(() => Effect.void), + Effect.flatMap(() => fs.rename(backup, dir)), + Effect.catch(() => Effect.void), + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ) + const first = tree.edgesOut.values().next().value?.to + if (!first) { + yield* fs.remove(dir, { recursive: true, force: true }).pipe(Effect.catch(() => Effect.void)) + yield* fs.rename(backup, dir).pipe(Effect.catch(() => Effect.void)) + return yield* new InstallFailedError({ add: [pkg], dir }) + } + yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.orElseSucceed(() => {})) + return resolveEntryPoint(first.name, first.path) } const tree = yield* reify({ dir, add: [pkg] }) @@ -243,6 +311,7 @@ const layer = Layer.effect( return Service.of({ add, install, + outdated, which, }) }), @@ -251,7 +320,7 @@ const layer = Layer.effect( export const node = makeGlobalNode({ service: Service, layer: layer, - deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node], + deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node, CrossSpawnSpawner.node], }) const { runPromise } = makeRuntime(Service, LayerNode.compile(node)) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 2e134c55e181..37875d1db5b0 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -166,6 +166,7 @@ describe("ConfigExternalPlugin", () => { } }), install: () => Effect.void, + outdated: () => Effect.succeed(false), which: () => Effect.succeed(undefined), }) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 0d5b52180aac..4cb9246d28d6 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -23,6 +23,7 @@ const npmLayer = Layer.succeed( Npm.Service.of({ add: () => Effect.succeed({ directory: "", entrypoint: undefined }), install: () => Effect.void, + outdated: () => Effect.succeed(false), which: () => Effect.succeed(undefined), }), ) diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index 9eda0351438f..f113cac47ea8 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -24,6 +24,7 @@ function npmEntrypoint(entrypoint?: string) { return Npm.Service.of({ add: () => Effect.succeed({ directory: "", entrypoint }), install: () => Effect.void, + outdated: () => Effect.succeed(false), which: () => Effect.succeed(undefined), }) } diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 5056c5dbb7ef..0fb7cbd05812 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -15,6 +15,7 @@ const it = testEffect(PluginTestLayer) const npm = Npm.Service.of({ add: () => Effect.succeed({ directory: "", entrypoint: undefined }), install: () => Effect.void, + outdated: () => Effect.succeed(false), which: () => Effect.succeed(undefined), }) diff --git a/packages/opencode/script/publish-ocv-npm.ts b/packages/opencode/script/publish-ocv-npm.ts new file mode 100644 index 000000000000..e361d790c47c --- /dev/null +++ b/packages/opencode/script/publish-ocv-npm.ts @@ -0,0 +1,265 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +const file = fileURLToPath(import.meta.url) +const root = path.resolve(path.dirname(file), "..") +process.chdir(root) + +const version = process.env.OPENCODE_VERSION +if (!version) { + throw new Error("OPENCODE_VERSION is required") +} + +const tag = process.env.OCV_NPM_TAG || "latest" +const tries = 6 +const canPublish = process.env.CI === "true" || process.env.OCV_ALLOW_LOCAL_PUBLISH === "1" + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function exists(name: string, version: string) { + const result = await $`npm view ${`${name}@${version}`} version --json`.nothrow() + return result.exitCode === 0 +} + +async function publish(dir: string, name: string, version: string) { + if (!canPublish) { + console.log(`dry run (set OCV_ALLOW_LOCAL_PUBLISH=1 to publish): ${name}@${version}`) + return + } + + if (await exists(name, version)) { + console.log(`skip ${name}@${version} (already published)`) + return + } + + for (let i = 0; i < tries; i++) { + const result = await $`npm publish *.tgz --access public --tag ${tag}`.cwd(dir).nothrow() + if (result.exitCode === 0) { + console.log(`published ${name}@${version}`) + return + } + + const err = result.stderr.toString() + "\n" + result.stdout.toString() + if (err.includes("cannot publish over the previously published versions") || err.includes("EPUBLISHCONFLICT")) { + console.log(`skip ${name}@${version} (already published)`) + return + } + + const limited = err.includes("E429") || err.includes("429 Too Many Requests") || err.includes("rate limited") + if (!limited || i === tries - 1) { + throw new Error(`failed to publish ${name}@${version}: ${err}`) + } + + const ms = 5000 * 2 ** i + console.log(`rate limited publishing ${name}@${version}, retrying in ${ms}ms`) + await wait(ms) + } +} + +const out = path.join(root, "dist", "npm", "ocv") +fs.rmSync(out, { recursive: true, force: true }) +fs.mkdirSync(path.join(out, "bin"), { recursive: true }) + +const pkg = { + name: "@leohenon/ocv", + version, + license: "MIT", + repository: { + type: "git", + url: "https://github.com/leohenon/opencode-vim", + }, + homepage: "https://leohenon.github.io/opencode-vim/", + bugs: { + url: "https://github.com/leohenon/opencode-vim/issues", + }, + bin: { + ocv: "./bin/ocv", + }, + scripts: { + postinstall: "node ./postinstall.cjs", + }, + engines: { + node: ">=18", + }, +} + +const launcher = `#!/usr/bin/env node + +const childProcess = require("child_process") +const fs = require("fs") +const path = require("path") + +function run(target) { + const result = childProcess.spawnSync(target, process.argv.slice(2), { + stdio: "inherit", + }) + if (result.error) { + console.error(result.error.message) + process.exit(1) + } + const code = typeof result.status === "number" ? result.status : 0 + process.exit(code) +} + +const envPath = process.env.OCV_BIN_PATH +if (envPath) run(envPath) + +const ext = process.platform === "win32" ? ".exe" : "" +const bin = path.join(__dirname, ".ocv" + ext) +if (!fs.existsSync(bin)) { + console.error("ocv binary is missing. Try reinstalling: npm i -g @leohenon/ocv@latest") + process.exit(1) +} + +run(bin) +` + +const postinstall = `#!/usr/bin/env node + +const fs = require("fs") +const os = require("os") +const path = require("path") + +const version = process.env.npm_package_version +const baseUrl = process.env.OCV_RELEASE_BASE_URL || "https://github.com/leohenon/opencode-vim/releases/download" +const node = Number((process.versions.node || "0").split(".")[0]) + +function text(err) { + return err instanceof Error ? err.message : String(err) +} + +function platform() { + const map = { darwin: "darwin", linux: "linux", win32: "windows" } + return map[os.platform()] || os.platform() +} + +function arch() { + const map = { x64: "x64", arm64: "arm64", arm: "arm" } + return map[os.arch()] || os.arch() +} + +function avx2(p, a) { + if (a !== "x64") return false + + if (p === "linux") { + try { + return /(^|\\s)avx2(\\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) + } catch { + return false + } + } + + if (p === "darwin") { + try { + const childProcess = require("child_process") + const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { + encoding: "utf8", + timeout: 1500, + }) + if (result.status !== 0) return false + return (result.stdout || "").trim() === "1" + } catch { + return false + } + } + + return false +} + +function musl() { + try { + if (fs.existsSync("/etc/alpine-release")) return true + } catch {} + + try { + const childProcess = require("child_process") + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase() + if (text.includes("musl")) return true + } catch {} + + return false +} + +function names(p, a) { + const base = "ocv-" + p + "-" + a + const baseline = a === "x64" && !avx2(p, a) + + if (p === "linux") { + if (musl()) { + if (a === "x64") { + if (baseline) return [base + "-baseline-musl", base + "-musl", base + "-baseline", base] + return [base + "-musl", base + "-baseline-musl", base, base + "-baseline"] + } + return [base + "-musl", base] + } + + if (a === "x64") { + if (baseline) return [base + "-baseline", base, base + "-baseline-musl", base + "-musl"] + return [base, base + "-baseline", base + "-musl", base + "-baseline-musl"] + } + return [base, base + "-musl"] + } + + if (a === "x64") { + if (baseline) return [base + "-baseline", base] + return [base, base + "-baseline"] + } + + return [base] +} + +async function main() { + if (node < 18) { + throw new Error("@leohenon/ocv install requires Node 18+ (detected " + process.versions.node + ")") + } + + const p = platform() + const a = arch() + const ext = p === "windows" ? ".exe" : "" + const out = path.join(__dirname, "bin", ".ocv" + ext) + const list = names(p, a) + + fs.mkdirSync(path.dirname(out), { recursive: true }) + + for (const name of list) { + const url = baseUrl + "/v" + version + "/" + name + ext + let result + try { + result = await fetch(url) + } catch (err) { + throw new Error("Failed to download " + name + ext + " from " + url + ": " + text(err)) + } + if (result.status === 404) continue + if (!result.ok) { + throw new Error("Failed to download " + name + ext + " from " + url + ": " + result.status + " " + result.statusText) + } + const data = Buffer.from(await result.arrayBuffer()) + fs.writeFileSync(out, data) + fs.chmodSync(out, 0o755) + console.log("installed ocv binary: " + name + ext) + return + } + + throw new Error("No compatible release asset found for " + p + "/" + a + " (" + list.join(", ") + ")") +} + +main().catch((err) => { + console.error("Failed to install ocv binary:", err.message) + process.exit(1) +}) +` + +fs.writeFileSync(path.join(out, "package.json"), JSON.stringify(pkg, null, 2) + "\n") +fs.writeFileSync(path.join(out, "README.md"), "# @leohenon/ocv\n\nocv (OpenCode fork with vim keybindings).\n") +fs.writeFileSync(path.join(out, "postinstall.cjs"), postinstall) +fs.writeFileSync(path.join(out, "bin", "ocv"), launcher) +fs.chmodSync(path.join(out, "bin", "ocv"), 0o755) +fs.copyFileSync(path.join(root, "..", "..", "LICENSE"), path.join(out, "LICENSE")) + +await $`bun pm pack`.cwd(out) +await publish(out, "@leohenon/ocv", version) diff --git a/packages/opencode/src/cli/cmd/run/splash.ts b/packages/opencode/src/cli/cmd/run/splash.ts index 141ff6fc5537..819ccf42d1ad 100644 --- a/packages/opencode/src/cli/cmd/run/splash.ts +++ b/packages/opencode/src/cli/cmd/run/splash.ts @@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback lines, body_left + label.length, top + 1, - `opencode --mini -s ${meta.session_id}`, + `ocv --mini -s ${meta.session_id}`, right, undefined, TextAttributes.BOLD, diff --git a/packages/opencode/src/cli/cmd/update.ts b/packages/opencode/src/cli/cmd/update.ts new file mode 100644 index 000000000000..098b4ef5e2ff --- /dev/null +++ b/packages/opencode/src/cli/cmd/update.ts @@ -0,0 +1,112 @@ +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { Installation } from "../../installation" +import fs from "fs/promises" +import os from "os" +import path from "path" + +const REPO = "leohenon/opencode-vim" + +async function latest() { + const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`) + if (!res.ok) throw new Error(`Failed to fetch latest release: ${res.statusText}`) + const data = (await res.json()) as { tag_name: string } + return data.tag_name.replace(/^v/, "") +} + +function target() { + const platform = os.platform() + const arch = os.arch() + const p = platform === "linux" ? "linux" : platform === "darwin" ? "darwin" : null + if (!p) throw new Error(`Unsupported platform: ${platform}`) + const a = arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : null + if (!a) throw new Error(`Unsupported architecture: ${arch}`) + return { platform: p, arch: a } +} + +async function download(version: string) { + const t = target() + const name = `ocv-${t.platform}-${t.arch}` + const ext = t.platform === "linux" ? "tar.gz" : "zip" + const url = `https://github.com/${REPO}/releases/download/v${version}/${name}.${ext}` + const res = await fetch(url) + if (!res.ok) throw new Error(`Download failed: ${res.statusText}`) + return { data: Buffer.from(await res.arrayBuffer()), ext } +} + +async function extract(data: Buffer, ext: string, dir: string) { + if (ext === "tar.gz") { + const tar = path.join(dir, "ocv.tar.gz") + await fs.writeFile(tar, data) + const proc = Bun.spawn(["tar", "-xzf", tar, "-C", dir]) + await proc.exited + if (proc.exitCode !== 0) throw new Error("Failed to extract tar.gz") + } else { + const zip = path.join(dir, "ocv.zip") + await fs.writeFile(zip, data) + const proc = Bun.spawn(["unzip", "-q", "-o", zip, "-d", dir]) + await proc.exited + if (proc.exitCode !== 0) throw new Error("Failed to extract zip") + } +} + +export const UpdateCommand = { + command: "update", + describe: "update ocv to the latest version", + handler: async () => { + UI.empty() + UI.println(UI.logo(" ")) + UI.empty() + prompts.intro("Update") + + const spinner = prompts.spinner() + spinner.start("Checking for updates...") + const version = await latest() + spinner.stop(`Latest version: ${version}`) + + if (Installation.VERSION === version) { + prompts.log.warn(`Already on the latest version (${version})`) + prompts.outro("Done") + return + } + + prompts.log.info(`${Installation.VERSION} → ${version}`) + + spinner.start("Downloading...") + const result = await download(version).catch((e) => e as Error) + if (result instanceof Error) { + spinner.stop("Download failed", 1) + prompts.log.error(result.message) + prompts.outro("Done") + return + } + spinner.stop("Downloaded") + + const spinner2 = prompts.spinner() + spinner2.start("Installing...") + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "ocv-")) + const err = await (async () => { + try { + await extract(result.data, result.ext, tmp) + const bin = path.join(tmp, "opencode") + const dest = process.execPath + const staging = dest + ".tmp" + await fs.copyFile(bin, staging) + await fs.chmod(staging, 0o755) + await fs.rename(staging, dest) + } finally { + await fs.rm(tmp, { recursive: true, force: true }) + } + })().catch((e) => e as Error) + if (err) { + spinner2.stop("Install failed", 1) + prompts.log.error(err.message) + prompts.outro("Done") + return + } + spinner2.stop("Installed") + + prompts.log.success(`Updated to ${version}`) + prompts.outro("Done") + }, +} diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 60bba4d63634..d6cf9f108fe0 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -1,7 +1,7 @@ import { Glob } from "@opencode-ai/core/util/glob" import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { pathToFileURL } from "url" -import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" +import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget, type PluginKind } from "@/plugin/shared" import path from "path" export type Scope = "global" | "local" @@ -42,6 +42,7 @@ export function pluginOptions(plugin: ConfigPluginV1.Spec): ConfigPluginV1.Optio export async function resolvePluginSpec( plugin: ConfigPluginV1.Spec, configFilepath: string, + kind: PluginKind = "server", ): Promise { const spec = pluginSpecifier(plugin) if (!isPathPluginSpec(spec)) return plugin @@ -53,7 +54,7 @@ export async function resolvePluginSpec( return pathToFileURL(path.resolve(base, spec)).href })() - const resolved = await resolvePathPluginTarget(file).catch(() => file) + const resolved = await resolvePathPluginTarget(file, kind).catch(() => file) if (Array.isArray(plugin)) return [resolved, plugin[1]] return resolved diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/config/tui-migrate.ts index 6ca254311e3b..345fe3b7591c 100644 --- a/packages/opencode/src/config/tui-migrate.ts +++ b/packages/opencode/src/config/tui-migrate.ts @@ -12,9 +12,13 @@ const TUI_SCHEMA_URL = "https://opencode.ai/tui.json" const decodeTheme = Schema.decodeUnknownOption(Schema.String) const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) +const decodeLangmap = Schema.decodeUnknownOption(TuiConfig.VimLangmap) +const decodeInitialMode = Schema.decodeUnknownOption(TuiConfig.VimInitialMode) +const decodeLineMotions = Schema.decodeUnknownOption(TuiConfig.VimLineMotions) const decodeScrollSpeed = Schema.decodeUnknownOption(TuiConfig.ScrollSpeed) const decodeScrollAcceleration = Schema.decodeUnknownOption(TuiConfig.ScrollAcceleration) const decodeDiffStyle = Schema.decodeUnknownOption(TuiConfig.DiffStyle) +const decodeBoolean = Schema.decodeUnknownOption(Schema.Boolean) interface MigrateInput { cwd: string @@ -72,16 +76,40 @@ function normalizeTui(data: Record): scroll_speed: number | undefined scroll_acceleration: { enabled: boolean } | undefined diff_style: "auto" | "stacked" | undefined + vim_enter_submit: boolean | undefined + vim_insert_after_submit: boolean | undefined + vim_initial_mode: "normal" | "insert" | undefined + vim_system_clipboard_register: boolean | undefined + vim_modal_input: boolean | undefined + vim_langmap: Record | undefined + vim_line_motions: "logical" | "display_vertical" | "display" | undefined + vim_showbreak: boolean | undefined } | undefined { const parsed = { scroll_speed: Option.getOrUndefined(decodeScrollSpeed(data.scroll_speed)), scroll_acceleration: Option.getOrUndefined(decodeScrollAcceleration(data.scroll_acceleration)), diff_style: Option.getOrUndefined(decodeDiffStyle(data.diff_style)), + vim_enter_submit: Option.getOrUndefined(decodeBoolean(data.vim_enter_submit)), + vim_insert_after_submit: Option.getOrUndefined(decodeBoolean(data.vim_insert_after_submit)), + vim_initial_mode: Option.getOrUndefined(decodeInitialMode(data.vim_initial_mode)), + vim_system_clipboard_register: Option.getOrUndefined(decodeBoolean(data.vim_system_clipboard_register)), + vim_modal_input: Option.getOrUndefined(decodeBoolean(data.vim_modal_input)), + vim_langmap: Option.getOrUndefined(decodeLangmap(data.vim_langmap)), + vim_line_motions: Option.getOrUndefined(decodeLineMotions(data.vim_line_motions)), + vim_showbreak: Option.getOrUndefined(decodeBoolean(data.vim_showbreak)), } return parsed.scroll_speed === undefined && parsed.diff_style === undefined && - parsed.scroll_acceleration === undefined + parsed.scroll_acceleration === undefined && + parsed.vim_enter_submit === undefined && + parsed.vim_insert_after_submit === undefined && + parsed.vim_initial_mode === undefined && + parsed.vim_system_clipboard_register === undefined && + parsed.vim_modal_input === undefined && + parsed.vim_langmap === undefined && + parsed.vim_line_motions === undefined && + parsed.vim_showbreak === undefined ? undefined : parsed } diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 8f503751f379..fbceef9b6b66 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -71,12 +71,12 @@ function normalize(raw: Record) { function dropUnknownKeybinds(input: Record) { if (!isRecord(input.keybinds)) return input - const invalid = TuiKeybind.unknownKeys(input.keybinds) - if (!invalid.length) return input + const next = TuiKeybind.dropUnknown(input.keybinds) + if (JSON.stringify(next) === JSON.stringify(input.keybinds)) return input return { ...input, - keybinds: Object.fromEntries(Object.entries(input.keybinds).filter(([key]) => !invalid.includes(key))), + keybinds: next, } } @@ -91,7 +91,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: return { ...config, plugin: yield* Effect.forEach(plugins, (plugin) => - Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugin as ConfigPlugin.Origin["spec"], configFilepath)), + Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugin as ConfigPlugin.Origin["spec"], configFilepath, "tui")), ), } }) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a36f..202b899141b1 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -25,6 +25,7 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { UpdateCommand } from "./cli/cmd/update" import { DbCommand } from "./cli/cmd/db" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" @@ -34,7 +35,7 @@ const args = hideBin(process.argv) function show(out: string) { const text = out.trimStart() - if (!text.startsWith("opencode ")) { + if (!text.startsWith("ocv ")) { process.stderr.write(UI.logo() + EOL + EOL) process.stderr.write(text + EOL) return @@ -44,7 +45,7 @@ function show(out: string) { const cli = yargs(args) .parserConfiguration({ "populate--": true }) - .scriptName("opencode") + .scriptName("ocv") .wrap(100) .help("help", "show help") .alias("help", "h") @@ -101,6 +102,7 @@ const cli = yargs(args) .command(SessionCommand) .command(PluginCommand) .command(DbCommand) + .command(UpdateCommand) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 4300220255b3..24406e431d6d 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -15,6 +15,8 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst import { NpmConfig } from "@opencode-ai/core/npm-config" import { InstallationEvent } from "@opencode-ai/schema/installation-event" +const npm = "@leohenon/ocv" + export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export type ReleaseType = "patch" | "minor" | "major" @@ -38,18 +40,21 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "InstallationInfo" }) export type Info = Schema.Schema.Type +export const VERSION = InstallationVersion +export const CHANNEL = InstallationChannel + export function userAgent(client = "cli") { - return `opencode/${InstallationChannel}/${InstallationVersion}/${client}` + return `opencode/${CHANNEL}/${VERSION}/${client}` } export const USER_AGENT = userAgent() export function isPreview() { - return InstallationChannel !== "latest" + return CHANNEL !== "latest" } export function isLocal() { - return InstallationChannel === "local" + return CHANNEL === "local" } export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { @@ -123,11 +128,11 @@ const layer: Layer.Layer { @@ -144,14 +149,16 @@ const layer: Layer.Layer text(["yarn", "global", "list"]) }, { name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) }, { name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) }, - { name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) }, + { name: "brew", command: () => text(["brew", "list", "--formula", "ocv"]) }, { name: "scoop", command: () => text(["scoop", "list", "opencode"]) }, { name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) }, ] @@ -197,7 +205,7 @@ const layer: Layer.Layer ({ + spec, + scope: "global" as const, + source: "builtin", +})) + type State = { hooks: Hooks[] } @@ -64,6 +72,15 @@ export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel } // Built-in plugins that are directly imported (not installed from npm) +export function plugins( + list: ConfigPlugin.Origin[] | undefined, + flags: Pick, +) { + if (flags.pure) return [] + if (flags.disableDefaultPlugins) return list ?? [] + return ConfigPlugin.deduplicatePluginOrigins([...BUILTIN_ORIGINS, ...(list ?? [])]) +} + function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] { return [ // Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in. @@ -178,14 +195,12 @@ const layer = Layer.effect( if (init._tag === "Some") hooks.push(init.value) } - const plugins = flags.pure ? [] : (cfg.plugin_origins ?? []) - if (flags.pure && cfg.plugin_origins?.length) { - } - if (plugins.length) yield* config.waitForDependencies() + const items = plugins(cfg.plugin_origins, flags) + if (items.length) yield* config.waitForDependencies() const loaded = yield* Effect.promise(() => PluginLoader.loadExternal({ - items: plugins, + items, kind: "server", report: { start(candidate) {}, diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index 94876b4d0cb8..99c7b46c0f62 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -94,7 +94,7 @@ export namespace PluginLoader { // First make sure the plugin exists locally, installing npm plugins on demand. let target = "" try { - target = await resolvePluginTarget(plan.spec) + target = await resolvePluginTarget(plan.spec, kind) } catch (error) { return { ok: false, stage: "install", error } } diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 6ead52c81328..d962884d50ae 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -12,7 +12,18 @@ const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 -const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) +const ALLOWED_MODELS = new Set([ + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.5", +]) const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"]) interface PkceCodes { @@ -292,11 +303,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug return Object.fromEntries( Object.entries(provider.models) - .filter(([, model]) => { + .filter(([modelID, model]) => { if (model.options.reasoningMode === "pro") return false if (ALLOWED_MODELS.has(model.api.id)) return true if (DISALLOWED_MODELS.has(model.api.id)) return false if (model.api.id === "gpt-5.6") return false + if (modelID.includes("codex")) return true const match = model.api.id.match(/^gpt-(\d+)(?:\.(\d+))?/) if (!match) return false const major = Number(match[1]) diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 1a519359bde6..cd9d8331ba4e 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -172,7 +172,7 @@ export function isPathPluginSpec(spec: string) { return spec.startsWith("file://") || spec.startsWith(".") || isAbsolutePath(spec) } -export async function resolvePathPluginTarget(spec: string) { +export async function resolvePathPluginTarget(spec: string, kind: PluginKind = "server") { const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw) const stat = await Filesystem.statAsync(file) @@ -182,7 +182,9 @@ export async function resolvePathPluginTarget(spec: string) { } if (await Filesystem.exists(path.join(file, "package.json"))) { - return pathToFileURL(file).href + if (kind === "tui") return pathToFileURL(file).href + const pkg = await readPluginPackage(file) + return resolvePackageEntrypoint(spec, "server", pkg) ?? pathToFileURL(file).href } const index = await resolveDirectoryIndex(file) @@ -204,8 +206,8 @@ export async function checkPluginCompatibility(target: string, opencodeVersion: } } -export async function resolvePluginTarget(spec: string) { - if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec) +export async function resolvePluginTarget(spec: string, kind: PluginKind = "server") { + if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec, kind) const hit = parse(spec) const pkg = hit?.name && hit.raw === hit.name ? `${hit.name}@latest` : spec const result = await Npm.add(pkg) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8d5baede50fd..19d846a0496a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1910,7 +1910,7 @@ describe("resolvePluginSpec", () => { const file = path.join(tmp.path, "opencode.json") const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file) - expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href) + expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) }) test("resolves plugin directories without package.json to index.ts", async () => { diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index cc43cf4e55de..2e5bdc1cf35b 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -3,7 +3,7 @@ import path from "path" import { pathToFileURL } from "url" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Layer } from "effect" +import { Effect, Layer, Option, Schema } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Config } from "@/config/config" @@ -131,12 +131,27 @@ it.instance("loads tui config with the same precedence order as server config pa yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project" }) yield* fs.writeWithDirs( path.join(test.directory, ".opencode", "tui.json"), - JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2), + JSON.stringify( + { + theme: "local", + diff_style: "stacked", + vim_enter_submit: true, + vim_insert_after_submit: true, + vim_initial_mode: "normal", + vim_modal_input: false, + }, + null, + 2, + ), ) const config = yield* getTuiConfig(test.directory) expect(config.theme).toBe("local") expect(config.diff_style).toBe("stacked") + expect(config.vim_enter_submit).toBe(true) + expect(config.vim_insert_after_submit).toBe(true) + expect(config.vim_initial_mode).toBe("normal") + expect(config.vim_modal_input).toBe(false) }), ), ) @@ -147,7 +162,8 @@ it.instance("resolves attention config defaults and overrides", () => const fs = yield* FSUtil.Service const test = yield* TestInstance - expect((yield* getTuiConfig(test.directory)).attention).toEqual({ + const defaults = yield* getTuiConfig(test.directory) + expect(defaults.attention).toEqual({ enabled: false, notifications: true, sound: true, @@ -155,6 +171,7 @@ it.instance("resolves attention config defaults and overrides", () => sound_pack: "opencode.default", sounds: {}, }) + expect(defaults.vim_modal_input).toBe(true) yield* fs.writeJson(path.join(test.directory, "tui.json"), { attention: { @@ -197,17 +214,40 @@ it.instance("migrates tui-specific keys from opencode.json when tui.json does no const source = path.join(test.directory, "opencode.json") yield* fs.writeJson(source, { theme: "migrated-theme", - tui: { scroll_speed: 5 }, + tui: { + scroll_speed: 5, + vim_enter_submit: true, + vim_insert_after_submit: true, + vim_initial_mode: "insert", + vim_modal_input: false, + vim_langmap: { д: "l" }, + vim_line_motions: "display", + vim_showbreak: true, + }, keybinds: { app_exit: "ctrl+q" }, }) const config = yield* getTuiConfig(test.directory) expect(config.theme).toBe("migrated-theme") expect(config.scroll_speed).toBe(5) + expect(config.vim_enter_submit).toBe(true) + expect(config.vim_insert_after_submit).toBe(true) + expect(config.vim_initial_mode).toBe("insert") + expect(config.vim_modal_input).toBe(false) + expect(config.vim_langmap).toEqual({ д: "l" }) + expect(config.vim_line_motions).toBe("display") + expect(config.vim_showbreak).toBe(true) expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") expect(JSON.parse(yield* fs.readFileString(path.join(test.directory, "tui.json")))).toMatchObject({ theme: "migrated-theme", scroll_speed: 5, + vim_enter_submit: true, + vim_insert_after_submit: true, + vim_initial_mode: "insert", + vim_modal_input: false, + vim_langmap: { д: "l" }, + vim_line_motions: "display", + vim_showbreak: true, }) const server = JSON.parse(yield* fs.readFileString(source)) expect(server.theme).toBeUndefined() @@ -219,6 +259,37 @@ it.instance("migrates tui-specific keys from opencode.json when tui.json does no ), ) +it.instance("validates vim langmap entries are single characters", () => + Effect.sync(() => { + const decode = Schema.decodeUnknownOption(TuiConfig.Info) + + expect(Option.isSome(decode({ vim_langmap: { д: "l" } }))).toBe(true) + expect(Option.isNone(decode({ vim_langmap: { дд: "l" } }))).toBe(true) + expect(Option.isNone(decode({ vim_langmap: { д: "ll" } }))).toBe(true) + }), +) + +it.instance("validates vim initial modes", () => + Effect.sync(() => { + const decode = Schema.decodeUnknownOption(TuiConfig.Info) + + expect(Option.isSome(decode({ vim_initial_mode: "normal" }))).toBe(true) + expect(Option.isSome(decode({ vim_initial_mode: "insert" }))).toBe(true) + expect(Option.isNone(decode({ vim_initial_mode: "visual" }))).toBe(true) + }), +) + +it.instance("validates vim line motion modes", () => + Effect.sync(() => { + const decode = Schema.decodeUnknownOption(TuiConfig.Info) + + expect(Option.isSome(decode({ vim_line_motions: "logical" }))).toBe(true) + expect(Option.isSome(decode({ vim_line_motions: "display_vertical" }))).toBe(true) + expect(Option.isSome(decode({ vim_line_motions: "display" }))).toBe(true) + expect(Option.isNone(decode({ vim_line_motions: "wrapped" }))).toBe(true) + }), +) + it.instance("migrates project legacy tui keys even when global tui.json already exists", () => withCleanState( Effect.gen(function* () { @@ -385,12 +456,14 @@ it.instance("flattens nested tui key inside tui.json", () => const test = yield* TestInstance yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "outer", - tui: { scroll_speed: 3, diff_style: "stacked" }, + tui: { scroll_speed: 3, diff_style: "stacked", vim_enter_submit: true, vim_insert_after_submit: true }, }) const config = yield* getTuiConfig(test.directory) expect(config.scroll_speed).toBe(3) expect(config.diff_style).toBe("stacked") + expect(config.vim_enter_submit).toBe(true) + expect(config.vim_insert_after_submit).toBe(true) expect(config.theme).toBe("outer") }), ), @@ -403,12 +476,14 @@ it.instance("top-level keys in tui.json take precedence over nested tui key", () const test = yield* TestInstance yield* fs.writeJson(path.join(test.directory, "tui.json"), { diff_style: "auto", - tui: { diff_style: "stacked", scroll_speed: 2 }, + vim_insert_after_submit: false, + tui: { diff_style: "stacked", scroll_speed: 2, vim_insert_after_submit: true }, }) const config = yield* getTuiConfig(test.directory) expect(config.diff_style).toBe("auto") expect(config.scroll_speed).toBe(2) + expect(config.vim_insert_after_submit).toBe(false) }), ), ) @@ -681,6 +756,26 @@ it.instance("does not derive tui path from OPENCODE_CONFIG", () => ), ) +it.instance("defaults copy mode to leader v", () => + withCleanState( + Effect.gen(function* () { + const test = yield* TestInstance + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("session.copy_mode")?.[0]?.key).toBe("v") + }), + ), +) + +it.instance("defaults prompt copy selection to leader y", () => + withCleanState( + Effect.gen(function* () { + const test = yield* TestInstance + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("prompt.copy_selection")?.[0]?.key).toBe("y") + }), + ), +) + it.instance("applies env and file substitutions in tui.json", () => withCleanState( withEnv( diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index 3d4b4cba31a6..09f93992ad2e 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -96,7 +96,7 @@ describe("installation", () => { Effect.gen(function* () { const result = yield* Installation.use.latest("npm") expect(result).toBe("1.5.0") - expect(npmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + expect(npmCalls).toContain(`https://registry.npmjs.org/${encodeURIComponent("@leohenon/ocv")}/${InstallationChannel}`) }), ) @@ -110,7 +110,7 @@ describe("installation", () => { Effect.gen(function* () { const result = yield* Installation.use.latest("bun") expect(result).toBe("1.6.0") - expect(bunCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + expect(bunCalls).toContain(`https://registry.npmjs.org/${encodeURIComponent("@leohenon/ocv")}/${InstallationChannel}`) }), ) @@ -124,7 +124,7 @@ describe("installation", () => { Effect.gen(function* () { const result = yield* Installation.use.latest("pnpm") expect(result).toBe("1.7.0") - expect(pnpmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + expect(pnpmCalls).toContain(`https://registry.npmjs.org/${encodeURIComponent("@leohenon/ocv")}/${InstallationChannel}`) }), ) @@ -149,8 +149,8 @@ describe("installation", () => { () => jsonResponse({ versions: { stable: "2.0.0" } }), (cmd, args) => { // getBrewFormula: return core formula (no tap) - if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return "" - if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode" + if (cmd === "brew" && args.includes("--formula") && args.includes("leohenon/tap/ocv")) return "" + if (cmd === "brew" && args.includes("--formula") && args.includes("ocv")) return "ocv" return "" }, ), @@ -168,7 +168,7 @@ describe("installation", () => { testLayer( () => jsonResponse({}), // HTTP not used for tap formula (cmd, args) => { - if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode" + if (cmd === "brew" && args.includes("leohenon/tap/ocv") && args.includes("--formula")) return "ocv" if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson return "" }, diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 159f7806a75e..d04d2deb6e7a 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -308,6 +308,27 @@ describe("plugin.codex", () => { cost: {}, options: { reasoningEffort: "high" }, }, + "legacy-codex": { + id: "legacy-codex", + api: { id: "legacy" }, + limit, + cost: {}, + options: {}, + }, + "gpt-5.4-codex-pro": { + id: "gpt-5.4-codex-pro", + api: { id: "gpt-5.4" }, + limit, + cost: {}, + options: { reasoningMode: "pro" }, + }, + "gpt-5.6-codex": { + id: "gpt-5.6-codex", + api: { id: "gpt-5.6" }, + limit, + cost: {}, + options: {}, + }, }, } @@ -321,6 +342,9 @@ describe("plugin.codex", () => { expect(models["gpt-5.4-pro"]).toBeUndefined() expect(models["gpt-5.7-pro"]).toBeDefined() expect(models["gpt-5.6-sol-high"]).toBeDefined() + expect(models["legacy-codex"]).toBeDefined() + expect(models["gpt-5.4-codex-pro"]).toBeUndefined() + expect(models["gpt-5.6-codex"]).toBeUndefined() expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe( provider.models as never, ) diff --git a/packages/opencode/test/plugin/index.test.ts b/packages/opencode/test/plugin/index.test.ts new file mode 100644 index 000000000000..7162a8658e5e --- /dev/null +++ b/packages/opencode/test/plugin/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import path from "path" + +const file = path.join(import.meta.dir, "../../src/plugin/index.ts") + +describe("plugin builtin auth loading", () => { + test("includes builtin anthropic auth plugin in runtime plugin list", async () => { + const src = await Bun.file(file).text() + + expect(src).toContain('const BUILTIN = ["op-anthropic-auth"]') + expect(src).toContain('flags: Pick') + expect(src).toContain("const BUILTIN_ORIGINS = BUILTIN.map") + expect(src).toContain("return ConfigPlugin.deduplicatePluginOrigins([...BUILTIN_ORIGINS, ...(list ?? [])])") + expect(src).toContain("const items = plugins(cfg.plugin_origins, flags)") + }) +}) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 6852311f9e84..9a58b47cc5a0 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -66,6 +66,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config" +import { useVimEnabled } from "./component/vim" import { createTuiApiAdapters } from "./plugin/adapters" import { createTuiApi } from "./plugin/api" import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime" @@ -132,6 +133,8 @@ const appBindingCommands = [ "app.heap_snapshot", "terminal.suspend", "terminal.title.toggle", + "input.vim.toggle", + "ui.minimal.toggle", "app.toggle.animations", "app.toggle.file_context", "app.toggle.diffwrap", @@ -420,6 +423,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi .finally(() => { setReady(true) }) + const vim = useVimEnabled() // Let selection copy/dismiss win ahead of normal bindings when explicit copy is required. const offSelectionKeys = keymap.intercept( @@ -826,11 +830,18 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "System", }, + { + name: "vim.q", + title: "Quit", + slashName: "q", + run: () => exit(), + category: "System", + }, { name: "app.exit", title: "Exit the app", slashName: "exit", - slashAliases: ["quit", "q"], + slashAliases: ["quit"], run: () => exit(), category: "System", }, @@ -892,6 +903,24 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dialog.clear() }, }, + { + name: "input.vim.toggle", + title: "Toggle vim mode", + category: "System", + run: () => { + kv.set("input_vim_mode", !vim()) + dialog.clear() + }, + }, + { + name: "ui.minimal.toggle", + title: "Toggle minimal ui", + category: "System", + run: () => { + kv.set("ui_minimal", !kv.get("ui_minimal", false)) + dialog.clear() + }, + }, { name: "app.toggle.animations", title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 38db569aaa24..9012278e4efd 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -6,6 +6,7 @@ import { useDialog } from "../ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" +import { consoleManagedProviderLabel } from "../util/provider-origin" import { useConnected } from "./use-connected" import { useSync } from "../context/sync" @@ -38,7 +39,11 @@ export function DialogModel(props: { providerID?: string }) { key: item, value: { providerID: provider.id, modelID: model.id }, title: model.name ?? item.modelID, - description: provider.name, + description: consoleManagedProviderLabel( + sync.data.console_state.consoleManagedProviders, + provider.id, + provider.name, + ), category, disabled: provider.id === "opencode" && model.id.includes("-nano"), footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, @@ -77,7 +82,9 @@ export function DialogModel(props: { providerID?: string }) { description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) ? "(Favorite)" : undefined, - category: connected() ? provider.name : undefined, + category: connected() + ? consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, provider.id, provider.name) + : undefined, disabled: provider.id === "opencode" && model.includes("-nano"), footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, onSelect() { @@ -136,7 +143,7 @@ export function DialogModel(props: { providerID?: string }) { const title = createMemo(() => { const value = provider() if (!value) return "Select model" - return value.name + return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, value.id, value.name) }) function onSelect(providerID: string, modelID: string) { diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 6b86a32e3482..a73fa5e06ee7 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -11,7 +11,7 @@ import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2" import { DialogModel } from "./dialog-model" import { useToast } from "../ui/toast" -import { isConsoleManagedProvider } from "../util/provider-origin" +import { CONSOLE_MANAGED_ICON, isConsoleManagedProvider } from "../util/provider-origin" import { useConnected } from "./use-connected" import { useBindings } from "../keymap" import { useClipboard } from "../context/clipboard" @@ -141,7 +141,11 @@ export function createDialogProviderOptions() { description: provider.description, footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, category: provider.category, - gutter: connected && onboarded() ? () => : undefined, + gutter: consoleManaged + ? () => {CONSOLE_MANAGED_ICON} + : connected && onboarded() + ? () => + : undefined, async onSelect() { if (consoleManaged) return diff --git a/packages/tui/src/component/prompt/empty-selection.ts b/packages/tui/src/component/prompt/empty-selection.ts new file mode 100644 index 000000000000..1721a7ebb1ad --- /dev/null +++ b/packages/tui/src/component/prompt/empty-selection.ts @@ -0,0 +1,20 @@ +export function emptyRows( + text: string, + sel: { start: number; end: number } | null, + info: { lineSources: number[]; lineWidthCols: number[] }, + scroll: number, + height: number, +) { + if (!sel) return [] + const lines = [0] + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") lines.push(i + 1) + } + const end = Math.min(scroll + height, info.lineSources.length) + return info.lineSources.slice(scroll, end).flatMap((line, row) => { + if (info.lineWidthCols[scroll + row] !== 0) return [] + const start = lines[line] + if (start === undefined) return [] + return sel.start <= start && start < sel.end ? [row] : [] + }) +} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index c48c751739ce..b2a74a5bb796 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -4,18 +4,20 @@ import { TextareaRenderable, MouseEvent, PasteEvent, + TextAttributes, decodePasteBytes, type KeyEvent, type Renderable, } from "@opentui/core" import type { CommandContext } from "@opentui/keymap" + import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import { registerOpencodeSpinner } from "../register-spinner" import path from "path" import { fileURLToPath } from "url" import { useLocal } from "../../context/local" import { Flag } from "@opencode-ai/core/flag/flag" -import { tint, useTheme } from "../../context/theme" +import { selectedForeground, tint, useTheme } from "../../context/theme" import { EmptyBorder, SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { useClipboard } from "../../context/clipboard" @@ -51,7 +53,24 @@ import { createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "../../context/args" -import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" +import { useVimEnabled } from "../vim" +import type { CopyModeAdapter } from "../vim/copy-adapter" +import { clearSelection } from "../vim/vim-motions" +import { vimCursorStyle } from "../vim/cursor-style" +import { usePromptVim } from "./vim" +import { emptyRows } from "./empty-selection" +import { drawShowbreak } from "./showbreak" +import { CONSOLE_MANAGED_ICON, consoleManagedProviderLabel } from "../../util/provider-origin" +import { + OPENCODE_BASE_MODE, + OPENCODE_COPY_MODE_ENTER_KEYS, + OPENCODE_COPY_MODE_EXIT_KEYS, + OPENCODE_COPY_MODE_TOGGLE_KEYS, + useBindings, + useCommandShortcut, + useLeaderActive, + useOpencodeKeymap, +} from "../../keymap" import { useTuiConfig } from "../../config" import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" @@ -62,8 +81,10 @@ registerOpencodeSpinner() export type PromptProps = { sessionID?: string + workspaceID?: string visible?: boolean disabled?: boolean + copyDuringModal?: boolean onSubmit?: () => void ref?: (ref: PromptRef | undefined) => void hint?: JSX.Element @@ -73,6 +94,7 @@ export type PromptProps = { normal?: string[] shell?: string[] } + copy?: CopyModeAdapter } function pastedFilepath(value: string, platform: string) { @@ -96,6 +118,8 @@ export type PromptRef = { submit(): void } +const PROMPT_RENDER_PATCH = Symbol("prompt-render-patch") +type PatchedPromptTextarea = TextareaRenderable & { [PROMPT_RENDER_PATCH]?: true } const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", @@ -145,7 +169,9 @@ export function Prompt(props: PromptProps) { let anchor: BoxRenderable const [inputTarget, setInputTarget] = createSignal() + const leader = useLeaderActive() + const local = useLocal() const args = useArgs() const paths = useTuiPaths() @@ -158,6 +184,7 @@ export function Prompt(props: PromptProps) { const project = useProject() const sync = useSync() const tuiConfig = useTuiConfig() + const cfg = tuiConfig const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) @@ -166,11 +193,14 @@ export function Prompt(props: PromptProps) { const keymap = useOpencodeKeymap() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") + const variantShortcut = useCommandShortcut("variant.cycle") const renderer = useRenderer() const exit = useExit() const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() const kv = useKV() + const vimEnabled = useVimEnabled() + const mini = createMemo(() => kv.get("ui_minimal", false)) const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) @@ -206,12 +236,74 @@ export function Prompt(props: PromptProps) { return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) }) const editorContextLabelState = createMemo(() => editor.labelState()) + const [editorContextHover, setEditorContextHover] = createSignal(false) const [auto, setAuto] = createSignal() + const maxHeight = createMemo(() => + cfg?.prompt?.max_height ?? cfg?.prompt_max_height ?? Math.max(6, Math.floor(dimensions().height / 3)), + ) + const showScrollbar = createMemo(() => cfg?.prompt_scrollbar !== false) + const activeOrgName = createMemo(() => sync.data.console_state.activeOrgName) + const canSwitchOrgs = createMemo(() => sync.data.console_state.switchableOrgCount > 1) + const currentProviderLabel = createMemo(() => { + const current = local.model.current() + const provider = local.model.parsed().provider + if (!current) return provider + return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, current.providerID, provider) + }) + const hasRightContent = createMemo(() => Boolean(props.right || activeOrgName())) const workspace = usePromptWorkspace(props.sessionID) const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) const [cursorVersion, setCursorVersion] = createSignal(0) - const currentProviderLabel = createMemo(() => local.model.parsed().provider) - const hasRightContent = createMemo(() => Boolean(props.right)) + + const [scrollbar, setScrollbar] = createSignal(null) + function syncScrollbar() { + setTimeout(() => { + if (!input || input.isDestroyed) return + if (!showScrollbar()) { + if (scrollbar() !== null) { + setScrollbar(null) + renderer.requestRender() + } + return + } + const total = input.editorView.getTotalVirtualLineCount() + const h = input.height + if (total <= h) { + if (scrollbar() !== null) { + setScrollbar(null) + renderer.requestRender() + } + return + } + const range = total - h + const virtual = h * 2 + const size = Math.max(1, Math.floor(virtual * (h / total))) + const ratio = range > 0 ? input.scrollY / range : 0 + const start = Math.round(ratio * (virtual - size)) + const end = start + size + const chars: string[] = [] + for (let row = 0; row < h; row++) { + const cs = row * 2 + const ce = cs + 2 + const ts = Math.max(start, cs) + const te = Math.min(end, ce) + const cov = te - ts + if (cov >= 2) chars.push("█") + else if (cov > 0) chars.push(ts - cs === 0 ? "▀" : "▄") + else chars.push(" ") + } + setScrollbar(chars) + renderer.requestRender() + }, 0) + } + + createEffect(() => { + if (showScrollbar()) { + syncScrollbar() + return + } + if (scrollbar() !== null) setScrollbar(null) + }) function promptModelWarning() { toast.show({ @@ -236,7 +328,7 @@ export function Prompt(props: PromptProps) { event.on("tui.prompt.append", (evt, { workspace }) => { if (workspace !== project.workspace.current()) return - if (!input || input.isDestroyed) return + if (!input || input.isDestroyed || vimState.isCopy()) return input.insertText(evt.properties.text) setTimeout(() => { // setTimeout is a workaround and needs to be addressed properly @@ -247,11 +339,39 @@ export function Prompt(props: PromptProps) { }, 0) }) + function cursorStyle() { + if (!vimEnabled() || store.mode !== "normal") return vimCursorStyle(undefined, tuiConfig.cursor) + if (vimState.isInsert()) return vimCursorStyle("insert", tuiConfig.cursor) + if (vimState.isReplace()) return vimCursorStyle("replace", tuiConfig.cursor) + return vimCursorStyle("normal", tuiConfig.cursor) + } + createEffect(() => { if (!input || input.isDestroyed) return - if (props.disabled) input.cursorColor = theme.backgroundElement - if (!props.disabled) input.cursorColor = theme.text - if (tuiConfig.cursor) input.cursorStyle = tuiConfig.cursor + if (props.disabled || vimState.isCopy()) { + input.cursorColor = theme.backgroundElement + input.showCursor = false + return + } + const visual = vimState.isVisual() + const block = vimEnabled() && store.mode === "normal" && vimState.mode() === "normal" + input.cursorColor = theme.text + input.showCursor = !(visual || block) + input.selectionBg = visual ? theme.secondary : undefined + input.selectionFg = visual ? selectedForeground(theme, theme.secondary) : undefined + }) + + createEffect((prev: boolean | undefined) => { + const active = props.copy?.active() ?? false + if (prev === true && !active && vimState.isCopy()) { + vimState.setMode("normal") + } + return active + }) + + createEffect(() => { + if (!input || input.isDestroyed) return + input.cursorStyle = cursorStyle() }) const lastUserMessage = createMemo(() => { @@ -297,6 +417,58 @@ export function Prompt(props: PromptProps) { extmarkToPartIndex: new Map(), interrupt: 0, }) + const promptVim = usePromptVim({ + textarea: () => input, + inputTarget, + mode: () => store.mode, + copy: () => props.copy, + disabled: () => props.disabled, + copyDuringModal: () => props.copyDuringModal, + sessionID: () => props.sessionID, + submit, + autocomplete: () => auto()?.visible ?? false, + snapshot: promptSnapshot, + snapshotDataEqual: promptPartDataEqual, + restore(next) { + input.setText(next.text) + input.cursorOffset = Math.max(0, Math.min(next.cursor, next.text.length)) + const parts = Array.isArray(next.data) ? (next.data as PromptInfo["parts"]) : [] + setStore("prompt", { + input: next.text, + parts, + }) + restoreExtmarksFromParts(parts) + }, + }) + const { + vim, + vimState, + vimIndicator, + enterCopyMode, + exitCopyMode, + copyEligible, + promptSelectionText, + copyPromptSelection, + shouldSyncVimRegister, + syncVimRegisterFromClipboard, + } = promptVim + + function resetVimHistory() { + vimState.resetHistory() + if (vimEnabled() && store.mode === "normal" && vimState.isInsert()) vim.beginInsertEdit() + } + + createEffect((previous) => { + const enabled = vimEnabled() + if (enabled && previous === false) resetVimHistory() + return enabled + }, vimEnabled()) + + createEffect<"normal" | "shell">((previous) => { + const mode = store.mode + if (mode !== previous) resetVimHistory() + return mode + }, store.mode) createEffect( on( @@ -339,14 +511,18 @@ export function Prompt(props: PromptProps) { name: "prompt.clear", category: "Prompt", hidden: true, + run: () => { clearPrompt() + dialog.clear() }, }, { title: "Submit prompt", + name: "prompt.submit", + category: "Prompt", hidden: true, run: async () => { @@ -357,6 +533,16 @@ export function Prompt(props: PromptProps) { dialog.clear() }, }, + { + title: "Copy prompt selection", + name: "prompt.copy_selection", + category: "Prompt", + enabled: () => !!promptSelectionText(), + run: async () => { + if (!(await copyPromptSelection())) return + dialog.clear() + }, + }, { title: "Remove editor context", name: "prompt.editor_context.clear", @@ -398,9 +584,24 @@ export function Prompt(props: PromptProps) { run: () => { if (auto()?.visible) return if (!input.focused) return + if (vimState.isCopy()) { + vimState.setMode("normal") + props.copy?.exit() + dialog.clear() + return + } + if (vimEnabled() && store.mode === "normal" && vimState.mode() !== "normal") { + if (vimState.isVisual()) clearSelection(input) + vimState.cancelEdit() + vimState.setMode("normal") + setStore("interrupt", 0) + dialog.clear() + return + } // TODO: this should be its own command if (store.mode === "shell") { setStore("mode", "normal") + vimState.clearPending() return } if (!props.sessionID) return @@ -510,6 +711,21 @@ export function Prompt(props: PromptProps) { }) restoreExtmarksFromParts(updatedNonTextParts) input.cursorOffset = Bun.stringWidth(normalized) + resetVimHistory() + }, + }, + { + title: "Copy mode", + name: "session.copy_mode", + category: "Session", + hidden: true, + run: () => { + if (!vimEnabled() || !props.copy) return + if (vimState.isCopy()) { + exitCopyMode() + return + } + enterCopyMode() }, }, { @@ -527,6 +743,7 @@ export function Prompt(props: PromptProps) { parts: [], }) input.gotoBufferEnd() + resetVimHistory() }} /> )) @@ -568,17 +785,137 @@ export function Prompt(props: PromptProps) { bindings: tuiConfig.keybinds.gather("prompt.palette", [ "prompt.submit", "prompt.editor", + "prompt.copy_selection", "prompt.editor_context.clear", "prompt.stash", "prompt.stash.pop", "prompt.stash.list", "prompt.skills", "session.interrupt", + "session.copy_mode", "workspace.set", "session.move", ]), })) + useBindings(() => ({ + target: inputTarget, + priority: 100, + enabled: + inputTarget() !== undefined && + !props.disabled && + vimEnabled() && + store.mode === "normal" && + vimState.mode() === "normal", + bindings: [ + { + key: "ctrl+r", + desc: "Redo", + group: "Prompt", + cmd: (ctx: CommandContext) => vim.handleKey(ctx.event), + }, + ], + })) + + useBindings(() => ({ + target: inputTarget, + priority: 100, + enabled: copyEligible() && vimState.isCopy(), + bindings: ["ctrl+d", "ctrl+u", "ctrl+f", "ctrl+b", "ctrl+e", "ctrl+y"].map((key) => ({ + key, + desc: "Scroll copy mode", + group: "Copy mode", + cmd: (ctx: CommandContext) => vim.handleKey(ctx.event), + })), + })) + + useBindings(() => ({ + target: inputTarget, + priority: 200, + enabled: copyEligible() && vimState.isCopy() && !!props.copy?.searchActive(), + bindings: [ + ...["backspace", "shift+backspace", "delete", "ctrl+h"].map((key) => ({ + key, + desc: "Delete search character", + group: "Copy mode", + cmd: () => { + props.copy?.searchBackspace() + return true + }, + })), + { + key: "return", + desc: "Submit search", + group: "Copy mode", + cmd: () => { + if (props.copy?.searchSubmit() === false) { + toast.show({ message: "Pattern not found", variant: "warning" }) + return true + } + const count = props.copy?.searchMatchCount() ?? 0 + if (count > 0) toast.show({ message: `${count} ${count === 1 ? "match" : "matches"}`, variant: "info" }) + return true + }, + }, + ], + })) + + useBindings(() => ({ + target: inputTarget, + priority: 200, + enabled: copyEligible() && vimState.isCopy() && !props.copy?.isVisual() && !!props.copy?.searchHighlighted(), + bindings: [ + { + key: "escape", + desc: "Clear search highlights", + group: "Copy mode", + cmd: () => { + if (props.copy?.searchActive()) props.copy.searchCancel() + else props.copy?.searchClear() + return true + }, + }, + ], + })) + + useBindings(() => ({ + target: inputTarget, + priority: 100, + enabled: copyEligible() && !vimState.isInsert() && !vimState.isReplace() && !!props.copy, + bindings: [ + { + key: OPENCODE_COPY_MODE_ENTER_KEYS, + desc: "Enter copy mode", + group: "Session", + cmd: () => { + if (vimState.isCopy()) return false + keymap.dispatchCommand("session.copy_mode") + }, + }, + { + key: OPENCODE_COPY_MODE_TOGGLE_KEYS, + desc: "Toggle copy mode", + group: "Session", + cmd: () => { + if (vimState.isCopy()) { + exitCopyMode(false) + return + } + keymap.dispatchCommand("session.copy_mode") + }, + }, + { + key: OPENCODE_COPY_MODE_EXIT_KEYS, + desc: "Exit copy mode", + group: "Session", + cmd: () => { + if (!vimState.isCopy()) return false + exitCopyMode(false) + }, + }, + ], + })) + const ref: PromptRef = { get focused() { return input.focused @@ -597,6 +934,7 @@ export function Prompt(props: PromptProps) { setStore("prompt", prompt) restoreExtmarksFromParts(prompt.parts) input.gotoBufferEnd() + resetVimHistory() }, reset() { input.clear() @@ -606,6 +944,7 @@ export function Prompt(props: PromptProps) { parts: [], }) setStore("extmarkToPartIndex", new Map()) + resetVimHistory() }, submit() { void submit() @@ -615,13 +954,13 @@ export function Prompt(props: PromptProps) { onMount(() => { const saved = stashed stashed = undefined - if (store.prompt.input) return - if (saved && saved.prompt.input) { + if (!store.prompt.input && saved?.prompt.input) { input.setText(saved.prompt.input) setStore("prompt", saved.prompt) restoreExtmarksFromParts(saved.prompt.parts) input.cursorOffset = saved.cursor } + if (vimEnabled() && vimState.isInsert()) vim.beginInsertEdit() }) onCleanup(() => { @@ -634,8 +973,9 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - if (props.visible === false || dialog.stack.length > 0) { + if (props.visible === false || dialog.stack.length > 0 || (props.disabled && !vimState.isCopy())) { if (input.focused) input.blur() + vimState.clearPending() return } @@ -655,6 +995,60 @@ export function Prompt(props: PromptProps) { } }) + const copyModePrompt = { owns: false, input: "", parts: [] as PromptInfo["parts"] } + const copyModeSuspend = { owns: false, previous: false } + const restoreCopyModeSuspend = () => { + if (!copyModeSuspend.owns || !input || input.isDestroyed) return + const traits = { ...input.traits } + if (copyModeSuspend.previous) traits.suspend = true + else delete traits.suspend + input.traits = traits + copyModeSuspend.owns = false + } + + createEffect(() => { + if (!input || input.isDestroyed) return + if (!vimState.isCopy()) { + copyModePrompt.owns = false + restoreCopyModeSuspend() + return + } + if (!copyModePrompt.owns) { + copyModePrompt.input = store.prompt.input + copyModePrompt.parts = store.prompt.parts + copyModePrompt.owns = true + } + if (store.prompt.input !== copyModePrompt.input || store.prompt.parts !== copyModePrompt.parts) { + setStore("prompt", { input: copyModePrompt.input, parts: copyModePrompt.parts }) + } + if (input.plainText !== copyModePrompt.input) input.setText(copyModePrompt.input) + if (!copyModeSuspend.owns) { + copyModeSuspend.previous = input.traits.suspend === true + copyModeSuspend.owns = true + } + if (input.traits.suspend !== true) input.traits = { ...input.traits, suspend: true } + }) + onCleanup(restoreCopyModeSuspend) + + function submitFromTextarea() { + if (vimState.isCopy()) { + if (!props.copy?.isVisual() && (props.copy?.toggleCollapsed() || props.copy?.activate())) return + props.copy?.copy() + props.copy?.exitPreserveScroll() + vimState.setMode("normal") + return + } + if (store.mode !== "normal") { + submit() + return + } + if (vimEnabled() && (vimState.isInsert() || vimState.isReplace()) && !cfg.vim_enter_submit) { + input.insertText("\n") + return + } + submit() + } + function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { input.extmarks.clear() setStore("extmarkToPartIndex", new Map()) @@ -733,6 +1127,7 @@ export function Prompt(props: PromptProps) { ) } + const stashCommands = createMemo(() => [ { @@ -750,6 +1145,7 @@ export function Prompt(props: PromptProps) { input.clear() setStore("prompt", { input: "", parts: [] }) setStore("extmarkToPartIndex", new Map()) + resetVimHistory() dialog.clear() }, }, @@ -765,6 +1161,7 @@ export function Prompt(props: PromptProps) { setStore("prompt", { input: entry.input, parts: entry.parts }) restoreExtmarksFromParts(entry.parts) input.gotoBufferEnd() + resetVimHistory() } dialog.clear() }, @@ -782,10 +1179,12 @@ export function Prompt(props: PromptProps) { setStore("prompt", { input: entry.input, parts: entry.parts }) restoreExtmarksFromParts(entry.parts) input.gotoBufferEnd() + resetVimHistory() }} /> )) }, + }, ].map((entry) => ({ namespace: "palette", @@ -800,7 +1199,7 @@ export function Prompt(props: PromptProps) { useBindings(() => { return { target: inputTarget, - enabled: inputTarget() !== undefined && !props.disabled, + enabled: inputTarget() !== undefined && !props.disabled && !vimState.isCopy(), bindings: tuiConfig.keybinds.get("prompt.paste"), } }) @@ -884,6 +1283,7 @@ export function Prompt(props: PromptProps) { setStore("mode", item.mode ?? "normal") restoreExtmarksFromParts(item.parts) input.cursorOffset = 0 + resetVimHistory() }, }, ], @@ -920,6 +1320,7 @@ export function Prompt(props: PromptProps) { setStore("mode", item.mode ?? "normal") restoreExtmarksFromParts(item.parts) input.cursorOffset = input.plainText.length + resetVimHistory() }, }, ], @@ -950,7 +1351,7 @@ export function Prompt(props: PromptProps) { // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. - if (input && !input.isDestroyed && input.plainText !== store.prompt.input) { + if (input && !input.isDestroyed && !vimState.isCopy() && input.plainText !== store.prompt.input) { setStore("prompt", "input", input.plainText) syncExtmarksWithPromptParts() } @@ -1119,6 +1520,7 @@ export function Prompt(props: PromptProps) { }) if (editorParts.length > 0) editor.markSelectionSent() } + vimState.clearPending() history.append({ ...store.prompt, mode: currentMode, @@ -1129,6 +1531,8 @@ export function Prompt(props: PromptProps) { parts: [], }) setStore("extmarkToPartIndex", new Map()) + vimState.resetHistory() + if (cfg.vim_insert_after_submit && vimEnabled() && !vimState.isCopy()) vimState.setMode("insert") props.onSubmit?.() // temporary hack to make sure the message is sent @@ -1142,10 +1546,60 @@ export function Prompt(props: PromptProps) { }, 50) } input.clear() + if (vimEnabled() && vimState.isInsert()) vim.beginInsertEdit() if (finishMoveProgress) move.finishSubmit() return true } + function promptSnapshot() { + syncExtmarksWithPromptParts() + return { + text: input.plainText, + cursor: input.cursorOffset, + data: structuredClone(unwrap(store.prompt.parts)), + } + } + + function promptPartDataEqual(before: unknown, after: unknown) { + if (!Array.isArray(before) || !Array.isArray(after)) return Bun.deepEquals(before, after) + return Bun.deepEquals(before.map(normalizePromptPartForRepeat), after.map(normalizePromptPartForRepeat)) + } + + function normalizePromptPartForRepeat(part: PromptInfo["parts"][number]) { + if (part.type === "agent" && part.source) { + return { + ...part, + source: { + ...part.source, + start: 0, + end: 0, + }, + } + } + if ((part.type === "file" || part.type === "text") && part.source?.text) { + return { + ...part, + source: { + ...part.source, + text: { + ...part.source.text, + start: 0, + end: 0, + }, + }, + } + } + return part + } + + function refreshPromptLayout() { + setTimeout(() => { + if (!input || input.isDestroyed) return + input.getLayoutNode().markDirty() + renderer.requestRender() + }, 0) + } + function pasteText(text: string, virtualText: string) { const currentOffset = input.cursorOffset const extmarkStart = currentOffset @@ -1178,6 +1632,7 @@ export function Prompt(props: PromptProps) { draft.extmarkToPartIndex.set(extmarkId, partIndex) }), ) + refreshPromptLayout() } async function pasteInputText(text: string) { @@ -1213,12 +1668,7 @@ export function Prompt(props: PromptProps) { } input.insertText(normalizedText) - - setTimeout(() => { - if (!input || input.isDestroyed) return - input.getLayoutNode().markDirty() - renderer.requestRender() - }, 0) + refreshPromptLayout() } async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { @@ -1266,7 +1716,7 @@ export function Prompt(props: PromptProps) { draft.extmarkToPartIndex.set(extmarkId, partIndex) }), ) - return + refreshPromptLayout() } function clearPrompt() { @@ -1283,10 +1733,13 @@ export function Prompt(props: PromptProps) { parts: [], }) setStore("extmarkToPartIndex", new Map()) + resetVimHistory() } + const dimmed = createMemo(() => leader() || vimState.isCopy()) const highlight = createMemo(() => { - if (leader()) return theme.border + if (dimmed()) return theme.border + if (store.mode === "shell") return theme.primary const agent = local.agent.current() if (!agent) return theme.border @@ -1310,6 +1763,8 @@ export function Prompt(props: PromptProps) { const placeholderText = createMemo(() => { if (props.showPlaceholder === false) return undefined + if (mini()) return undefined + if (props.sessionID) return undefined if (store.mode === "shell") { if (!shell().length) return undefined const example = shell()[store.placeholder % shell().length] @@ -1342,8 +1797,36 @@ export function Prompt(props: PromptProps) { }), } }) - const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3))) - const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48))) + function isInsertIndicator(indicator: string) { + return indicator === "-- INSERT --" + } + + function isVisualIndicator(indicator: string) { + return ["-- VISUAL --", "-- VISUAL LINE --", "-- VISUAL BLOCK --"].includes(indicator) + } + + function VimIndicator() { + return ( + + {(indicator) => ( + + {indicator()} + + )} + + ) + } return ( <> @@ -1359,42 +1842,77 @@ export function Prompt(props: PromptProps) { > -