diff --git a/$null b/$null new file mode 100644 index 000000000..ade355f07 --- /dev/null +++ b/$null @@ -0,0 +1 @@ +ERROR: Invalid pattern is specified in "path:pattern". diff --git a/.github/workflows/auto-close-harvested.yml b/.github/workflows/auto-close-harvested.yml deleted file mode 100644 index 1547ab961..000000000 --- a/.github/workflows/auto-close-harvested.yml +++ /dev/null @@ -1,153 +0,0 @@ -name: Auto-close harvested PRs - -# When a commit on main contains a "Harvested from PR #N" line in its -# message, close PR #N with a templated thank-you that links back to -# the merged commit. Solves the long-standing problem where contributor -# PRs whose code lands via maintainer cherry-pick stay open and -# `CONFLICTING` forever, even though their fix is credited in the -# CHANGELOG. -# -# The expected commit-message convention is documented in -# CONTRIBUTING.md. Two patterns are recognised: -# -# * `Harvested from PR #1234 by @username` (preferred) -# * `harvested from #1234` (case-insensitive fallback) -# -# The first match's PR number is closed; multiple PRs can be closed -# per commit by repeating the line. The match runs on the commit -# body only, not on the subject line, so the subject can describe -# the change naturally without baking a number into it. - -on: - push: - branches: [main] - -permissions: - contents: read - pull-requests: write - issues: write - -# Only one auto-close run at a time so two near-simultaneous main -# pushes can't both try to close the same PR (the second would just -# fail with "Pull request is already closed", harmless but noisy). -concurrency: - group: auto-close-harvested - cancel-in-progress: false - -jobs: - close: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # We need at least the commits that this push introduced. - # fetch-depth: 0 is the simplest correct option; the - # alternative (fetching just `before..after`) is fragile - # when force-pushes happen. - fetch-depth: 0 - - - name: Close PRs referenced by harvested-from lines - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BEFORE_SHA: ${{ github.event.before }} - AFTER_SHA: ${{ github.event.after }} - shell: bash - run: | - set -euo pipefail - - # The first push to a fresh branch has BEFORE_SHA = 0000…0000. - # In that case fall back to the latest commit only — we don't - # want to scan the entire history. - if [[ "${BEFORE_SHA}" == "0000000000000000000000000000000000000000" || -z "${BEFORE_SHA:-}" ]]; then - RANGE="${AFTER_SHA}" - RANGE_ARGS=("-1" "${AFTER_SHA}") - else - RANGE="${BEFORE_SHA}..${AFTER_SHA}" - RANGE_ARGS=("${RANGE}") - fi - echo "Scanning commit range: ${RANGE}" - - # `git log --format=%H%n%B%n--END--` separates commits with a - # sentinel so multi-line bodies don't get mangled. - mapfile -t commits < <(git log "${RANGE_ARGS[@]}" --format="%H") - - if [[ ${#commits[@]} -eq 0 ]]; then - echo "No commits in range; nothing to do." - exit 0 - fi - - declare -A processed_prs=() - - for sha in "${commits[@]}"; do - body="$(git log -1 --format=%B "${sha}")" - # Two patterns, both case-insensitive on the keyword: - # "Harvested from PR #1234 by @username" (preferred form) - # "harvested from #1234" (short form) - mapfile -t pr_numbers < <( - printf '%s\n' "${body}" \ - | grep -oiE 'harvested from (pr )?#[0-9]+' \ - | grep -oE '#[0-9]+' \ - | tr -d '#' \ - | sort -u || true - ) - - if [[ ${#pr_numbers[@]} -eq 0 ]]; then - continue - fi - - short_sha="${sha:0:12}" - subject="$(git log -1 --format=%s "${sha}")" - - for pr in "${pr_numbers[@]}"; do - key="${pr}-${sha}" - if [[ -n "${processed_prs[${key}]:-}" ]]; then - continue - fi - processed_prs[${key}]=1 - - # Idempotency: skip if the PR is already closed. - state="$(gh pr view "${pr}" --json state --jq .state 2>/dev/null || echo "MISSING")" - if [[ "${state}" == "CLOSED" || "${state}" == "MERGED" ]]; then - echo "PR #${pr} is already ${state}; skipping." - continue - fi - if [[ "${state}" == "MISSING" ]]; then - echo "::warning::PR #${pr} not found or inaccessible; skipping." - continue - fi - - author="$(gh pr view "${pr}" --json author --jq '.author.login' 2>/dev/null || echo "")" - greeting="Hi" - if [[ -n "${author}" ]]; then - greeting="Thanks @${author}" - fi - - # NOTE: this block intentionally avoids `< ${subject}" \ - "" \ - "Closing this PR now that the code is on \`main\`. Credit lives in the commit message and (where applicable) the \`CHANGELOG.md\` entry for the next release. Apologies for not closing this at the time of the merge — the auto-close workflow is new in v0.8.31." \ - "" \ - "If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the [\`CONTRIBUTING.md\`](${contributing_url}) doc has a short note on what makes a contribution mergeable as-is." \ - )" - - echo "Closing PR #${pr} (harvested in ${short_sha})" - if ! gh pr close "${pr}" \ - --repo "${GITHUB_REPOSITORY}" \ - --comment "${body_text}"; then - echo "::warning::Failed to close PR #${pr}; continuing" - fi - done - done - - echo "Auto-close pass complete." diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml deleted file mode 100644 index f73794482..000000000 --- a/.github/workflows/auto-tag.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Auto-tag on version bump - -# When the workspace version on `main` advances past the latest existing -# `vX.Y.Z` tag, push the matching tag automatically. The push then triggers -# `release.yml`, which runs parity, builds binaries, drafts the GitHub -# Release, and publishes the npm wrapper. -# -# IMPORTANT: tag pushes signed by the default `GITHUB_TOKEN` do NOT trigger -# downstream `on: push: tags` workflows (GitHub Actions safety rule). For -# this auto-tag flow to actually fire `release.yml`, store a PAT (or -# fine-grained token) with `contents: write` on this repo as the -# `RELEASE_TAG_PAT` secret. Without it, the tag is created but `release.yml` -# does NOT run automatically — you'd have to push the tag again manually -# (`git push origin v$VERSION` from a developer machine) to trigger release. - -on: - push: - branches: [main] - paths: - - 'Cargo.toml' - - 'npm/codewhale/package.json' - - 'npm/deepseek-tui/package.json' - workflow_dispatch: - -permissions: - contents: write - -jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - # Prefer PAT so the resulting tag push triggers release.yml. - # Falls back to GITHUB_TOKEN, which will tag but NOT trigger. - token: ${{ secrets.RELEASE_TAG_PAT || github.token }} - - - name: Read workspace version - id: ver - run: | - v="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')" - if [ -z "$v" ]; then - echo "::error::Could not parse workspace version from Cargo.toml" >&2 - exit 1 - fi - echo "version=$v" >> "$GITHUB_OUTPUT" - echo "tag=v$v" >> "$GITHUB_OUTPUT" - echo "Workspace version: $v" - - - name: Check whether tag already exists - id: check - env: - TAG: ${{ steps.ver.outputs.tag }} - run: | - git fetch --tags --quiet - if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null \ - || git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then - echo "exists=true" >> "$GITHUB_OUTPUT" - echo "Tag ${TAG} already exists; nothing to do." - else - echo "exists=false" >> "$GITHUB_OUTPUT" - echo "Tag ${TAG} does not exist; will create." - fi - - - name: Verify version consistency - if: steps.check.outputs.exists == 'false' - run: ./scripts/release/check-versions.sh - - - name: Create and push tag - if: steps.check.outputs.exists == 'false' - env: - TAG: ${{ steps.ver.outputs.tag }} - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag "${TAG}" - git push origin "${TAG}" - echo "Pushed ${TAG}. release.yml should now run (requires RELEASE_TAG_PAT for trigger)." - - - name: Warn if PAT missing - if: steps.check.outputs.exists == 'false' && env.HAS_PAT != 'true' - env: - HAS_PAT: ${{ secrets.RELEASE_TAG_PAT != '' }} - run: | - echo "::warning::RELEASE_TAG_PAT secret is not set. The tag was pushed using GITHUB_TOKEN, which does NOT trigger release.yml. Manually re-push the tag from a developer machine, or run 'gh workflow run release.yml --ref ${{ steps.ver.outputs.tag }}'." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4203a17cd..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,127 +0,0 @@ -name: CI - -on: - push: - branches: [master, main] - pull_request: - branches: [master, main] - schedule: - - cron: '31 6 * * 1' - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: -Dwarnings - -jobs: - versions: - name: Version drift - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Check version drift - run: ./scripts/release/check-versions.sh - - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - name: Check formatting - run: cargo fmt --all -- --check - - name: Linux clippy location - run: echo "Linux clippy/test gates run on CNB for mirrored fix/*, rebrand/*, work/v*, and main branches." - - test: - name: Test - runs-on: ${{ matrix.os }} - strategy: - matrix: - # Linux workspace tests moved to CNB; GitHub keeps the platform - # coverage CNB cannot provide. - os: [ubuntu-latest, macos-latest, windows-latest] - steps: - - uses: actions/checkout@v4 - if: runner.os != 'Linux' - - uses: dtolnay/rust-toolchain@stable - if: runner.os != 'Linux' - - uses: Swatinem/rust-cache@v2 - if: runner.os != 'Linux' - with: - cache-bin: false - - name: Run tests - if: runner.os != 'Linux' - run: cargo test --workspace --all-features --locked - - name: Lockfile drift guard - if: runner.os != 'Linux' - run: git diff --exit-code -- Cargo.lock - - name: Run Offline Eval Harness - if: runner.os != 'Linux' - run: cargo run -p codewhale-tui --all-features -- eval - - name: Linux test location - if: runner.os == 'Linux' - run: echo "Linux workspace tests run on CNB for mirrored first-party branches." - - npm-wrapper-smoke: - name: npm wrapper smoke - if: github.event_name != 'schedule' - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }} - steps: - - uses: actions/checkout@v4 - if: runner.os != 'Linux' - - uses: dtolnay/rust-toolchain@stable - if: runner.os != 'Linux' - - uses: actions/setup-node@v4 - if: runner.os != 'Linux' - with: - node-version: 20 - - uses: Swatinem/rust-cache@v2 - if: runner.os != 'Linux' - with: - cache-bin: false - - name: Build wrapper binaries - if: runner.os != 'Linux' - run: cargo build --release --locked -p codewhale-cli -p codewhale-tui - - name: Smoke wrapper install and delegated entrypoints - if: runner.os != 'Linux' - run: node scripts/release/npm-wrapper-smoke.js - - name: Linux smoke location - if: runner.os == 'Linux' - run: echo "Linux npm wrapper smoke runs on CNB for mirrored first-party branches." - - # Check documentation builds without warnings - docs: - name: Documentation - if: github.event_name == 'schedule' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - name: Install Linux system dependencies - if: runner.os == 'Linux' - run: | - for i in 1 2 3 4 5; do - sudo apt-get update && break - echo "apt-get update failed (attempt $i); retrying in 15s" - sleep 15 - done - sudo apt-get install -y libdbus-1-dev pkg-config - - uses: Swatinem/rust-cache@v2 - with: - cache-bin: false - - name: Build docs - run: cargo doc --workspace --no-deps - env: - RUSTDOCFLAGS: -Dwarnings diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml deleted file mode 100644 index 53fcd34a7..000000000 --- a/.github/workflows/nightly.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Nightly - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: nightly-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: -Dwarnings - DEEPSEEK_BUILD_SHA: ${{ github.sha }} - -jobs: - build: - name: Build ${{ matrix.artifact_name }} - strategy: - fail-fast: false - matrix: - include: - # --- codewhale (cli dispatcher, canonical) --- - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - binary: codewhale - artifact_name: codewhale-linux-x64 - - os: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - binary: codewhale - artifact_name: codewhale-linux-arm64 - - os: macos-latest - target: x86_64-apple-darwin - binary: codewhale - artifact_name: codewhale-macos-x64 - - os: macos-latest - target: aarch64-apple-darwin - binary: codewhale - artifact_name: codewhale-macos-arm64 - - os: windows-latest - target: x86_64-pc-windows-msvc - binary: codewhale.exe - artifact_name: codewhale-windows-x64.exe - # --- codewhale-tui (TUI runtime, canonical) --- - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - binary: codewhale-tui - artifact_name: codewhale-tui-linux-x64 - - os: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - binary: codewhale-tui - artifact_name: codewhale-tui-linux-arm64 - - os: macos-latest - target: x86_64-apple-darwin - binary: codewhale-tui - artifact_name: codewhale-tui-macos-x64 - - os: macos-latest - target: aarch64-apple-darwin - binary: codewhale-tui - artifact_name: codewhale-tui-macos-arm64 - - os: windows-latest - target: x86_64-pc-windows-msvc - binary: codewhale-tui.exe - artifact_name: codewhale-tui-windows-x64.exe - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - cache-bin: false - - name: Install Linux system dependencies - if: runner.os == 'Linux' - run: | - for i in 1 2 3 4 5; do - sudo apt-get update && break - echo "apt-get update failed (attempt $i); retrying in 15s" - sleep 15 - done - sudo apt-get install -y libdbus-1-dev pkg-config - - name: Build - shell: bash - run: cargo build --release --locked --target ${{ matrix.target }} - - name: Stage artifact - id: stage - shell: bash - run: | - short_sha="${GITHUB_SHA::12}" - bin_path="target/${{ matrix.target }}/release/${{ matrix.binary }}" - if [ ! -f "${bin_path}" ]; then - echo "Binary not at ${bin_path}; searching target/ for ${{ matrix.binary }}:" - find target -name "${{ matrix.binary }}" -type f - exit 1 - fi - - mkdir -p nightly - cp "${bin_path}" "nightly/${{ matrix.artifact_name }}" - cat > nightly/nightly-build-info.txt <> "${GITHUB_OUTPUT}" - - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.stage.outputs.name }} - path: nightly/* - retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index b650617c2..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,448 +0,0 @@ -name: Release - -on: - push: - tags: ['v*'] - workflow_dispatch: - inputs: - version: - description: 'Package/release version to publish to npm, without the leading v' - required: true - type: string - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: -Dwarnings - -jobs: - parity: - if: github.event_name == 'push' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - name: Install Linux system dependencies - if: runner.os == 'Linux' - run: | - for i in 1 2 3 4 5; do - sudo apt-get update && break - echo "apt-get update failed (attempt $i); retrying in 15s" - sleep 15 - done - sudo apt-get install -y libdbus-1-dev pkg-config - - uses: Swatinem/rust-cache@v2 - with: - cache-bin: false - - name: Format check - run: cargo fmt --all -- --check - - name: Compile check - run: cargo check --workspace --all-targets --locked - - name: Clippy - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - - name: Workspace tests - run: cargo test --workspace --all-features --locked - - name: TUI snapshot parity - run: cargo test -p codewhale-tui-core --test snapshot --locked - - name: Protocol schema parity - run: cargo test -p codewhale-protocol --test parity_protocol --locked - - name: State persistence parity - run: cargo test -p codewhale-state --test parity_state --locked - - name: Lockfile drift guard - run: git diff --exit-code -- Cargo.lock - - resolve: - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.release.outputs.tag }} - source_ref: ${{ steps.release.outputs.source_ref }} - sha: ${{ steps.release.outputs.sha }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Resolve release source - id: release - shell: bash - run: | - if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - tag="v${{ inputs.version }}" - git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}" - sha="$(git rev-list -n 1 "${tag}")" - source_ref="${tag}" - else - tag="${GITHUB_REF_NAME}" - sha="${GITHUB_SHA}" - source_ref="${GITHUB_REF_NAME}" - fi - - if [ -z "${sha}" ]; then - echo "Unable to resolve release source for ${tag}" >&2 - exit 1 - fi - - echo "tag=${tag}" >> "$GITHUB_OUTPUT" - echo "source_ref=${source_ref}" >> "$GITHUB_OUTPUT" - echo "sha=${sha}" >> "$GITHUB_OUTPUT" - - build: - needs: [parity, resolve] - # `parity` is gated to tag-push events. On manual `workflow_dispatch`, - # parity is skipped, so let `build` proceed when parity either succeeded - # or was skipped — but never when it actually failed or the run was - # cancelled. Operators using dispatch are expected to have already run - # the same gates locally / via ci.yml on `main`. - if: ${{ !cancelled() && (needs.parity.result == 'success' || needs.parity.result == 'skipped') }} - strategy: - matrix: - include: - # --- codewhale (cli dispatcher, canonical) --- - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - binary: codewhale - artifact_name: codewhale-linux-x64 - - os: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - binary: codewhale - artifact_name: codewhale-linux-arm64 - - os: macos-latest - target: x86_64-apple-darwin - binary: codewhale - artifact_name: codewhale-macos-x64 - - os: macos-latest - target: aarch64-apple-darwin - binary: codewhale - artifact_name: codewhale-macos-arm64 - - os: windows-latest - target: x86_64-pc-windows-msvc - binary: codewhale.exe - artifact_name: codewhale-windows-x64.exe - # --- codewhale-tui (TUI runtime, canonical) --- - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - binary: codewhale-tui - artifact_name: codewhale-tui-linux-x64 - - os: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - binary: codewhale-tui - artifact_name: codewhale-tui-linux-arm64 - - os: macos-latest - target: x86_64-apple-darwin - binary: codewhale-tui - artifact_name: codewhale-tui-macos-x64 - - os: macos-latest - target: aarch64-apple-darwin - binary: codewhale-tui - artifact_name: codewhale-tui-macos-arm64 - - os: windows-latest - target: x86_64-pc-windows-msvc - binary: codewhale-tui.exe - artifact_name: codewhale-tui-windows-x64.exe - # --- deepseek (legacy dispatcher shim; removed in v0.9.0) --- - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - binary: deepseek - artifact_name: deepseek-linux-x64 - - os: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - binary: deepseek - artifact_name: deepseek-linux-arm64 - - os: macos-latest - target: x86_64-apple-darwin - binary: deepseek - artifact_name: deepseek-macos-x64 - - os: macos-latest - target: aarch64-apple-darwin - binary: deepseek - artifact_name: deepseek-macos-arm64 - - os: windows-latest - target: x86_64-pc-windows-msvc - binary: deepseek.exe - artifact_name: deepseek-windows-x64.exe - # --- deepseek-tui (legacy TUI shim; removed in v0.9.0) --- - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - binary: deepseek-tui - artifact_name: deepseek-tui-linux-x64 - - os: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - binary: deepseek-tui - artifact_name: deepseek-tui-linux-arm64 - - os: macos-latest - target: x86_64-apple-darwin - binary: deepseek-tui - artifact_name: deepseek-tui-macos-x64 - - os: macos-latest - target: aarch64-apple-darwin - binary: deepseek-tui - artifact_name: deepseek-tui-macos-arm64 - - os: windows-latest - target: x86_64-pc-windows-msvc - binary: deepseek-tui.exe - artifact_name: deepseek-tui-windows-x64.exe - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.resolve.outputs.source_ref }} - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - cache-bin: false - - name: Install Linux system dependencies - if: runner.os == 'Linux' - run: | - for i in 1 2 3 4 5; do - sudo apt-get update && break - echo "apt-get update failed (attempt $i); retrying in 15s" - sleep 15 - done - sudo apt-get install -y libdbus-1-dev pkg-config - - name: Build - shell: bash - env: - DEEPSEEK_BUILD_SHA: ${{ needs.resolve.outputs.sha }} - run: cargo build --release --locked --target ${{ matrix.target }} - - name: Rename binary - shell: bash - run: | - BIN_PATH="target/${{ matrix.target }}/release/${{ matrix.binary }}" - if [ ! -f "${BIN_PATH}" ]; then - echo "Binary not at ${BIN_PATH}; searching target/ for ${{ matrix.binary }}:" - find target -name "${{ matrix.binary }}" -type f - exit 1 - fi - cp "${BIN_PATH}" "${{ matrix.artifact_name }}" - - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact_name }} - path: ${{ matrix.artifact_name }} - docker: - needs: [build, resolve] - if: ${{ !cancelled() && needs.build.result == 'success' }} - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout release source - uses: actions/checkout@v4 - with: - ref: ${{ needs.resolve.outputs.source_ref }} - path: source - - name: Checkout release infrastructure - uses: actions/checkout@v4 - with: - path: infra - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Normalize image name - id: image - shell: bash - run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: | - ${{ steps.image.outputs.name }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern=v{{major}} - type=ref,event=tag - type=semver,pattern={{version}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} - type=semver,pattern={{major}}.{{minor}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} - type=semver,pattern=v{{major}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} - type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }} - type=raw,value=v${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }} - type=raw,value=latest - - name: Build and push - uses: docker/build-push-action@v6 - env: - # The build record is useful in CI, but it is uploaded as a - # `.dockerbuild` artifact. The release job intentionally downloads - # all binary artifacts, so suppress the extra record artifact there. - DOCKER_BUILD_RECORD_UPLOAD: false - DOCKER_BUILD_SUMMARY: false - with: - context: source - file: infra/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - DEEPSEEK_BUILD_SHA=${{ needs.resolve.outputs.sha }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - release: - needs: [build, docker, resolve] - if: ${{ !cancelled() && needs.build.result == 'success' && needs.docker.result == 'success' }} - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/download-artifact@v4 - with: - path: artifacts - # Match both the canonical `codewhale*` artifacts and the legacy - # `deepseek*` shim artifacts that ship for the transition release. - pattern: '*' - - name: List artifacts - run: find artifacts -type f - - name: Generate checksum manifest - shell: bash - run: | - mkdir -p artifacts/checksums - # Canonical manifest used by codewhale's `codewhale update` flow. - manifest="artifacts/checksums/codewhale-artifacts-sha256.txt" - : > "${manifest}" - while IFS= read -r -d '' file; do - hash="$(sha256sum "${file}" | awk '{print $1}')" - base="$(basename "${file}")" - printf '%s %s\n' "${hash}" "${base}" >> "${manifest}" - done < <(find artifacts -type f ! -path 'artifacts/checksums/*' -print0 | sort -z) - # Legacy alias manifest so v0.8.40 `deepseek update` clients can - # still find a manifest by their hardcoded name. Same content; will - # be removed once the legacy shim binaries are retired in v0.9.0. - cp "${manifest}" "artifacts/checksums/deepseek-artifacts-sha256.txt" - cat "${manifest}" - - uses: softprops/action-gh-release@v1 - with: - tag_name: ${{ needs.resolve.outputs.tag }} - files: artifacts/*/* - prerelease: false - body: | - > This release renames the project to **CodeWhale**. The legacy - > `deepseek` and `deepseek-tui` binaries continue to ship as - > deprecation shims for one release cycle; they print a one-line - > warning and forward to `codewhale` / `codewhale-tui`. They will - > be removed in v0.9.0. See `docs/REBRAND.md` for the full - > migration story. - - ## Install - - ### Recommended — npm (one command, both binaries) - - ```bash - npm install -g codewhale - ``` - - The wrapper downloads both binaries from this Release and places them in the same directory. - - ### Docker / GHCR - - ```bash - docker run --rm -it \ - -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v ~/.deepseek:/home/codewhale/.deepseek \ - ghcr.io/hmbown/codewhale:${{ needs.resolve.outputs.tag }} - ``` - - The image ships the `codewhale` dispatcher and `codewhale-tui` runtime (plus the legacy `deepseek` / `deepseek-tui` shims during the transition). The `latest` tag is also updated on release. - - ### Cargo (Linux / macOS) - - ```bash - cargo install codewhale-cli codewhale-tui --locked - ``` - - Both crates are required — `codewhale-cli` produces the `codewhale` dispatcher and `codewhale-tui` produces the interactive runtime that the dispatcher delegates to. Installing only one binary will fail at runtime with a `MISSING_COMPANION_BINARY` error. - - ### Manual download - - **Both** binaries below must be downloaded for your platform and dropped into the same directory (e.g. `~/.local/bin/`): - - | Platform | Dispatcher | TUI runtime | - |---|---|---| - | Linux x64 | `codewhale-linux-x64` | `codewhale-tui-linux-x64` | - | Linux ARM64 | `codewhale-linux-arm64` | `codewhale-tui-linux-arm64` | - | macOS x64 | `codewhale-macos-x64` | `codewhale-tui-macos-x64` | - | macOS ARM | `codewhale-macos-arm64` | `codewhale-tui-macos-arm64` | - | Windows x64 | `codewhale-windows-x64.exe` | `codewhale-tui-windows-x64.exe` | - - Then `chmod +x` both (Unix) and run `./codewhale`. - - Legacy `deepseek-*` and `deepseek-tui-*` assets are also attached for one release cycle so that existing `deepseek update` invocations on v0.8.40 keep working; they install the deprecation shims, which forward to the canonical binaries. - - ### Verify (recommended) - - Download `codewhale-artifacts-sha256.txt` from this Release and verify: - - ```bash - # Linux - sha256sum -c codewhale-artifacts-sha256.txt - - # macOS - shasum -a 256 -c codewhale-artifacts-sha256.txt - ``` - - The legacy `deepseek-artifacts-sha256.txt` is also attached for backward compatibility and contains the same hashes. - - ## Changelog - - See [CHANGELOG.md](https://github.com/Hmbown/CodeWhale/blob/main/CHANGELOG.md) for the full notes for this release. - - homebrew: - needs: [release, resolve] - if: ${{ !cancelled() && needs.release.result == 'success' }} - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Check Homebrew tap token - id: homebrew-token - env: - TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }} - run: | - if [ -z "${TOKEN:-}" ]; then - echo "No Homebrew tap token configured; skipping tap update." - echo "available=false" >> "${GITHUB_OUTPUT}" - else - echo "available=true" >> "${GITHUB_OUTPUT}" - fi - # Checkout main (not the tag) so the release-infra script is always - # available, even for tags created before this workflow was added. - - uses: actions/checkout@v4 - if: steps.homebrew-token.outputs.available == 'true' - with: - ref: main - - name: Download checksum manifest - if: steps.homebrew-token.outputs.available == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release download ${{ needs.resolve.outputs.tag }} \ - --repo ${{ github.repository }} \ - --pattern 'deepseek-artifacts-sha256.txt' \ - --dir /tmp - - name: Update Homebrew tap - if: steps.homebrew-token.outputs.available == 'true' - env: - TAG: ${{ needs.resolve.outputs.tag }} - MANIFEST: /tmp/deepseek-artifacts-sha256.txt - TAP_REPO: Hmbown/homebrew-deepseek-tui - TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }} - run: bash .github/scripts/update-homebrew-tap.sh - -# npm publish is intentionally not automated. The npm account requires 2FA OTP -# on every publish, and a granular automation token that bypasses 2FA has not -# been provisioned. Release the npm wrapper manually from a developer machine -# after the GitHub Release has been created — see CLAUDE.md "Releases" for the -# exact commands. diff --git a/.github/workflows/spam-lockdown.yml b/.github/workflows/spam-lockdown.yml deleted file mode 100644 index 17f11bf0d..000000000 --- a/.github/workflows/spam-lockdown.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Lock down obvious spam issues - -on: - issues: - types: [opened] - -permissions: - issues: write - -jobs: - lockdown: - runs-on: ubuntu-latest - steps: - - name: Auto-close spam patterns from new accounts - uses: actions/github-script@v7 - with: - script: | - const issue = context.payload.issue; - const author = issue.user; - - // Only consider brand-new accounts. If the user has been around - // long enough to file good-faith issues elsewhere, don't touch. - const created = new Date(author.created_at || 0); - const ageDays = (Date.now() - created.getTime()) / 86_400_000; - if (ageDays > 30) return; - - const blob = `${issue.title || ''}\n${issue.body || ''}`; - const patterns = [ - /\bcrypto\b/i, - /\bairdrop\b/i, - /\bnft\b/i, - /\bpresale\b/i, - /\busdt\b/i, - /\btg\s*@/i, - /\btelegram\s+@/i, - /\bt\.me\//i, - /\bwhatsapp\s+\+/i, - /\bseo\s+service/i, - /\bguest\s+post/i, - /\bbacklink/i, - /\bbuy\s+followers/i, - /\bjoin\s+our\s+(community|server|group)/i, - /\bpromot[ei]\s+your\b/i, - ]; - const hit = patterns.find(p => p.test(blob)); - if (!hit) return; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: [ - 'This issue was auto-closed because the title or body matches', - 'a spam pattern (paid promotion / unrelated link) and the author', - 'account is less than 30 days old. If this is a real bug or', - 'feature request, please reopen with a clearer description', - '(in English or 中文) of the project-relevant context.', - ].join(' '), - }); - - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: 'closed', - state_reason: 'not_planned', - }); - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['spam'], - }).catch(() => {}); // ignore if label doesn't exist yet diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 75535aa7f..000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Close stale issues - -on: - schedule: - - cron: '17 5 * * *' # daily, off-peak - workflow_dispatch: {} - -permissions: - issues: write - pull-requests: write - -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v9 - with: - days-before-stale: 14 - days-before-close: 7 - stale-issue-message: > - This issue has been inactive for 14 days while waiting on - additional information. It will close automatically in 7 days - unless someone responds. If you still need help, drop a - comment with the requested details and a maintainer can - reopen. - close-issue-message: > - Closing for inactivity. Feel free to comment to reopen if - you can share the requested information. - stale-issue-label: 'stale' - only-labels: 'needs-info' - exempt-issue-labels: 'pinned,keep-open,bug,security' - # Don't touch PRs — `actions/stale` defaults can be aggressive - # there. We only want it for `needs-info` issues. - days-before-pr-stale: -1 - days-before-pr-close: -1 - operations-per-run: 60 diff --git a/.github/workflows/sync-cnb.yml b/.github/workflows/sync-cnb.yml deleted file mode 100644 index 33c7cfe1d..000000000 --- a/.github/workflows/sync-cnb.yml +++ /dev/null @@ -1,125 +0,0 @@ -name: Sync to CNB - -# Mirror commits and release tags to cnb.cool/codewhale.net/codewhale -# so users behind GitHub-blocking networks can fetch the source and tagged -# releases from the Tencent-hosted mirror. -# -# Triggers: -# * push to main → mirrors that commit to CNB main -# * tag matching v* → mirrors that tag to CNB -# * release work branches → mirrors release-candidate refs for CNB preflight -# * fix/rebrand branches → mirrors first-party heavy Linux CI refs -# * Tencent release branches → mirrors Feishu/Lighthouse setup branches -# * workflow_dispatch → manual fallback if any of the above fails -# -# Why the rewrite (v0.8.31): -# The previous implementation used the opaque tencentcom/git-sync Docker -# action, which discovered every local ref via fetch-depth: 0 and tried -# to push them all — including dependabot/* branches that GitHub had -# locally. Those follow-on pushes ran without the configured credential -# helper in scope and failed with -# `fatal: could not read Username for 'https://cnb.cool'` -# No concurrency block meant the main-push and tag-push workflow runs -# that auto-tag.yml fires within seconds of each other raced. About -# half of recent runs failed for those two reasons combined. - -on: - push: - branches: - - main - - 'work/v*' - - 'fix/*' - - 'rebrand/*' - - 'work/v*-feishu-*' - - 'work/v*-lighthouse*' - tags: ['v*'] - workflow_dispatch: {} - -# Serialize runs so the back-to-back main-push + tag-push from auto-tag.yml -# don't race each other rebasing onto CNB. cancel-in-progress: false so -# every commit actually arrives — we'd rather queue than drop. -concurrency: - group: cnb-sync - cancel-in-progress: false - -permissions: - contents: read - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push triggering ref to CNB - env: - CNB_TOKEN: ${{ secrets.CNB_GIT_TOKEN }} - shell: bash - run: | - set -euo pipefail - - if [ -z "${CNB_TOKEN:-}" ]; then - echo "::error::CNB_GIT_TOKEN secret is not set; cannot push to CNB." >&2 - exit 1 - fi - - # URL-encode any '%' in the token so basic-auth doesn't break on - # special characters. CNB tokens are typically alphanumeric so - # this is belt-and-suspenders. - ENCODED_TOKEN="$(printf '%s' "${CNB_TOKEN}" | python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read(), safe=""))')" - REMOTE_URL="https://cnb:${ENCODED_TOKEN}@cnb.cool/codewhale.net/codewhale.git" - # Use a masked alias so the token never appears in log lines. - git remote add cnb "${REMOTE_URL}" - - # Push with retry on transient failures (CNB rate-limits, DNS - # blips, etc.). Args after `kind` are forwarded to `git push` - # so callers can pass `--force-with-lease`, multiple refspecs, - # etc. without quoting them into one string. - push_with_retry() { - local kind="$1" - shift - local attempt - for attempt in 1 2 3; do - echo "Attempt ${attempt}: pushing ${kind} to CNB" - if git push cnb "$@" 2>&1; then - echo "Successfully pushed ${kind} to CNB" - return 0 - fi - if [ "${attempt}" -lt 3 ]; then - sleep $((attempt * 5)) - fi - done - echo "::error::Failed to push ${kind} to CNB after 3 attempts" >&2 - return 1 - } - - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then - TAG="${GITHUB_REF#refs/tags/}" - # Release tags may be repointed while rebuilding a failed - # publish attempt. CNB is a one-way mirror, so force the tag - # there to match GitHub instead of failing on "already exists". - push_with_retry "tag ${TAG}" "+refs/tags/${TAG}:refs/tags/${TAG}" - elif [[ "${GITHUB_REF}" == refs/heads/main ]]; then - # Plain --force. The CNB mirror is one-way by design — - # nothing else pushes to it, so there's no contributor work - # to protect against. `--force-with-lease` would be safer - # in a multi-writer scenario, but in our setup the lease - # check requires `refs/remotes/cnb/main` to exist in the - # runner's local clone, which it never does (we add `cnb` - # as a fresh remote in this step and don't fetch first). - # That made the lease check spuriously fail with - # `! [rejected] HEAD -> main (stale info)` even when CNB - # was actually behind GitHub. - push_with_retry "main" HEAD:refs/heads/main --force - else - # First-party fix/rebrand/release branches are first-class CNB - # sources for heavy Linux CI, release preflight, and - # Lighthouse/Feishu bootstrap. - # Mirror the triggering branch exactly so the CNB clone path stays - # useful before the branch has merged to main or become a release - # tag. - BRANCH="${GITHUB_REF#refs/heads/}" - push_with_retry "branch ${BRANCH}" "HEAD:refs/heads/${BRANCH}" --force - fi diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml deleted file mode 100644 index 4c7ad25b5..000000000 --- a/.github/workflows/triage.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Issue triage - -on: - issues: - types: [opened, reopened] - -permissions: - issues: write - contents: read - -jobs: - label: - runs-on: ubuntu-latest - steps: - - name: Auto-label by title and body - uses: actions/github-script@v7 - with: - script: | - const issue = context.payload.issue; - const title = (issue.title || '').toLowerCase(); - const body = (issue.body || '').toLowerCase(); - const text = `${title}\n${body}`; - const labels = new Set(); - - // Type - if (/\b(bug|crash|panic|broken|stack ?trace|regression|err(?:or)?|fail(?:ed|ure)?)\b/.test(text)) labels.add('bug'); - if (/\b(feat(?:ure)?|request|enhancement|wishlist|proposal|please add|would be nice|support for)\b/.test(text)) labels.add('enhancement'); - if (/\b(docs?|readme|documentation|typo|grammar|wording|spelling)\b/.test(text)) labels.add('documentation'); - if (/\b(question|how (?:do|to)|why does|what does|is it possible)\b/.test(text)) labels.add('question'); - - // Locale — title contains CJK (Chinese, Japanese, Korean) characters - if (/[぀-ヿ㐀-鿿가-힯]/.test(issue.title || '')) labels.add('lang:zh'); - - // Areas (path-driven hints) - if (/crates\/tui|\btui\b|ratatui|composer|sidebar/.test(text)) labels.add('area:tui'); - if (/crates\/core|engine|turn ?loop|agent ?loop/.test(text)) labels.add('area:core'); - if (/crates\/mcp|\bmcp\b/.test(text)) labels.add('area:mcp'); - if (/crates\/state|sqlite|sessions?|persistence/.test(text)) labels.add('area:state'); - if (/crates\/execpolicy|approval|sandbox|seatbelt|landlock/.test(text)) labels.add('area:execpolicy'); - if (/crates\/tools|tool[ _]call|tool[ _]registry/.test(text)) labels.add('area:tools'); - if (/install|cargo install|npm install|scoop|homebrew|prebuilt|binary/.test(text)) labels.add('area:install'); - if (/windows/.test(text)) labels.add('os:windows'); - if (/macos|darwin|apple silicon/.test(text)) labels.add('os:macos'); - if (/\blinux\b|ubuntu|debian|fedora|arch ?linux/.test(text)) labels.add('os:linux'); - - if (labels.size === 0) return; - - // Only add labels that already exist on the repo to avoid creating noise. - const existing = await github.paginate(github.rest.issues.listLabelsForRepo, { - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100, - }); - const existingNames = new Set(existing.map(l => l.name)); - const toAdd = [...labels].filter(name => existingNames.has(name)); - if (toAdd.length === 0) return; - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: toAdd, - }); diff --git a/.gitignore b/.gitignore index 0668130d3..fa0246d15 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,14 @@ docs/*_PLAN.md # direnv .envrc .direnv +config.toml +deepseek.ps1 +pack_claude*.js +pack_claude*.ps1 +.github/workflows.bak/ +_test_exe.js +check_scripts.js +_read_cli.js +verify_deploy.js +pack_ds_full.js +InstallationLog.txt diff --git a/Added b/Added new file mode 100644 index 000000000..e69de29bb diff --git a/build_check.bat b/build_check.bat new file mode 100644 index 000000000..356742daa --- /dev/null +++ b/build_check.bat @@ -0,0 +1,7 @@ +@echo off +set MSYS_NO_PATHCONV=1 +set MSYS2_ARG_CONV_EXCL=* +set PATH=C:\msys64\mingw64\bin;%PATH% +cd /d "C:\Users\ljm37\DeepSeek Tui" +set CARGO_TARGET_DIR=C:\cargo_target +cargo check -p deepseek-tui \ No newline at end of file diff --git a/check_py.py b/check_py.py new file mode 100644 index 000000000..522d8b9aa --- /dev/null +++ b/check_py.py @@ -0,0 +1,7 @@ +import importlib +for m in ['openpyxl','pandas','pdfplumber','selenium','playwright','requests','PIL','bs4','lxml','numpy','matplotlib','flask','fastapi']: + try: + importlib.import_module(m) + print(f'{m}: OK') + except: + print(f'{m}: MISS') diff --git a/check_scripts.js b/check_scripts.js new file mode 100644 index 000000000..32632da43 --- /dev/null +++ b/check_scripts.js @@ -0,0 +1,35 @@ +// check_scripts.js - Find actual script files in skills +const fs=require('fs'); +const p=require('path'); +const h=require('os').homedir(); +const dirs=[p.join(h,'.claude','skills'),p.join(h,'.agents','skills')]; +const scripts=[]; +for(const d of dirs){ + if(!fs.existsSync(d))continue; + function w(dd,name){ + for(const f of fs.readdirSync(dd,{withFileTypes:true})){ + const fp=p.join(dd,f.name); + if(f.isDirectory())w(fp,name); + else if(/\.(py|js|sh|bat|ps1)$/.test(f.name)&&!f.name.includes('node_modules')){ + const s=fs.statSync(fp).size; + scripts.push({skill:name,file:f.name,path:fp,size:s}); + } + } + } + for(const e of fs.readdirSync(d,{withFileTypes:true})){ + if(e.isDirectory())w(p.join(d,e.name),e.name); + } +} +scripts.sort((a,b)=>b.size-a.size); +for(const s of scripts){ + console.log(`${s.skill}: ${s.file} (${(s.size/1024).toFixed(1)}KB)`); +} +console.log(`\nTotal: ${scripts.length} script files`); + +// Categorize by type +const byExt={}; +for(const s of scripts){ + const ext=p.extname(s.file); + byExt[ext]=(byExt[ext]||0)+1; +} +console.log('\nBy type:',JSON.stringify(byExt)); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 4e5e78c00..4db825d76 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -944,13 +944,25 @@ impl Default for ComposerState { /// Viewport/scroll state — fields related to transcript scrolling and caching. pub struct ViewportState { + // Conversation region (upper) pub transcript_scroll: TranscriptScroll, pub pending_scroll_delta: i32, - pub mouse_scroll: MouseScrollState, pub transcript_cache: TranscriptViewCache, + + // Tool output region (lower) + pub tool_output_scroll: TranscriptScroll, + pub pending_tool_scroll_delta: i32, + pub tool_output_cache: TranscriptViewCache, + + // Shared state + pub mouse_scroll: MouseScrollState, pub transcript_selection: TranscriptSelection, pub selection_autoscroll: Option, pub transcript_scrollbar_dragging: bool, + + // Region area tracking + pub conversation_area: Option, + pub tool_output_area: Option, pub last_transcript_area: Option, pub last_transcript_top: usize, pub last_transcript_visible: usize, @@ -962,13 +974,25 @@ pub struct ViewportState { impl Default for ViewportState { fn default() -> Self { Self { + // Conversation region transcript_scroll: TranscriptScroll::to_bottom(), pending_scroll_delta: 0, - mouse_scroll: MouseScrollState::new(), transcript_cache: TranscriptViewCache::new(), + + // Tool output region + tool_output_scroll: TranscriptScroll::to_bottom(), + pending_tool_scroll_delta: 0, + tool_output_cache: TranscriptViewCache::new(), + + // Shared state + mouse_scroll: MouseScrollState::new(), transcript_selection: TranscriptSelection::default(), selection_autoscroll: None, transcript_scrollbar_dragging: false, + + // Region area tracking + conversation_area: None, + tool_output_area: None, last_transcript_area: None, last_transcript_top: 0, last_transcript_visible: 0, diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 477eafa01..d93021a34 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -172,6 +172,25 @@ impl Default for TranscriptRenderOptions { } impl HistoryCell { + /// Returns true if this cell belongs in the conversation (upper) region. + pub fn is_conversation_cell(&self) -> bool { + matches!( + self, + HistoryCell::User { .. } + | HistoryCell::Assistant { .. } + | HistoryCell::Thinking { .. } + | HistoryCell::System { .. } + | HistoryCell::Error { .. } + | HistoryCell::ArchivedContext { .. } + | HistoryCell::SubAgent(_) + ) + } + + /// Returns true if this cell belongs in the tool output (lower) region. + pub fn is_tool_output_cell(&self) -> bool { + matches!(self, HistoryCell::Tool(_)) + } + /// Render the cell into a set of terminal lines. /// /// This is the live-display path used by widgets that don't already pass diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 589c31aea..3c6a3f0a3 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -55,10 +55,12 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Up); - app.viewport.pending_scroll_delta = app - .viewport - .pending_scroll_delta - .saturating_add(update.delta_lines); + let pending_delta = if mouse_hits_tool_output_area(app, mouse) { + &mut app.viewport.pending_tool_scroll_delta + } else { + &mut app.viewport.pending_scroll_delta + }; + *pending_delta = pending_delta.saturating_add(update.delta_lines); if update.delta_lines != 0 { app.user_scrolled_during_stream = true; app.needs_redraw = true; @@ -66,10 +68,12 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Down); - app.viewport.pending_scroll_delta = app - .viewport - .pending_scroll_delta - .saturating_add(update.delta_lines); + let pending_delta = if mouse_hits_tool_output_area(app, mouse) { + &mut app.viewport.pending_tool_scroll_delta + } else { + &mut app.viewport.pending_scroll_delta + }; + *pending_delta = pending_delta.saturating_add(update.delta_lines); if update.delta_lines != 0 { app.user_scrolled_during_stream = true; app.needs_redraw = true; @@ -141,6 +145,24 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec bool { + if let Some(area) = app.viewport.tool_output_area { + return mouse.row >= area.y && mouse.row < area.y.saturating_add(area.height) + && mouse.column >= area.x && mouse.column < area.x.saturating_add(area.width); + } + false +} + +/// Check if mouse is over the conversation area (upper region). +fn mouse_hits_conversation_area(app: &App, mouse: MouseEvent) -> bool { + if let Some(area) = app.viewport.conversation_area { + return mouse.row >= area.y && mouse.row < area.y.saturating_add(area.height) + && mouse.column >= area.x && mouse.column < area.x.saturating_add(area.width); + } + false +} + pub(crate) fn mouse_hits_transcript_scrollbar(app: &App, mouse: MouseEvent) -> bool { let Some(area) = app.viewport.last_transcript_area else { return false; diff --git a/crates/tui/src/tui/tab/id_generator.rs b/crates/tui/src/tui/tab/id_generator.rs new file mode 100644 index 000000000..43038d44b --- /dev/null +++ b/crates/tui/src/tui/tab/id_generator.rs @@ -0,0 +1,65 @@ +//! 统一ID生成器 - Unified ID Generator +//! +//! 提供全局唯一的ID生成功能,支持多种前缀和格式。 + +use chrono::Utc; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// 全局ID生成器 +pub struct IdGenerator { + counter: AtomicU64, +} + +impl IdGenerator { + /// 生成新ID + pub fn next(&self) -> u64 { + self.counter.fetch_add(1, Ordering::Relaxed) + } +} + +// 全局实例 +static ID_GENERATOR: IdGenerator = IdGenerator { + counter: AtomicU64::new(1), +}; + +/// 生成唯一的Tab ID +pub fn generate_tab_id() -> String { + let id = ID_GENERATOR.next(); + let timestamp = Utc::now().timestamp(); + format!("tab_{}_{}", timestamp, id) +} + +/// 生成唯一的任务ID +pub fn generate_task_id() -> String { + let id = ID_GENERATOR.next(); + let timestamp = Utc::now().timestamp(); + format!("task_{}_{}", timestamp, id) +} + +/// 生成唯一的会议ID +pub fn generate_meeting_id() -> String { + let id = ID_GENERATOR.next(); + let timestamp = Utc::now().timestamp(); + format!("mtg_{}_{}", timestamp, id) +} + +/// 生成唯一的消息ID +pub fn generate_message_id() -> String { + let id = ID_GENERATOR.next(); + let timestamp = Utc::now().timestamp_millis(); + format!("msg_{}_{}", timestamp, id) +} + +/// 生成唯一的委托ID +pub fn generate_delegation_id() -> String { + let id = ID_GENERATOR.next(); + let timestamp = Utc::now().timestamp(); + format!("dlg_{}_{}", timestamp, id) +} + +/// 生成唯一的会话ID +pub fn generate_session_id() -> String { + let id = ID_GENERATOR.next(); + let timestamp = Utc::now().timestamp(); + format!("sess_{}_{}", timestamp, id) +} \ No newline at end of file diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 25e1fdb11..9e5560e6b 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -121,7 +121,7 @@ use super::slash_menu::{ }; use super::views::{ConfigView, HelpView, ModalKind, ShellControlView, ViewEvent}; use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview}; -use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable}; +use super::widgets::{ChatWidget, ChatRegion, ComposerWidget, HeaderData, HeaderWidget, Renderable}; // === Constants === @@ -5696,7 +5696,7 @@ fn render(f: &mut Frame, app: &mut App) { .direction(Direction::Vertical) .constraints([ Constraint::Length(header_height), // Header - Constraint::Min(1), // Chat area + Constraint::Min(1), // Chat area (split below) Constraint::Length(preview_height), // Pending input preview (0 if empty) Constraint::Length(composer_height), // Composer Constraint::Length(footer_height), // Footer @@ -5773,8 +5773,6 @@ fn render(f: &mut Frame, app: &mut App) { .style(Style::default().bg(app.ui_theme.surface_bg)) .render(chunks[1], f.buffer_mut()); - let mut sidebar_area = None; - // When the file-tree pane is visible and the terminal is wide // enough, reserve the left ~25% for the file tree. let mut chat_area = @@ -5798,6 +5796,8 @@ fn render(f: &mut Frame, app: &mut App) { chunks[1] }; + // First calculate sidebar width, then split chat_area for sidebar + let mut sidebar_area = None; if let Some(sidebar_width) = sidebar_width_for_chat_area(app, chat_area.width) { let split = Layout::default() .direction(Direction::Horizontal) @@ -5807,9 +5807,31 @@ fn render(f: &mut Frame, app: &mut App) { sidebar_area = Some(split[1]); } - let chat_widget = ChatWidget::new(app, chat_area); + // Now split chat_area (after sidebar) into conversation and tool output regions + const TOOL_OUTPUT_DEFAULT_HEIGHT: u16 = 10; + let chat_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // Conversation area (flexible) + Constraint::Length(TOOL_OUTPUT_DEFAULT_HEIGHT), // Tool output area (fixed height) + ]) + .split(chat_area); + + let conversation_area = chat_layout[0]; + let tool_output_area = chat_layout[1]; + + // Update viewport areas for mouse routing + app.viewport.conversation_area = Some(conversation_area); + app.viewport.tool_output_area = Some(tool_output_area); + + // Conversation widget (upper region) + let conversation_widget = ChatWidget::new(app, conversation_area, ChatRegion::Conversation); let buf = f.buffer_mut(); - chat_widget.render(chat_area, buf); + conversation_widget.render(conversation_area, buf); + + // Tool output widget (lower region) + let tool_output_widget = ChatWidget::new(app, tool_output_area, ChatRegion::ToolOutput); + tool_output_widget.render(tool_output_area, buf); if let Some(sidebar_area) = sidebar_area { super::sidebar::render_sidebar(f, sidebar_area, app); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index a81797690..fedd92474 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -53,6 +53,15 @@ const COMPOSER_PANEL_HEIGHT: u16 = 2; const JUMP_TO_LATEST_BUTTON_WIDTH: u16 = 3; const JUMP_TO_LATEST_BUTTON_HEIGHT: u16 = 3; +/// Identifies which region of the split chat area a widget should render. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatRegion { + /// Upper region: conversation/transcript (user, assistant, thinking). + Conversation, + /// Lower region: tool output (exec, exploring, patch, etc.). + ToolOutput, +} + pub struct ChatWidget { content_area: Rect, lines: Vec>, @@ -63,6 +72,7 @@ pub struct ChatWidget { scroll_thumb: Color, jump_border: Color, jump_arrow: Color, + region: ChatRegion, } #[derive(Debug, Clone, Copy)] @@ -73,7 +83,7 @@ struct TranscriptScrollbar { } impl ChatWidget { - pub fn new(app: &mut App, area: Rect) -> Self { + pub fn new(app: &mut App, area: Rect, region: ChatRegion) -> Self { let content_area = area; let background = app.ui_theme.surface_bg; let scroll_track = app.ui_theme.border; @@ -101,6 +111,7 @@ impl ChatWidget { scroll_thumb, jump_border, jump_arrow, + region, }; } @@ -130,36 +141,92 @@ impl ChatWidget { let history_len = app.history.len(); let has_collapsed = !app.collapsed_cells.is_empty(); - // Fast path: no collapsed cells — use original slices directly. + // Helper closure to check if a cell belongs in this region + let cell_belongs_to_region = |cell: &HistoryCell| -> bool { + match region { + ChatRegion::Conversation => cell.is_conversation_cell(), + ChatRegion::ToolOutput => cell.is_tool_output_cell(), + } + }; + + // Fast path: no collapsed cells — but still need region filtering if !has_collapsed { - let mut cell_revisions: Vec = - Vec::with_capacity(app.history.len() + active_entries.len()); - cell_revisions.extend_from_slice(&app.history_revisions); - if !active_entries.is_empty() { - let active_rev = app.active_cell_revision; - for i in 0..active_entries.len() { - let salt = (i as u64).wrapping_add(1); - cell_revisions.push( - active_rev - .wrapping_mul(0x9E37_79B9_7F4A_7C15) - .wrapping_add(salt), - ); + // Check if we need region filtering + let needs_filter = app.history.iter().any(|c| !cell_belongs_to_region(c)) + || active_entries.iter().any(|c| !cell_belongs_to_region(c)); + + if !needs_filter { + // No filtering needed, use original slices + let mut cell_revisions: Vec = + Vec::with_capacity(app.history.len() + active_entries.len()); + cell_revisions.extend_from_slice(&app.history_revisions); + if !active_entries.is_empty() { + let active_rev = app.active_cell_revision; + for i in 0..active_entries.len() { + let salt = (i as u64).wrapping_add(1); + cell_revisions.push( + active_rev + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(salt), + ); + } + } + // Build identity mapping: filtered index == original index. + app.collapsed_cell_map = (0..app.history.len() + active_entries.len()).collect(); + + let shards: [&[HistoryCell]; 2] = [&app.history, active_entries]; + app.viewport.transcript_cache.ensure_split( + &shards, + &cell_revisions, + content_area.width.max(1), + render_options, + ); + } else { + // Region filtering needed + let mut filtered_cells: Vec = + Vec::with_capacity(history_len + active_entries.len()); + let mut filtered_revs: Vec = + Vec::with_capacity(history_len + active_entries.len()); + let mut filtered_to_original: Vec = + Vec::with_capacity(history_len + active_entries.len()); + + for (idx, cell) in app.history.iter().enumerate() { + if cell_belongs_to_region(cell) { + filtered_cells.push(cell.clone()); + filtered_revs.push(app.history_revisions[idx]); + filtered_to_original.push(idx); + } } - } - // Build identity mapping: filtered index == original index. - app.collapsed_cell_map = (0..app.history.len() + active_entries.len()).collect(); - let shards: [&[HistoryCell]; 2] = [&app.history, active_entries]; - app.viewport.transcript_cache.ensure_split( - &shards, - &cell_revisions, - content_area.width.max(1), - render_options, - ); + if !active_entries.is_empty() { + let active_rev = app.active_cell_revision; + for (i, cell) in active_entries.iter().enumerate() { + if cell_belongs_to_region(cell) { + let original_idx = history_len + i; + filtered_cells.push(cell.clone()); + let salt = (i as u64).wrapping_add(1); + filtered_revs.push( + active_rev + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(salt), + ); + filtered_to_original.push(original_idx); + } + } + } + + app.collapsed_cell_map = filtered_to_original; + + let shards: [&[HistoryCell]; 1] = [&filtered_cells]; + app.viewport.transcript_cache.ensure_split( + &shards, + &filtered_revs, + content_area.width.max(1), + render_options, + ); + } } else { - // Slow path: clone non-collapsed cells into filtered vecs so - // collapsed cells are excluded from rendering. Build the - // filtered→original index mapping. + // Slow path: collapsed cells + region filtering let mut filtered_cells: Vec = Vec::with_capacity(history_len + active_entries.len()); let mut filtered_revs: Vec = @@ -171,9 +238,11 @@ impl ChatWidget { if app.collapsed_cells.contains(&idx) { continue; } - filtered_cells.push(cell.clone()); - filtered_revs.push(app.history_revisions[idx]); - filtered_to_original.push(idx); + if cell_belongs_to_region(cell) { + filtered_cells.push(cell.clone()); + filtered_revs.push(app.history_revisions[idx]); + filtered_to_original.push(idx); + } } if !active_entries.is_empty() { @@ -183,14 +252,16 @@ impl ChatWidget { if app.collapsed_cells.contains(&original_idx) { continue; } - filtered_cells.push(cell.clone()); - let salt = (i as u64).wrapping_add(1); - filtered_revs.push( - active_rev - .wrapping_mul(0x9E37_79B9_7F4A_7C15) - .wrapping_add(salt), - ); - filtered_to_original.push(original_idx); + if cell_belongs_to_region(cell) { + filtered_cells.push(cell.clone()); + let salt = (i as u64).wrapping_add(1); + filtered_revs.push( + active_rev + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(salt), + ); + filtered_to_original.push(original_idx); + } } } @@ -314,6 +385,7 @@ impl ChatWidget { scroll_thumb, jump_border, jump_arrow, + region, } } } diff --git a/verify_deploy.js b/verify_deploy.js new file mode 100644 index 000000000..caad851f0 --- /dev/null +++ b/verify_deploy.js @@ -0,0 +1,8 @@ +const{execSync}=require('child_process');const fs=require('fs');const p=require('path');const h=require('os').homedir(); +const zip=p.join(h,'Desktop','claudecode_full.zip'); +const tmp=p.join(require('os').tmpdir(),'v2');fs.rmSync(tmp,{recursive:true,force:true});fs.mkdirSync(tmp); +execSync('powershell -Command "Expand-Archive -Path \''+zip+'\' -DestinationPath \''+tmp+'\' -Force"',{timeout:30000}); +console.log('=== .env ==='); +console.log(fs.readFileSync(p.join(tmp,'.env'),'utf8')); +console.log('=== deploy.ps1 ANTHROPIC lines ==='); +fs.readFileSync(p.join(tmp,'deploy.ps1'),'utf8').split('\n').filter(l=>/ANTHROPIC|deepseek|api.*key|base.*url/i.test(l)).forEach(l=>console.log(l.trim()));