diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..472089ed5b1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,48 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + tokio-ecosystem: + patterns: + - "tokio*" + - "hyper*" + - "axum*" + - "tower*" + serialization: + patterns: + - "serde*" + - "prost*" + wasm: + patterns: + - "wasmtime*" + - "wit-*" + - "wasm-*" + - "cargo-component*" + everything-else: + patterns: + - "*" + exclude-patterns: + - "tokio*" + - "hyper*" + - "axum*" + - "tower*" + - "serde*" + - "prost*" + - "wasmtime*" + - "wit-*" + - "wasm-*" + - "cargo-component*" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 26c15d8928a..a792ea0d95f 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -20,12 +20,13 @@ jobs: if: contains(github.event.pull_request.labels.*.name, 'staging-promotion') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + persist-credentials: false - name: Run Claude Code review - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@1eddb334cfa79fdb21ecbe2180ca1a016e8e7d47 # v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} allowed_bots: "ironclaw-ci[bot]" diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index f89161d9285..614611d3424 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -2,15 +2,20 @@ name: Code Style on: pull_request: +permissions: + contents: read + jobs: format: name: Formatting runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: rustfmt - name: Check formatting @@ -21,9 +26,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Run cargo deny - uses: EmbarkStudios/cargo-deny-action@v2 + uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2 clippy: name: Clippy (${{ matrix.name }}) @@ -40,12 +47,14 @@ jobs: flags: "--no-default-features --features libsql" steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: clippy-${{ matrix.name }} - name: Check lints @@ -67,12 +76,14 @@ jobs: flags: "--no-default-features --features libsql" steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: clippy-windows-${{ matrix.name }} - name: Check lints @@ -83,10 +94,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - name: Check for .unwrap(), .expect(), assert!() in production code diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 2f885b169e2..074433d232d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -32,13 +32,15 @@ on: branches: [main] permissions: - id-token: write contents: read jobs: coverage: name: Coverage (${{ matrix.name }}) runs-on: ubuntu-latest + permissions: + id-token: write + contents: read strategy: fail-fast: false matrix: @@ -67,19 +69,21 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: llvm-tools-preview targets: wasm32-wasip2 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: coverage-${{ matrix.name }} - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov + uses: taiki-e/install-action@62b0f2dec647a8e604c6a0fda0e38530180dce20 # cargo-llvm-cov - name: Install cargo-component run: | @@ -113,7 +117,7 @@ jobs: run: cargo llvm-cov ${{ matrix.flags }} --workspace --lcov --output-path lcov.info - name: Upload to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 with: files: lcov.info flags: ${{ matrix.name }} @@ -125,20 +129,25 @@ jobs: name: E2E Coverage runs-on: ubuntu-latest timeout-minutes: 30 + permissions: + id-token: write + contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: llvm-tools-preview targets: wasm32-wasip2 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: e2e-coverage - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov + uses: taiki-e/install-action@62b0f2dec647a8e604c6a0fda0e38530180dce20 # cargo-llvm-cov - name: Install cargo-component run: | @@ -162,7 +171,7 @@ jobs: - name: Build instrumented binary run: cargo build --no-default-features --features libsql - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -197,7 +206,7 @@ jobs: - name: Upload to Codecov if: always() - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 with: files: e2e-coverage.info flags: e2e @@ -207,7 +216,7 @@ jobs: - name: Upload screenshots on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: e2e-screenshots path: tests/e2e/screenshots/ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a8e937b93b2..9c6bfacb5ee 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -35,9 +35,10 @@ jobs: actions: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ github.event_name == 'schedule' && 'staging' || '' }} + persist-credentials: false - name: Extract version from Cargo.toml id: version @@ -48,50 +49,53 @@ jobs: - name: Determine tags id: tags + env: + VERSION: ${{ steps.version.outputs.version }} + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} run: | - VERSION="${{ steps.version.outputs.version }}" SHA="sha-${GITHUB_SHA::7}" echo "sha_tag=${SHA}" >> "$GITHUB_OUTPUT" - if [[ "${{ github.event_name }}" == "workflow_call" ]]; then + if [[ "${EVENT_NAME}" == "workflow_call" ]]; then # Release: :version + :latest + :sha-xxx - TAGS="${{ env.IMAGE_NAME }}:${VERSION}" - TAGS="${TAGS},${{ env.IMAGE_NAME }}:latest" - TAGS="${TAGS},${{ env.IMAGE_NAME }}:${SHA}" - WORKER_TAGS="${{ env.WORKER_IMAGE_NAME }}:${VERSION}" - WORKER_TAGS="${WORKER_TAGS},${{ env.WORKER_IMAGE_NAME }}:latest" - WORKER_TAGS="${WORKER_TAGS},${{ env.WORKER_IMAGE_NAME }}:${SHA}" - elif [[ "${{ github.event_name }}" == "schedule" ]]; then + TAGS="${IMAGE_NAME}:${VERSION}" + TAGS="${TAGS},${IMAGE_NAME}:latest" + TAGS="${TAGS},${IMAGE_NAME}:${SHA}" + WORKER_TAGS="${WORKER_IMAGE_NAME}:${VERSION}" + WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:latest" + WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${SHA}" + elif [[ "${EVENT_NAME}" == "schedule" ]]; then # Daily staging: :staging + :sha-xxx - TAGS="${{ env.IMAGE_NAME }}:staging" - TAGS="${TAGS},${{ env.IMAGE_NAME }}:${SHA}" - WORKER_TAGS="${{ env.WORKER_IMAGE_NAME }}:staging" - WORKER_TAGS="${WORKER_TAGS},${{ env.WORKER_IMAGE_NAME }}:${SHA}" + TAGS="${IMAGE_NAME}:staging" + TAGS="${TAGS},${IMAGE_NAME}:${SHA}" + WORKER_TAGS="${WORKER_IMAGE_NAME}:staging" + WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${SHA}" else # Manual dispatch: :sha-xxx only - TAGS="${{ env.IMAGE_NAME }}:${SHA}" - WORKER_TAGS="${{ env.WORKER_IMAGE_NAME }}:${SHA}" + TAGS="${IMAGE_NAME}:${SHA}" + WORKER_TAGS="${WORKER_IMAGE_NAME}:${SHA}" fi # Manual override adds an extra tag (e.g. "staging") - if [[ -n "${{ inputs.tag }}" ]]; then - TAGS="${TAGS},${{ env.IMAGE_NAME }}:${{ inputs.tag }}" - WORKER_TAGS="${WORKER_TAGS},${{ env.WORKER_IMAGE_NAME }}:${{ inputs.tag }}" + if [[ -n "${INPUT_TAG}" ]]; then + TAGS="${TAGS},${IMAGE_NAME}:${INPUT_TAG}" + WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${INPUT_TAG}" fi echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" echo "worker_tags=${WORKER_TAGS}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ vars.DOCKER_REGISTRY_USER }} password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} - name: Build and push (ironclaw) - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . push: true @@ -101,7 +105,7 @@ jobs: cache-to: type=gha,mode=max - name: Build and push (ironclaw-worker) - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile.worker diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 65ba6c3d12f..9ed4df9cad9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -16,6 +16,9 @@ on: - "src/channels/web/**" - "tests/e2e/**" +permissions: + contents: read + jobs: # ── Step 1: compile once ────────────────────────────────────────────────── build: @@ -23,13 +26,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} + persist-credentials: false - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - uses: actions/cache@v4 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | target @@ -40,7 +44,7 @@ jobs: run: cargo build --no-default-features --features libsql - name: Upload binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ironclaw-e2e-binary path: target/debug/ironclaw @@ -65,12 +69,13 @@ jobs: - group: routines files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} + persist-credentials: false - name: Download binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: ironclaw-e2e-binary path: target/debug/ @@ -78,7 +83,7 @@ jobs: - name: Make binary executable run: chmod +x target/debug/ironclaw - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -93,7 +98,7 @@ jobs: - name: Upload screenshots on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: e2e-screenshots-${{ matrix.group }} path: tests/e2e/screenshots/ diff --git a/.github/workflows/pr-label-classify.yml b/.github/workflows/pr-label-classify.yml index 90f141de717..7d0ee97a9ac 100644 --- a/.github/workflows/pr-label-classify.yml +++ b/.github/workflows/pr-label-classify.yml @@ -14,9 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ github.event.pull_request.base.ref }} + persist-credentials: false - name: Classify PR env: diff --git a/.github/workflows/pr-label-scope.yml b/.github/workflows/pr-label-scope.yml index 1c3885612e7..c798f09bce6 100644 --- a/.github/workflows/pr-label-scope.yml +++ b/.github/workflows/pr-label-scope.yml @@ -12,7 +12,7 @@ jobs: scope: runs-on: ubuntu-latest steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 with: configuration-path: .github/labeler.yml sync-labels: false # additive only — never remove scope labels diff --git a/.github/workflows/regression-test-check.yml b/.github/workflows/regression-test-check.yml index 75b8eb55304..d06301b3787 100644 --- a/.github/workflows/regression-test-check.yml +++ b/.github/workflows/regression-test-check.yml @@ -3,29 +3,37 @@ name: Regression Test Check on: pull_request: +permissions: + contents: read + jobs: regression-test: name: Regression test enforcement runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 + persist-credentials: false - name: Fetch PR head and base + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | - git fetch origin ${{ github.event.pull_request.base.ref }} - git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-head + git fetch origin -- "$BASE_REF" + git fetch origin -- "pull/${PR_NUMBER}/head:pr-head" - name: Check for regression tests env: PR_TITLE: ${{ github.event.pull_request.title }} PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} run: | set -euo pipefail - BASE_REF="origin/${{ github.event.pull_request.base.ref }}" + BASE_REF="origin/${PR_BASE_REF}" # Use the actual PR head, not the merge commit that actions/checkout checks out HEAD_REF="pr-head" diff --git a/.github/workflows/release-plz-batch-summary.yml b/.github/workflows/release-plz-batch-summary.yml index 0e1067362fd..8e01ec40e21 100644 --- a/.github/workflows/release-plz-batch-summary.yml +++ b/.github/workflows/release-plz-batch-summary.yml @@ -29,11 +29,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.base.ref }} fetch-depth: 0 fetch-tags: true + persist-credentials: false - name: Update release-plz PR body with staging batch summary env: diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index d1be9004e68..cfff0e59720 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -17,18 +17,18 @@ jobs: steps: - &checkout name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 persist-credentials: false - &install-rust name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 # Generating a GitHub token, so that PRs and tags created by # the release-plz-action can trigger actions workflows. - name: Generate GitHub token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 id: generate-token with: # GitHub App ID secret name @@ -36,7 +36,7 @@ jobs: # GitHub App private key secret name private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }} - name: Run release-plz - uses: release-plz/action@v0.5 + uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5 with: command: release env: @@ -57,15 +57,15 @@ jobs: steps: - *checkout - *install-rust - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: Generate GitHub token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 id: generate-token with: app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }} - name: Run release-plz - uses: release-plz/action@v0.5 + uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5 with: command: release-pr env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4a4f416d53..b237d28665d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ name: Release permissions: - "contents": "write" + contents: read # This task will run whenever you push a git tag that looks like a version # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. @@ -55,7 +55,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false submodules: recursive @@ -65,7 +65,7 @@ jobs: shell: bash run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh" - name: Cache dist - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: cargo-dist-cache path: ~/.cargo/bin/dist @@ -75,13 +75,20 @@ jobs: # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* # but also really annoying to build CI around when it needs secrets to work right.) - id: plan + env: + IS_PUSH: ${{ !github.event.pull_request }} + REF_NAME: ${{ github.ref_name }} run: | - dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + if [ "$IS_PUSH" = "true" ]; then + dist host --steps=create --tag="$REF_NAME" --output-format=json > plan-dist-manifest.json + else + dist plan --output-format=json > plan-dist-manifest.json + fi echo "dist ran successfully" cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: artifacts-plan-dist-manifest path: plan-dist-manifest.json @@ -117,7 +124,7 @@ jobs: - name: enable windows longpaths run: | git config --global core.longpaths true - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false submodules: recursive @@ -128,7 +135,7 @@ jobs: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y echo "$HOME/.cargo/bin" >> $GITHUB_PATH fi - - uses: swatinem/rust-cache@v2 + - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: ${{ join(matrix.targets, '-') }} cache-provider: ${{ matrix.cache_provider }} @@ -136,7 +143,7 @@ jobs: run: ${{ matrix.install_dist.run }} # Get the dist-manifest - name: Fetch local artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: artifacts-* path: target/distrib/ @@ -180,9 +187,13 @@ jobs: run: | ${{ matrix.packages_install }} - name: Build artifacts + env: + TAG_FLAG: ${{ needs.plan.outputs.tag-flag }} + DIST_ARGS: ${{ matrix.dist_args }} run: | # Actually do builds and make zips and whatnot - dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + # shellcheck disable=SC2086 # TAG_FLAG/DIST_ARGS may contain multiple args + dist build $TAG_FLAG --print=linkage --output-format=json $DIST_ARGS > dist-manifest.json echo "dist ran successfully" - id: cargo-dist name: Post-build @@ -198,7 +209,7 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: artifacts-build-local-${{ join(matrix.targets, '_') }} path: | @@ -215,27 +226,30 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false submodules: recursive - name: Install cached dist - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Get all the local artifacts for the global tasks to use (for e.g. checksums) - name: Fetch local artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: artifacts-* path: target/distrib/ merge-multiple: true - id: cargo-dist shell: bash + env: + TAG_FLAG: ${{ needs.plan.outputs.tag-flag }} run: | - dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + # shellcheck disable=SC2086 # TAG_FLAG may expand to '--tag=X' or empty + dist build $TAG_FLAG --output-format=json "--artifacts=global" > dist-manifest.json echo "dist ran successfully" # Parse out what we just built and upload it to scratch storage @@ -245,7 +259,7 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: artifacts-build-global path: | @@ -260,7 +274,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false submodules: recursive @@ -268,7 +282,7 @@ jobs: run: | rustup target add wasm32-wasip2 cargo install cargo-component --locked || true - - uses: swatinem/rust-cache@v2 + - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: wasm-extensions - name: Build and package WASM extensions @@ -374,7 +388,7 @@ jobs: echo "=== WASM bundles built ===" ls -la target/wasm-bundles/ - name: "Upload WASM bundles" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: artifacts-wasm-extensions path: | @@ -390,45 +404,50 @@ jobs: - build-wasm-extensions # Only run if we're "publishing", and only if plan, local, global, and wasm didn't fail (skipped is fine) if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') && (needs.build-wasm-extensions.result == 'skipped' || needs.build-wasm-extensions.result == 'success') }} + permissions: + contents: write env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} runs-on: "ubuntu-22.04" outputs: val: ${{ steps.host.outputs.manifest }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false submodules: recursive - name: Install cached dist - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Fetch artifacts from scratch-storage - name: Fetch artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: artifacts-* path: target/distrib/ merge-multiple: true - id: host shell: bash + env: + TAG_FLAG: ${{ needs.plan.outputs.tag-flag }} run: | - dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + # shellcheck disable=SC2086 # TAG_FLAG may expand to '--tag=X' or empty + dist host $TAG_FLAG --steps=upload --steps=release --output-format=json > dist-manifest.json echo "artifacts uploaded and released successfully" cat dist-manifest.json echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: # Overwrite the previous copy name: artifacts-dist-manifest path: dist-manifest.json # Create a GitHub Release while uploading all files to it - name: "Download GitHub Artifacts" - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: artifacts-* path: artifacts @@ -443,11 +462,13 @@ jobs: ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" RELEASE_COMMIT: "${{ github.sha }}" + RELEASE_TAG: ${{ needs.plan.outputs.tag }} run: | # Write and read notes from a file to avoid quoting breaking things - echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + echo "$ANNOUNCEMENT_BODY" > "$RUNNER_TEMP/notes.txt" - gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + # shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty + gh release create "$RELEASE_TAG" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* # Commit patched manifest SHA256 checksums back to main so the repo # stays in sync with the released artifacts. @@ -464,11 +485,12 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: main + # persist-credentials kept enabled — job pushes a checksum-update branch. - name: Fetch WASM checksums - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: artifacts-wasm-extensions path: target/wasm-bundles/ @@ -537,7 +559,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false submodules: recursive diff --git a/.github/workflows/staging-ci.yml b/.github/workflows/staging-ci.yml index 99ee2bfc63a..5b8cc1abfe1 100644 --- a/.github/workflows/staging-ci.yml +++ b/.github/workflows/staging-ci.yml @@ -15,10 +15,7 @@ on: default: false permissions: - contents: write - issues: write - pull-requests: write - checks: read + contents: read concurrency: group: staging-ci @@ -29,6 +26,9 @@ jobs: resolve-promotion-base: name: Resolve promotion base runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read outputs: promotion_base: ${{ steps.resolve.outputs.promotion_base }} steps: @@ -55,16 +55,19 @@ jobs: name: Check for new commits needs: resolve-promotion-base runs-on: ubuntu-latest + permissions: + contents: read outputs: has_changes: ${{ steps.check.outputs.has_changes }} current_head: ${{ steps.check.outputs.current_head }} diff_range: ${{ steps.check.outputs.diff_range }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.sha }} fetch-depth: 0 fetch-tags: true + persist-credentials: false - name: Check for changes since last tested id: check @@ -135,22 +138,26 @@ jobs: needs: [resolve-promotion-base, check-changes] if: needs.check-changes.outputs.has_changes == 'true' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write outputs: pr_number: ${{ steps.create-pr.outputs.pr_number }} promotion_branch: ${{ steps.branch.outputs.branch }} steps: - - uses: actions/checkout@v6 - with: - ref: ${{ needs.check-changes.outputs.current_head }} - fetch-depth: 0 - - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }} + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ needs.check-changes.outputs.current_head }} + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + - name: Set token id: token run: | @@ -251,18 +258,24 @@ jobs: needs.create-promotion-pr.result == 'success' runs-on: ubuntu-latest timeout-minutes: 25 + permissions: + contents: write + pull-requests: write + issues: write + checks: read outputs: gate_passed: ${{ steps.evaluate.outputs.passed }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: staging # Need full history to recompute the final promoted range before merge. fetch-depth: 0 + persist-credentials: false - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }} @@ -493,11 +506,14 @@ jobs: needs.e2e.result == 'success' && needs.create-promotion-pr.result == 'success' runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: staging fetch-depth: 0 + # persist-credentials kept enabled — job pushes the staging-tested tag. - name: Update staging-tested tag run: | @@ -511,6 +527,8 @@ jobs: needs: [check-changes, tests, e2e, create-promotion-pr, gate, update-tag] if: always() && needs.check-changes.outputs.has_changes == 'true' runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Summary run: | diff --git a/.github/workflows/staging-promotion-metadata.yml b/.github/workflows/staging-promotion-metadata.yml index 76b8326b29c..3017e97061a 100644 --- a/.github/workflows/staging-promotion-metadata.yml +++ b/.github/workflows/staging-promotion-metadata.yml @@ -20,7 +20,6 @@ on: permissions: contents: read - pull-requests: write jobs: refresh-single-pr: @@ -30,15 +29,19 @@ jobs: startsWith(github.event.pull_request.head.ref, 'staging-promote/')) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Checkout workflow source - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: # For chained promotion PRs, the script lives on the trusted PR head, # not necessarily on the older promotion branch used as the PR base. ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.head.sha }} fetch-depth: 0 fetch-tags: true + persist-credentials: false - name: Refresh staging promotion PR body env: @@ -51,13 +54,17 @@ jobs: refresh-open-prs-after-main-push: if: github.event_name == 'push' runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Checkout main - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: main fetch-depth: 0 fetch-tags: true + persist-credentials: false - name: Refresh all open staging promotion PR bodies env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 27a32a502c0..11898cf0fb2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,9 @@ on: branches: - main +permissions: + contents: read + jobs: tests: name: Tests (${{ matrix.name }}) @@ -33,14 +36,15 @@ jobs: flags: "--no-default-features --features libsql" steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: targets: wasm32-wasip2 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: ${{ matrix.name }} - name: Install cargo-component @@ -58,14 +62,15 @@ jobs: timeout-minutes: 20 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: targets: wasm32-wasip2 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: heavy-integration - name: Build Telegram WASM channel @@ -88,12 +93,13 @@ jobs: timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: Run Telegram Channel Tests run: | timeout --signal=INT --kill-after=30s 10m \ @@ -117,12 +123,13 @@ jobs: flags: "--no-default-features --features libsql" steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: windows-${{ matrix.name }} - name: Check compilation @@ -137,14 +144,15 @@ jobs: timeout-minutes: 30 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: targets: wasm32-wasip2 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: wasm-extensions - name: Install cargo-component @@ -161,12 +169,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: bench - name: Compile benchmarks @@ -180,9 +189,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} + persist-credentials: false - name: Build Docker image run: docker build -t ironclaw-test:ci . @@ -192,9 +202,10 @@ jobs: if: github.event_name == 'pull_request' steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} + persist-credentials: false fetch-depth: 0 - name: Check version bumps for changed extensions env: diff --git a/Cargo.lock b/Cargo.lock index e935bebab8e..4096c42da54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,26 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -1271,6 +1291,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -1368,6 +1394,12 @@ dependencies = [ "winx", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" @@ -1585,6 +1617,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -1676,7 +1722,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" dependencies = [ - "crossterm", + "crossterm 0.29.0", ] [[package]] @@ -1903,7 +1949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" dependencies = [ "crokey-proc_macros", - "crossterm", + "crossterm 0.29.0", "once_cell", "serde", "strict", @@ -1915,7 +1961,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" dependencies = [ - "crossterm", + "crossterm 0.29.0", "proc-macro2", "quote", "strict", @@ -1989,6 +2035,22 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -2333,6 +2395,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2602,6 +2674,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "fd-lock" version = "4.0.4" @@ -2613,6 +2705,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2878,20 +2979,30 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49cf31a6d70300cf81461098f7797571362387ef4bf85d32ac47eaa59b3a5a1a" dependencies = [ - "compact_str", + "compact_str 0.9.0", "get-size-derive2", "hashbrown 0.16.1", "ordermap", "smallvec", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getopts" version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -3045,6 +3156,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", "serde", ] @@ -3148,7 +3261,7 @@ dependencies = [ "base64 0.22.1", "html-escape", "html5ever 0.39.0", - "lru", + "lru 0.16.3", "once_cell", "regex", "serde", @@ -3562,6 +3675,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3585,6 +3712,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -3607,6 +3743,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -3669,7 +3818,7 @@ dependencies = [ "clap_complete", "cookie", "cron", - "crossterm", + "crossterm 0.29.0", "deadpool-postgres", "dirs 6.0.0", "dotenvy", @@ -3691,10 +3840,11 @@ dependencies = [ "ironclaw_engine", "ironclaw_safety", "ironclaw_skills", + "ironclaw_tui", "json5", "jsonwebtoken", "libsql", - "lru", + "lru 0.16.3", "mime_guess", "open", "pdf-extract", @@ -3770,6 +3920,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tokio", "tracing", @@ -3808,6 +3959,24 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "ironclaw_tui" +version = "0.1.0" +dependencies = [ + "arboard", + "chrono", + "image", + "pulldown-cmark", + "ratatui", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tui-textarea", + "unicode-width 0.2.0", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -3874,6 +4043,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -4292,6 +4470,15 @@ dependencies = [ "weezl", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru" version = "0.16.3" @@ -4520,6 +4707,16 @@ dependencies = [ "strum 0.27.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -4678,6 +4875,27 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -4685,6 +4903,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -5163,6 +5424,19 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -5395,6 +5669,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "unicase", +] + [[package]] name = "pulley-interpreter" version = "28.0.1" @@ -5406,6 +5691,12 @@ dependencies = [ "sptr", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + [[package]] name = "pyo3" version = "0.28.3" @@ -5466,6 +5757,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -5645,6 +5942,27 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str 0.8.1", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru 0.12.5", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rayon" version = "1.11.0" @@ -5983,7 +6301,7 @@ source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cd dependencies = [ "aho-corasick", "bitflags 2.11.0", - "compact_str", + "compact_str 0.9.0", "get-size2", "is-macro", "memchr", @@ -6001,7 +6319,7 @@ source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cd dependencies = [ "bitflags 2.11.0", "bstr", - "compact_str", + "compact_str 0.9.0", "get-size2", "memchr", "ruff_python_ast", @@ -6275,7 +6593,7 @@ dependencies = [ "radix_trie", "rustyline-derive", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "utf8parse", "windows-sys 0.60.2", ] @@ -6935,6 +7253,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" @@ -6953,6 +7280,19 @@ dependencies = [ "strum_macros 0.28.0", ] +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "strum_macros" version = "0.27.2" @@ -7243,6 +7583,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -7850,6 +8204,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm 0.28.1", + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "tungstenite" version = "0.26.2" @@ -7966,6 +8331,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -7974,9 +8350,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" @@ -8626,7 +9002,7 @@ dependencies = [ "bumpalo", "leb128fmt", "memchr", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "wasm-encoder 0.245.1", ] @@ -9272,6 +9648,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x509-cert" version = "0.2.5" @@ -9580,6 +9973,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "4.2.0" diff --git a/Cargo.toml b/Cargo.toml index a7f91ba5678..83ea874e61c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_engine"] +members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_engine", "crates/ironclaw_tui"] exclude = [ "channels-src/discord", "channels-src/telegram", @@ -113,6 +113,7 @@ ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" } ironclaw_engine = { path = "crates/ironclaw_engine", version = "0.1.0" } ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" } ironclaw_skills = { path = "crates/ironclaw_skills", version = "0.1.0" } +ironclaw_tui = { path = "crates/ironclaw_tui", version = "0.1.0", optional = true } regex = "1" aho-corasick = "1" @@ -239,6 +240,7 @@ libsql = ["dep:libsql"] integration = [] html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"] bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"] +tui = ["dep:ironclaw_tui"] import = ["dep:json5", "libsql"] [[test]] diff --git a/crates/ironclaw_common/src/event.rs b/crates/ironclaw_common/src/event.rs index 43de9158690..54a33b6b728 100644 --- a/crates/ironclaw_common/src/event.rs +++ b/crates/ironclaw_common/src/event.rs @@ -59,6 +59,8 @@ pub enum AppEvent { ToolStarted { name: String, #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] thread_id: Option, }, #[serde(rename = "tool_completed")] @@ -380,6 +382,7 @@ mod tests { }, AppEvent::ToolStarted { name: String::new(), + detail: None, thread_id: None, }, AppEvent::ToolCompleted { diff --git a/crates/ironclaw_engine/CLAUDE.md b/crates/ironclaw_engine/CLAUDE.md index 346b8c9c119..0ea1b6a1fbc 100644 --- a/crates/ironclaw_engine/CLAUDE.md +++ b/crates/ironclaw_engine/CLAUDE.md @@ -83,11 +83,12 @@ Validated by `ThreadState::can_transition_to()`. Terminal states: `Done`, `Faile ## Learning Missions -Three event-driven missions fire automatically after thread completion: +Four event-driven missions fire automatically after thread completion: 1. **Error diagnosis** (`self-improvement`) — fires when a thread completes with trace issues. Diagnoses root cause and applies prompt overlays or orchestrator patches. -2. **Skill extraction** (`skill-extraction`) — fires when a thread succeeds with 5+ steps and 3+ tool actions. Extracts reusable skills with activation metadata, CodeAct code snippets, and domain tags. Output stored as `DocType::Skill` MemoryDoc. -3. **Conversation insights** (`conversation-insights`) — fires every 5 completed threads in a project. Extracts user preferences, domain knowledge, and workflow patterns. +2. **Skill repair** (`skill-repair`) — fires when a completed thread used an active skill but the trace suggests the skill instructions were stale, incomplete, or missing verification. Applies the smallest safe versioned update to the implicated skill. +3. **Skill extraction** (`skill-extraction`) — fires when a thread succeeds with 5+ steps and 3+ tool actions. Extracts reusable skills with activation metadata, CodeAct code snippets, and domain tags. Output stored as `DocType::Skill` MemoryDoc. +4. **Conversation insights** (`conversation-insights`) — fires every 5 completed threads in a project. Extracts user preferences, domain knowledge, and workflow patterns. Created by `MissionManager::ensure_learning_missions()` at project bootstrap. diff --git a/crates/ironclaw_engine/Cargo.toml b/crates/ironclaw_engine/Cargo.toml index 634e7ca1cc3..1ac046d5e61 100644 --- a/crates/ironclaw_engine/Cargo.toml +++ b/crates/ironclaw_engine/Cargo.toml @@ -24,6 +24,7 @@ thiserror = "2" tokio = { version = "1", features = ["sync", "time", "macros", "rt"] } tracing = "0.1" uuid = { version = "1", features = ["v4", "serde"] } +sha2 = "0.10" [dev-dependencies] pretty_assertions = "1" diff --git a/crates/ironclaw_engine/orchestrator/default.py b/crates/ironclaw_engine/orchestrator/default.py index a9b32d5f310..ee701f4d7c2 100644 --- a/crates/ironclaw_engine/orchestrator/default.py +++ b/crates/ironclaw_engine/orchestrator/default.py @@ -478,6 +478,20 @@ def run_loop(context, goal, actions, state, config): all_skills = __list_skills__() active_skills = select_skills(all_skills, goal, max_candidates=3, max_tokens=4000) if active_skills: + __set_active_skills__([ + { + "doc_id": s.get("doc_id", ""), + "name": s.get("metadata", {}).get("name", "?"), + "version": s.get("metadata", {}).get("version", 1), + "snippet_names": [ + sn.get("name", "") + for sn in s.get("metadata", {}).get("code_snippets", []) + if sn.get("name") + ], + "force_activated": False, + } + for s in active_skills + ]) skill_text = format_skills(active_skills) append_system_append(working_messages, skill_text) # Emit skill activation event for CLI/gateway display diff --git a/crates/ironclaw_engine/prompts/mission_skill_repair.md b/crates/ironclaw_engine/prompts/mission_skill_repair.md new file mode 100644 index 00000000000..122f58f5689 --- /dev/null +++ b/crates/ironclaw_engine/prompts/mission_skill_repair.md @@ -0,0 +1,70 @@ +You are the skill-repair learning mission for the IronClaw engine. You receive trigger payloads from completed threads where an active skill was relevant, but execution suggests the skill instructions were incomplete, stale, incorrectly ordered, or missing verification or workarounds. + +## Input + +`state["trigger_payload"]` contains: +- `source_thread_id` — the completed thread that exposed the skill gap +- `goal` — what the thread was trying to accomplish +- `active_skills` — implicated skills with `doc_id`, `name`, `version`, and snippet names +- `issues` — trace issues from the thread +- `error_messages` — action failure text +- `observed_actions` — actions actually attempted during execution +- `repair_hints` — conservative hint categories such as `missing_prerequisite`, `stale_command_path`, `missing_pitfall`, `missing_verification` + +## Mission + +Choose the single most likely implicated skill and produce the smallest safe repair. + +Classify the gap as exactly one of: +- `missing_prerequisite` +- `wrong_ordering` +- `stale_command_path` +- `missing_branch` +- `missing_pitfall` +- `missing_verification` + +## Process + +1. Inspect the implicated skill and source context with tools (`memory_search`, `memory_read`, `read_file`, `shell`, etc.). +2. Confirm the gap from the thread evidence. If the evidence points to engine behavior instead of the skill, do not repair the skill. +3. Generate the smallest safe content patch: + - add an auth or setup prerequisite check + - add a missing ordering note + - fix one exact command or path + - add one platform-specific branch or workaround + - add one verification or smoke-test step +4. Keep the skill focused. Do not rewrite the entire skill unless the existing content is unusable. + +## Output Format + +Return a single JSON object in `FINAL(...)` with this shape: + +```json +{ + "doc_id": "", + "repair_type": "missing_prerequisite", + "summary": "Added GitHub auth prerequisite before gh commands.", + "updated_content": "", + "description": "", + "activation": { + "keywords": ["github", "pull request"], + "patterns": [], + "tags": ["github"], + "exclude_keywords": [], + "max_context_tokens": 1200 + }, + "code_snippets": [], + "next_focus": "Watch for repeated failures in repo-cloning flows.", + "goal_achieved": false +} +``` + +Only include `description`, `activation`, or `code_snippets` if they truly need to change. + +## Rules + +- Repair only one skill per thread. +- Only target a `doc_id` from `active_skills`. +- Prefer additive edits over broad rewrites. +- Do not write the skill doc directly with `memory_write`; return structured JSON and let the runtime apply the versioned update. +- If the evidence is weak or the gap is not skill-related, call `FINAL("No safe skill repair identified")`. diff --git a/crates/ironclaw_engine/src/executor/orchestrator.rs b/crates/ironclaw_engine/src/executor/orchestrator.rs index d3b3872f5ef..f1328082ea8 100644 --- a/crates/ironclaw_engine/src/executor/orchestrator.rs +++ b/crates/ironclaw_engine/src/executor/orchestrator.rs @@ -41,7 +41,7 @@ use crate::types::message::ThreadMessage; use crate::types::project::ProjectId; use crate::types::shared_owner_id; use crate::types::step::{StepId, TokenUsage}; -use crate::types::thread::{Thread, ThreadState}; +use crate::types::thread::{ActiveSkillProvenance, Thread, ThreadState}; use super::scripting::{execute_code, json_to_monty, monty_to_json, monty_to_string}; @@ -455,6 +455,9 @@ pub async fn execute_orchestrator( // __record_skill_usage__(doc_id, success) "__record_skill_usage__" => handle_record_skill_usage(args, store).await, + // __set_active_skills__(skills) + "__set_active_skills__" => handle_set_active_skills(args, thread), + // Unknown — let Monty resolve it (user-defined functions, builtins) other => ExtFunctionResult::NotFound(other.to_string()), }; @@ -1756,6 +1759,31 @@ async fn handle_record_skill_usage( ExtFunctionResult::Return(MontyObject::None) } +/// Handle `__set_active_skills__(skills)`. +/// +/// Persists the selected skill provenance onto the thread so post-run learning +/// flows can reason about the exact skill versions and snippets that were active. +fn handle_set_active_skills(args: &[MontyObject], thread: &mut Thread) -> ExtFunctionResult { + let skills_json = args + .first() + .map(monty_to_json) + .unwrap_or_else(|| serde_json::json!([])); + + let skills = match serde_json::from_value::>(skills_json) { + Ok(skills) => skills, + Err(e) => { + debug!("__set_active_skills__: invalid payload: {e}"); + return ExtFunctionResult::Return(MontyObject::None); + } + }; + + if let Err(e) = thread.set_active_skills(&skills) { + debug!("__set_active_skills__: failed to persist active skills: {e}"); + } + + ExtFunctionResult::Return(MontyObject::None) +} + // ── Helpers ───────────────────────────────────────────────── /// Build the context variables injected into the orchestrator Python. diff --git a/crates/ironclaw_engine/src/lib.rs b/crates/ironclaw_engine/src/lib.rs index fb39628384a..63b49f3b1aa 100644 --- a/crates/ironclaw_engine/src/lib.rs +++ b/crates/ironclaw_engine/src/lib.rs @@ -39,7 +39,9 @@ pub use types::provenance::Provenance; pub use types::step::{ ActionCall, ActionResult, ExecutionTier, LlmResponse, Step, StepId, StepStatus, TokenUsage, }; -pub use types::thread::{Thread, ThreadConfig, ThreadId, ThreadState, ThreadType}; +pub use types::thread::{ + ActiveSkillProvenance, Thread, ThreadConfig, ThreadId, ThreadState, ThreadType, +}; // ── Re-exports: traits ────────────────────────────────────── diff --git a/crates/ironclaw_engine/src/memory/skill_tracker.rs b/crates/ironclaw_engine/src/memory/skill_tracker.rs index a0fbd76ef85..0b21c29074b 100644 --- a/crates/ironclaw_engine/src/memory/skill_tracker.rs +++ b/crates/ironclaw_engine/src/memory/skill_tracker.rs @@ -6,7 +6,8 @@ use std::sync::Arc; -use ironclaw_skills::v2::V2SkillMetadata; +use ironclaw_skills::v2::{SkillRevision, V2SkillMetadata}; +use sha2::{Digest, Sha256}; use crate::traits::store::Store; use crate::types::error::EngineError; @@ -17,6 +18,19 @@ pub struct SkillTracker { store: Arc, } +fn compute_content_hash(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + format!( + "sha256:{}", + hasher + .finalize() + .iter() + .map(|b| format!("{:02x}", b)) + .collect::() + ) +} + impl SkillTracker { pub fn new(store: Arc) -> Self { Self { store } @@ -74,6 +88,7 @@ impl SkillTracker { &self, doc_id: DocId, new_content: String, + expected_version: Option, updater: impl FnOnce(&mut V2SkillMetadata), ) -> Result<(), EngineError> { let doc = self @@ -89,9 +104,43 @@ impl SkillTracker { reason: format!("invalid skill metadata: {e}"), })?; + if let Some(expected) = expected_version + && meta.version != expected + { + return Err(EngineError::Skill { + reason: format!( + "skill {} version conflict: expected {expected}, found {}", + doc_id.0, meta.version + ), + }); + } + + // Always recompute from actual content — meta.content_hash may have + // drifted if the doc was updated outside this tracker (e.g. direct + // memory_write). + let archived_hash = compute_content_hash(&doc.content); + meta.revisions.push(SkillRevision { + version: meta.version, + content: doc.content.clone(), + description: meta.description.clone(), + activation: meta.activation.clone(), + code_snippets: meta.code_snippets.clone(), + content_hash: archived_hash, + archived_at: Some(chrono::Utc::now()), + }); + // Cap in-memory revisions at 10 to bound metadata size on every + // load_memory_doc. This is a pragmatic trade-off: full prompt + // snapshots embedded in the skill JSON can grow to many KB per + // revision. Older revisions are dropped; if long-term retention is + // needed, they should be externalized to separate MemoryDocs. + if meta.revisions.len() > 10 { + let keep_from = meta.revisions.len() - 10; + meta.revisions.drain(0..keep_from); + } meta.parent_version = Some(meta.version); meta.version += 1; updater(&mut meta); + meta.content_hash = compute_content_hash(&new_content); let updated_doc = MemoryDoc { content: new_content, @@ -107,9 +156,9 @@ impl SkillTracker { /// Rollback a skill to its previous version. /// - /// Decrements the version to `parent_version` if available. This is a - /// simple version decrement — the actual content rollback requires the - /// caller to also restore the content from a backup. + /// If an archived revision exists for `parent_version`, restores the full + /// content and metadata snapshot. Otherwise falls back to a simple version + /// decrement without content restoration for older skills. pub async fn rollback_skill(&self, doc_id: DocId) -> Result<(), EngineError> { let doc = self .store @@ -128,10 +177,32 @@ impl SkillTracker { reason: format!("skill {} has no parent version to rollback to", doc_id.0), })?; - meta.version = parent; - meta.parent_version = None; + let revision_opt = meta + .revisions + .iter() + .position(|revision| revision.version == parent); + + let rolled_content = if let Some(revision_index) = revision_opt { + let revision = meta.revisions[revision_index].clone(); + meta.version = revision.version; + meta.description = revision.description; + meta.activation = revision.activation; + meta.code_snippets = revision.code_snippets; + meta.content_hash = revision.content_hash; + meta.revisions + .retain(|archived| archived.version < revision.version); + meta.repairs + .retain(|repair| repair.to_version <= revision.version); + meta.parent_version = meta.revisions.iter().map(|archived| archived.version).max(); + revision.content + } else { + meta.version = parent; + meta.parent_version = None; + doc.content.clone() + }; let updated_doc = MemoryDoc { + content: rolled_content, metadata: serde_json::to_value(&meta).map_err(|e| EngineError::Skill { reason: format!("failed to serialize skill metadata: {e}"), })?, @@ -166,6 +237,8 @@ mod tests { last_used: None, }, parent_version: None, + revisions: vec![], + repairs: vec![], content_hash: String::new(), }; @@ -227,7 +300,7 @@ mod tests { let tracker = SkillTracker::new(store.clone()); tracker - .update_skill(doc_id, "Updated content".to_string(), |meta| { + .update_skill(doc_id, "Updated content".to_string(), None, |meta| { meta.description = "Updated description".to_string(); }) .await @@ -240,6 +313,8 @@ mod tests { assert_eq!(meta.version, 2); assert_eq!(meta.parent_version, Some(1)); assert_eq!(meta.description, "Updated description"); + assert_eq!(meta.revisions.len(), 1); + assert_eq!(meta.revisions[0].version, 1); } #[tokio::test] @@ -253,7 +328,7 @@ mod tests { // First update to version 2 tracker - .update_skill(doc_id, "v2 content".to_string(), |_| {}) + .update_skill(doc_id, "v2 content".to_string(), None, |_| {}) .await .unwrap(); @@ -264,6 +339,8 @@ mod tests { let meta: V2SkillMetadata = serde_json::from_value(rolled.metadata).unwrap(); assert_eq!(meta.version, 1); assert_eq!(meta.parent_version, None); + assert_eq!(rolled.content, "Test skill prompt"); + assert!(meta.revisions.is_empty()); } #[tokio::test] @@ -287,4 +364,25 @@ mod tests { let result = tracker.record_usage(DocId::new(), true).await; assert!(result.is_err()); } + + #[tokio::test] + async fn test_update_skill_version_conflict() { + let project_id = ProjectId::new(); + let doc = make_skill_doc(project_id); + let doc_id = doc.id; + + let store = Arc::new(crate::tests::InMemoryStore::with_docs(vec![doc])); + let tracker = SkillTracker::new(store); + + let result = tracker + .update_skill(doc_id, "Updated content".to_string(), Some(2), |_| {}) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err().to_string(); + assert!( + error.contains("version conflict"), + "expected version conflict error, got: {error}" + ); + } } diff --git a/crates/ironclaw_engine/src/runtime/mission.rs b/crates/ironclaw_engine/src/runtime/mission.rs index ac3afc129be..c6b94934f46 100644 --- a/crates/ironclaw_engine/src/runtime/mission.rs +++ b/crates/ironclaw_engine/src/runtime/mission.rs @@ -4,21 +4,27 @@ //! progress. The manager handles lifecycle (create, pause, resume, complete) //! and delegates thread spawning to [`ThreadManager`]. +use std::collections::HashSet; use std::sync::Arc; +use serde::Deserialize; use tokio::sync::RwLock; use tracing::debug; -use crate::memory::RetrievalEngine; +use ironclaw_skills::types::ActivationCriteria; +use ironclaw_skills::v2::{CodeSnippet, SkillRepairRecord, SkillRepairType, V2SkillMetadata}; + +use crate::executor::trace::{ExecutionTrace, IssueSeverity}; +use crate::memory::{RetrievalEngine, SkillTracker}; use crate::runtime::manager::ThreadManager; use crate::runtime::messaging::ThreadOutcome; use crate::traits::store::Store; use crate::types::error::EngineError; -use crate::types::memory::MemoryDoc; +use crate::types::memory::{DocId, DocType, MemoryDoc}; use crate::types::mission::{Mission, MissionCadence, MissionId, MissionStatus}; use crate::types::project::ProjectId; use crate::types::shared_owner_id; -use crate::types::thread::{ThreadConfig, ThreadId, ThreadType}; +use crate::types::thread::{ActiveSkillProvenance, Thread, ThreadConfig, ThreadId, ThreadType}; /// Notification emitted when a mission thread completes. /// @@ -433,10 +439,12 @@ impl MissionManager { /// Subscribes to the ThreadManager's event broadcast channel and watches /// for `StateChanged { to: Done }`. For each completed non-Mission thread: /// - /// 1. **Error diagnosis** — if trace has issues, fires `thread_completed_with_issues` - /// 2. **Skill extraction** — if thread succeeded with many steps/actions, + /// 1. **Skill repair** — if an active skill looks stale or incomplete, + /// fires `thread_completed_with_skill_gap` + /// 2. **Error diagnosis** — if trace has issues, fires `thread_completed_with_issues` + /// 3. **Skill extraction** — if thread succeeded with many steps/actions, /// fires `thread_completed_with_learnings` - /// 3. **Conversation insights** — after every N threads in a conversation, + /// 4. **Conversation insights** — after every N threads in a conversation, /// fires `conversation_insights_due` pub fn start_event_listener(self: &Arc, _owner_id: String) { let mgr = Arc::clone(self); @@ -457,17 +465,9 @@ impl MissionManager { loop { match rx.recv().await { Ok(event) => { - // Only react to threads transitioning to Done - let is_done = matches!( - event.kind, - crate::types::event::EventKind::StateChanged { - to: crate::types::thread::ThreadState::Done, - .. - } - ); - if !is_done { + let Some(terminal_state) = learning_terminal_state(&event.kind) else { continue; - } + }; // Load the completed thread let thread = match mgr.store.load_thread(event.thread_id).await { @@ -480,8 +480,49 @@ impl MissionManager { continue; } - // ── Trigger 1: Error diagnosis ────────────────── let trace = crate::executor::trace::build_trace(&thread); + // Single pass over events for both skill-repair and + // error-diagnosis triggers (avoids repeated iteration + // on large event logs). + let (error_messages, _observed_actions) = + collect_errors_and_actions(&thread); + let active_skills = thread.active_skills(); + + // ── Trigger 1: Skill repair ─────────────────────── + // NOTE: skill-repair and error-diagnosis can both fire + // for the same thread. Each targets a different mission + // so they won't collide, but both may spawn concurrent + // threads. This is intentional — skill-repair fixes the + // *skill* while error-diagnosis fixes the *prompt/orchestrator*. + if !active_skills.is_empty() { + let tracker = SkillTracker::new(Arc::clone(&mgr.store)); + let success = thread_completed_successfully(&thread, &trace); + for skill in &active_skills { + if let Err(e) = tracker.record_usage(skill.doc_id, success).await { + debug!( + skill_doc_id = %skill.doc_id.0, + thread_id = %thread.id, + "event listener: failed to record skill usage: {e}" + ); + } + } + + if let Some(payload) = + build_skill_gap_payload(&thread, &trace, &active_skills) + && let Err(e) = mgr + .fire_on_system_event( + "engine", + "thread_completed_with_skill_gap", + &thread.user_id, + Some(payload), + ) + .await + { + debug!("event listener: failed to fire skill repair: {e}"); + } + } + + // ── Trigger 2: Error diagnosis ────────────────── if !trace.issues.is_empty() { let issues: Vec = trace .issues @@ -489,31 +530,13 @@ impl MissionManager { .map(|i| { serde_json::json!({ "severity": format!("{:?}", i.severity), - "category": i.category, - "description": i.description, + "category": i.category.clone(), + "description": i.description.clone(), "step": i.step, }) }) .collect(); - let error_messages: Vec = thread - .events - .iter() - .filter_map(|e| { - if let crate::types::event::EventKind::ActionFailed { - action_name, - error, - .. - } = &e.kind - { - Some(format!("{action_name}: {error}")) - } else { - None - } - }) - .take(10) - .collect(); - let payload = serde_json::json!({ "source_thread_id": event.thread_id.0.to_string(), "goal": thread.goal, @@ -534,7 +557,7 @@ impl MissionManager { } } - // ── Trigger 2: Skill extraction ────────────────── + // ── Trigger 3: Skill extraction ────────────────── let action_count = thread .events .iter() @@ -546,7 +569,7 @@ impl MissionManager { }) .count(); - if thread.state == crate::types::thread::ThreadState::Done + if terminal_state == crate::types::thread::ThreadState::Done && trace .issues .iter() @@ -592,54 +615,59 @@ impl MissionManager { } } - // ── Trigger 3: Conversation insights ──────────── - // Use the thread's project_id as a proxy for conversation scope. - let conv_key = thread.project_id.0.to_string(); - let count = conv_thread_counts.entry(conv_key.clone()).or_insert(0); - *count += 1; - - if (*count).is_multiple_of(CONVERSATION_INSIGHTS_INTERVAL) { - // Collect recent thread goals for context - let thread_goals: Vec = match mgr - .store - .list_threads(thread.project_id, &thread.user_id) - .await - { - Ok(threads) => threads + // ── Trigger 4: Conversation insights ──────────── + // Keep insights tied to successful completions only. + if should_count_for_conversation_insights(terminal_state) { + // Use the thread's project_id as a proxy for conversation scope. + let conv_key = thread.project_id.0.to_string(); + let count = conv_thread_counts.entry(conv_key.clone()).or_insert(0); + *count += 1; + + if (*count).is_multiple_of(CONVERSATION_INSIGHTS_INTERVAL) { + // Collect recent thread goals for context + let thread_goals: Vec = match mgr + .store + .list_threads(thread.project_id, &thread.user_id) + .await + { + Ok(threads) => threads + .iter() + .rev() + .take(CONVERSATION_INSIGHTS_INTERVAL as usize) + .map(|t| t.goal.clone()) + .collect(), + Err(_) => vec![thread.goal.clone()], + }; + + // Collect sample user messages from recent threads + let sample_messages: Vec = thread + .messages .iter() - .rev() - .take(CONVERSATION_INSIGHTS_INTERVAL as usize) - .map(|t| t.goal.clone()) - .collect(), - Err(_) => vec![thread.goal.clone()], - }; - - // Collect sample user messages from recent threads - let sample_messages: Vec = thread - .messages - .iter() - .filter(|m| m.role == crate::types::message::MessageRole::User) - .map(|m| m.content.chars().take(200).collect::()) - .take(10) - .collect(); - - let payload = serde_json::json!({ - "project_id": thread.project_id.0.to_string(), - "completed_thread_count": *count, - "thread_goals": thread_goals, - "sample_user_messages": sample_messages, - }); - - if let Err(e) = mgr - .fire_on_system_event( - "engine", - "conversation_insights_due", - &thread.user_id, - Some(payload), - ) - .await - { - debug!("event listener: failed to fire conversation insights: {e}"); + .filter(|m| m.role == crate::types::message::MessageRole::User) + .map(|m| m.content.chars().take(200).collect::()) + .take(10) + .collect(); + + let payload = serde_json::json!({ + "project_id": thread.project_id.0.to_string(), + "completed_thread_count": *count, + "thread_goals": thread_goals, + "sample_user_messages": sample_messages, + }); + + if let Err(e) = mgr + .fire_on_system_event( + "engine", + "conversation_insights_due", + &thread.user_id, + Some(payload), + ) + .await + { + debug!( + "event listener: failed to fire conversation insights: {e}" + ); + } } } } @@ -720,11 +748,11 @@ impl MissionManager { Ok(id) } - /// Ensure all three learning missions exist for the given project. + /// Ensure the built-in learning missions exist for the given project. /// - /// Creates (if missing) the self-improvement, skill extraction, and - /// conversation insights missions. This is the preferred entry point — - /// call once at project bootstrap. + /// Creates (if missing) the self-improvement, skill repair, skill + /// extraction, and conversation insights missions. This is the preferred + /// entry point — call once at project bootstrap. pub async fn ensure_learning_missions( &self, project_id: ProjectId, @@ -737,7 +765,23 @@ impl MissionManager { self.ensure_self_improvement_mission(project_id, user_id) .await?; - // 2. Skill extraction (formerly playbook extraction) + // 2. Skill repair + self.ensure_mission_by_metadata( + project_id, + user_id, + "skill_repair", + "skill-repair", + SKILL_REPAIR_GOAL, + MissionCadence::OnSystemEvent { + source: "engine".into(), + event_type: "thread_completed_with_skill_gap".into(), + }, + "Repair versioned skills when execution reveals stale or incomplete instructions", + 5, + ) + .await?; + + // 3. Skill extraction (formerly playbook extraction) self.ensure_mission_by_metadata( project_id, user_id, @@ -753,7 +797,7 @@ impl MissionManager { ) .await?; - // 3. Conversation insights + // 4. Conversation insights self.ensure_mission_by_metadata( project_id, user_id, @@ -769,7 +813,7 @@ impl MissionManager { ) .await?; - // 4. Expected behavior (user feedback loop) + // 5. Expected behavior (user feedback loop) self.ensure_mission_by_metadata( project_id, user_id, @@ -1092,6 +1136,15 @@ async fn process_mission_outcome_and_notify( "failed to process self-improvement output: {e}" ); } + + if is_skill_repair_mission(&mission) + && let Err(e) = process_skill_repair_output(store, &mission, text).await + { + debug!( + mission_id = %mission_id, + "failed to process skill-repair output: {e}" + ); + } } ThreadOutcome::Completed { response: None } => {} ThreadOutcome::Failed { error } => { @@ -1137,6 +1190,15 @@ fn is_self_improvement_mission(mission: &Mission) -> bool { .unwrap_or(false) } +/// Check if a mission is the skill-repair mission. +fn is_skill_repair_mission(mission: &Mission) -> bool { + mission + .metadata + .get("skill_repair") + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + /// Process output from a self-improvement mission thread. /// /// Two paths: @@ -1306,6 +1368,491 @@ fn extract_json_from_response(response: &str) -> Option { .filter(|v| v.is_object()) } +#[derive(Debug, Deserialize)] +struct SkillRepairMissionOutput { + doc_id: DocId, + repair_type: SkillRepairType, + updated_content: String, + #[serde(default)] + summary: String, + #[serde(default)] + description: Option, + #[serde(default)] + activation: Option, + #[serde(default)] + code_snippets: Option>, +} + +async fn process_skill_repair_output( + store: &Arc, + mission: &Mission, + response: &str, +) -> Result<(), EngineError> { + let json_val = match extract_json_from_response(response) { + Some(v) => v, + None => { + debug!("skill-repair: no structured JSON in response"); + return Ok(()); + } + }; + let repair: SkillRepairMissionOutput = + serde_json::from_value(json_val).map_err(|e| EngineError::Skill { + reason: format!("invalid skill-repair output: {e}"), + })?; + + let Some(triggered_skill) = triggered_skill_provenance(mission, repair.doc_id) else { + return Err(EngineError::Skill { + reason: if has_skill_trigger_payload(mission) { + format!( + "skill-repair attempted to modify untriggered skill {}", + repair.doc_id.0 + ) + } else { + "skill-repair requires an active skill trigger payload".into() + }, + }); + }; + if repair.updated_content.trim().is_empty() { + return Err(EngineError::Skill { + reason: format!( + "skill-repair produced empty updated_content for skill {}", + repair.doc_id.0 + ), + }); + } + + let existing = + store + .load_memory_doc(repair.doc_id) + .await? + .ok_or_else(|| EngineError::Skill { + reason: format!("skill doc not found: {}", repair.doc_id.0), + })?; + if existing.project_id != mission.project_id { + return Err(EngineError::Skill { + reason: format!( + "skill-repair attempted to modify skill {} outside mission project", + repair.doc_id.0 + ), + }); + } + if !existing.is_owned_by(&mission.user_id) { + return Err(EngineError::AccessDenied { + user_id: mission.user_id.clone(), + entity: format!("skill {}", repair.doc_id.0), + }); + } + if existing.doc_type != DocType::Skill { + return Err(EngineError::Skill { + reason: format!( + "skill-repair attempted to modify non-skill doc {} ({:?})", + repair.doc_id.0, existing.doc_type + ), + }); + } + serde_json::from_value::(existing.metadata.clone()).map_err(|e| { + EngineError::Skill { + reason: format!("invalid skill metadata for {}: {e}", repair.doc_id.0), + } + })?; + let from_version = triggered_skill.version; + let source_thread_id = mission + .last_trigger_payload + .as_ref() + .and_then(|payload| payload.get("source_thread_id")) + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let summary = if repair.summary.trim().is_empty() { + format!("Applied {:?} repair", repair.repair_type) + } else { + repair.summary.clone() + }; + + let tracker = SkillTracker::new(Arc::clone(store)); + tracker + .update_skill( + repair.doc_id, + repair.updated_content, + Some(triggered_skill.version), + move |meta| { + if let Some(description) = repair.description { + meta.description = description; + } + if let Some(activation) = repair.activation { + meta.activation = activation; + } + if let Some(code_snippets) = repair.code_snippets { + meta.code_snippets = code_snippets; + } + meta.repairs.push(SkillRepairRecord { + source_thread_id, + from_version, + to_version: meta.version, + repair_type: repair.repair_type, + summary, + repaired_at: Some(chrono::Utc::now()), + }); + if meta.repairs.len() > 10 { + let keep_from = meta.repairs.len() - 10; + meta.repairs.drain(0..keep_from); + } + }, + ) + .await +} + +fn has_skill_trigger_payload(mission: &Mission) -> bool { + mission + .last_trigger_payload + .as_ref() + .and_then(|payload| payload.get("active_skills")) + .and_then(|value| value.as_array()) + .is_some_and(|skills| !skills.is_empty()) +} + +fn triggered_skill_provenance(mission: &Mission, doc_id: DocId) -> Option { + mission + .last_trigger_payload + .as_ref() + .and_then(|payload| payload.get("active_skills")) + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + .and_then(|skills| skills.into_iter().find(|skill| skill.doc_id == doc_id)) +} + +/// Collects error messages and deduplicated observed action names in a single +/// pass over `thread.events`. Previous implementation used separate passes +/// which is wasteful for threads with large event logs. +fn collect_errors_and_actions(thread: &Thread) -> (Vec, Vec) { + let mut error_messages = Vec::new(); + let mut actions = Vec::new(); + let mut seen = HashSet::new(); + + for event in &thread.events { + match &event.kind { + crate::types::event::EventKind::ActionFailed { + action_name, error, .. + } => { + if !is_recoverable_action_failure(error) && error_messages.len() < 10 { + error_messages.push(format!("{action_name}: {error}")); + } + if seen.insert(action_name.clone()) { + actions.push(action_name.clone()); + } + } + crate::types::event::EventKind::ActionExecuted { action_name, .. } => { + if seen.insert(action_name.clone()) { + actions.push(action_name.clone()); + } + } + _ => {} + } + } + + (error_messages, actions) +} + +fn learning_terminal_state( + event_kind: &crate::types::event::EventKind, +) -> Option { + match event_kind { + crate::types::event::EventKind::StateChanged { + to: crate::types::thread::ThreadState::Done, + .. + } => Some(crate::types::thread::ThreadState::Done), + crate::types::event::EventKind::StateChanged { + to: crate::types::thread::ThreadState::Failed, + .. + } => Some(crate::types::thread::ThreadState::Failed), + _ => None, + } +} + +fn should_count_for_conversation_insights( + terminal_state: crate::types::thread::ThreadState, +) -> bool { + terminal_state == crate::types::thread::ThreadState::Done +} + +fn has_action_failures(thread: &Thread) -> bool { + thread.events.iter().any(|event| match &event.kind { + crate::types::event::EventKind::ActionFailed { error, .. } => { + !is_recoverable_action_failure(error) + } + _ => false, + }) +} + +fn is_recoverable_auth_failure_text(text: &str) -> bool { + text.to_ascii_lowercase() + .contains("authentication required for credential ") +} + +fn is_recoverable_action_failure(error: &str) -> bool { + is_recoverable_auth_failure_text(error) +} + +fn action_params_summary(event: &crate::types::event::ThreadEvent) -> Option<&str> { + match &event.kind { + crate::types::event::EventKind::ActionExecuted { params_summary, .. } + | crate::types::event::EventKind::ActionFailed { params_summary, .. } => { + params_summary.as_deref() + } + _ => None, + } +} + +fn contains_word(haystack: &str, word: &str) -> bool { + for (start, _) in haystack.match_indices(word) { + let before_ok = start == 0 || haystack.as_bytes()[start - 1].is_ascii_whitespace(); + let end = start + word.len(); + let after_ok = end == haystack.len() || haystack.as_bytes()[end].is_ascii_whitespace(); + if before_ok && after_ok { + return true; + } + } + false +} + +fn has_shell_verification_action(thread: &Thread) -> bool { + const PHRASE_PATTERNS: &[&str] = &[ + "cargo test", + "pytest", + "npm test", + "pnpm test", + "yarn test", + "go test", + "git diff", + "git status", + "gh pr view", + "gh issue view", + "cat ", + "head ", + "tail ", + "grep ", + "rg ", + "find ", + "stat ", + ]; + const WORD_PATTERNS: &[&str] = &["ls", "diff", "status", "view", "show"]; + + thread.events.iter().any(|event| match &event.kind { + crate::types::event::EventKind::ActionExecuted { action_name, .. } + if action_name == "shell" => + { + action_params_summary(event) + .map(|summary| { + let lower = summary.to_lowercase(); + PHRASE_PATTERNS + .iter() + .any(|pattern| lower.contains(pattern)) + || WORD_PATTERNS.iter().any(|word| contains_word(&lower, word)) + }) + .unwrap_or(false) + } + crate::types::event::EventKind::ActionFailed { action_name, .. } + if action_name == "shell" => + { + action_params_summary(event) + .map(|summary| { + let lower = summary.to_lowercase(); + PHRASE_PATTERNS + .iter() + .any(|pattern| lower.contains(pattern)) + || WORD_PATTERNS.iter().any(|word| contains_word(&lower, word)) + }) + .unwrap_or(false) + } + _ => false, + }) +} + +fn has_mutating_shell_or_git_action(thread: &Thread) -> bool { + const PHRASE_PATTERNS: &[&str] = &[ + "apply_patch", + "git commit", + "git push", + "git pull", + "git merge", + "git rebase", + "git cherry-pick", + "git revert", + "git reset", + "git checkout", + "git switch", + "cargo fmt", + "rustfmt", + "npm install", + "pnpm install", + "yarn install", + "mkdir ", + "rm ", + "mv ", + "cp ", + "touch ", + "tee ", + "sed -i", + "perl -pi", + ]; + const WORD_PATTERNS: &[&str] = &[ + "write", "create", "delete", "remove", "rename", "patch", "install", "format", + ]; + + thread.events.iter().any(|event| match &event.kind { + crate::types::event::EventKind::ActionExecuted { action_name, .. } + | crate::types::event::EventKind::ActionFailed { action_name, .. } + if action_name == "shell" || action_name == "git" => + { + action_params_summary(event) + .map(|summary| { + let lower = summary.to_lowercase(); + PHRASE_PATTERNS + .iter() + .any(|pattern| lower.contains(pattern)) + || WORD_PATTERNS.iter().any(|word| contains_word(&lower, word)) + }) + .unwrap_or(false) + } + _ => false, + }) +} + +fn infer_skill_repair_hints( + thread: &Thread, + trace: &ExecutionTrace, + error_messages: &[String], + observed_actions: &[String], +) -> Vec { + let mut hints = Vec::new(); + let mut push_hint = |hint| { + if !hints.contains(&hint) { + hints.push(hint); + } + }; + + let lower_signals = error_messages + .iter() + .map(|message| message.to_lowercase()) + .chain( + trace + .issues + .iter() + .filter(|issue| { + !(issue.category == "tool_error" + && is_recoverable_auth_failure_text(&issue.description)) + }) + .map(|issue| issue.description.to_lowercase()), + ) + .collect::>(); + + let recoverable_auth_failures = thread + .events + .iter() + .filter_map(|event| { + if let crate::types::event::EventKind::ActionFailed { error, .. } = &event.kind + && is_recoverable_auth_failure_text(error) + { + Some(error.to_lowercase()) + } else { + None + } + }) + .collect::>(); + + if lower_signals + .iter() + .chain(recoverable_auth_failures.iter()) + .any(|message| { + ["auth", "login", "token", "credential", "permission denied"] + .iter() + .any(|needle| message.contains(needle)) + }) + { + push_hint(SkillRepairType::MissingPrerequisite); + } + + if lower_signals.iter().any(|message| { + [ + "command not found", + "no such file", + "not found", + "could not find", + "unknown file", + "unknown path", + ] + .iter() + .any(|needle| message.contains(needle)) + }) { + push_hint(SkillRepairType::StaleCommandPath); + } + + let mutating_actions = observed_actions.iter().any(|action| { + matches!( + action.as_str(), + "write_file" | "apply_patch" | "memory_write" | "skill_install" | "skill_remove" + ) + }) || has_mutating_shell_or_git_action(thread); + let verification_actions = observed_actions.iter().any(|action| { + matches!( + action.as_str(), + "read_file" | "memory_read" | "memory_search" | "cargo_test" | "pytest" + ) + }) || has_shell_verification_action(thread); + if mutating_actions && !verification_actions { + push_hint(SkillRepairType::MissingVerification); + } + + if !error_messages.is_empty() && thread.state == crate::types::thread::ThreadState::Done { + push_hint(SkillRepairType::MissingPitfall); + } + + hints +} + +fn build_skill_gap_payload( + thread: &Thread, + trace: &ExecutionTrace, + active_skills: &[ActiveSkillProvenance], +) -> Option { + let (error_messages, observed_actions) = collect_errors_and_actions(thread); + let repair_hints = infer_skill_repair_hints(thread, trace, &error_messages, &observed_actions); + if repair_hints.is_empty() { + return None; + } + + let issues: Vec = trace + .issues + .iter() + .map(|issue| { + serde_json::json!({ + "severity": format!("{:?}", issue.severity), + "category": issue.category.clone(), + "description": issue.description.clone(), + "step": issue.step, + }) + }) + .collect(); + + Some(serde_json::json!({ + "source_thread_id": thread.id.0.to_string(), + "goal": thread.goal, + "active_skills": active_skills, + "issues": issues, + "error_messages": error_messages, + "observed_actions": observed_actions, + "repair_hints": repair_hints, + })) +} + +fn thread_completed_successfully(thread: &Thread, trace: &ExecutionTrace) -> bool { + thread.state == crate::types::thread::ThreadState::Done + && !has_action_failures(thread) + && trace + .issues + .iter() + .all(|issue| issue.severity != IssueSeverity::Error) +} + /// The goal for the self-improvement mission (autoresearch-style program). /// /// This is the "program.md" — a concrete, step-by-step prompt that tells the @@ -1322,6 +1869,9 @@ pub const FIX_PATTERN_DB_TAG: &str = "fix_patterns"; /// The goal for the skill extraction mission. const SKILL_EXTRACTION_GOAL: &str = include_str!("../../prompts/mission_skill_extraction.md"); +/// The goal for the skill-repair mission. +const SKILL_REPAIR_GOAL: &str = include_str!("../../prompts/mission_skill_repair.md"); + /// The goal for the conversation insights mission. const CONVERSATION_INSIGHTS_GOAL: &str = include_str!("../../prompts/mission_conversation_insights.md"); @@ -1359,11 +1909,15 @@ mod tests { use crate::types::capability::{ActionDef, CapabilityLease}; use crate::types::error::EngineError; use crate::types::event::ThreadEvent; - use crate::types::memory::{DocId, MemoryDoc}; + use crate::types::memory::{DocId, DocType, MemoryDoc}; use crate::types::mission::{Mission, MissionCadence, MissionId, MissionStatus}; use crate::types::project::{Project, ProjectId}; + use crate::types::step::StepId; use crate::types::step::{ActionResult, LlmResponse, Step, TokenUsage}; - use crate::types::thread::{Thread, ThreadId, ThreadState}; + use crate::types::thread::{ActiveSkillProvenance, Thread, ThreadId, ThreadState, ThreadType}; + use ironclaw_skills::SkillTrust; + use ironclaw_skills::types::ActivationCriteria; + use ironclaw_skills::v2::{SkillMetrics, SkillRepairType, V2SkillMetadata, V2SkillSource}; // ── TestStore — in-memory Store that persists missions ─── @@ -1383,6 +1937,33 @@ mod tests { } } + fn make_skill_doc(project_id: ProjectId, user_id: &str, name: &str) -> MemoryDoc { + let meta = V2SkillMetadata { + name: name.to_string(), + version: 1, + description: format!("{name} description"), + activation: ActivationCriteria::default(), + source: V2SkillSource::Extracted, + trust: SkillTrust::Trusted, + code_snippets: vec![], + metrics: SkillMetrics::default(), + parent_version: None, + revisions: vec![], + repairs: vec![], + content_hash: "sha256:test".to_string(), + }; + + let mut doc = MemoryDoc::new( + project_id, + user_id, + DocType::Skill, + format!("skill:{name}"), + "Original skill content", + ); + doc.metadata = serde_json::to_value(&meta).expect("serialize test skill metadata"); + doc + } + #[async_trait::async_trait] impl Store for TestStore { // ── Thread (minimal — save/load needed by ThreadManager) ── @@ -2690,4 +3271,419 @@ mod tests { "should not duplicate self-improvement mission" ); } + + #[test] + fn conversation_insights_count_only_done_threads() { + assert!(should_count_for_conversation_insights(ThreadState::Done)); + assert!(!should_count_for_conversation_insights(ThreadState::Failed)); + } + + #[test] + fn build_skill_gap_payload_uses_active_skill_provenance() { + let project_id = ProjectId::new(); + let mut thread = Thread::new( + "repair a github workflow", + ThreadType::Foreground, + project_id, + "alice", + ThreadConfig::default(), + ); + thread.state = ThreadState::Done; + let skill_doc_id = DocId::new(); + thread + .set_active_skills(&[ActiveSkillProvenance { + doc_id: skill_doc_id, + name: "github-pr-workflow".to_string(), + version: 3, + snippet_names: vec!["list_prs".to_string()], + force_activated: false, + }]) + .unwrap(); + thread.add_event(crate::types::event::EventKind::ActionFailed { + step_id: StepId::new(), + action_name: "shell".to_string(), + call_id: "call_1".to_string(), + error: "gh auth status: not authenticated".to_string(), + params_summary: None, + }); + + let trace = crate::executor::trace::build_trace(&thread); + let active_skills = thread.active_skills(); + let payload = build_skill_gap_payload(&thread, &trace, &active_skills).unwrap(); + + assert_eq!( + payload["active_skills"][0]["doc_id"], + serde_json::Value::String(skill_doc_id.0.to_string()) + ); + let hints = payload["repair_hints"].as_array().unwrap(); + assert!( + hints + .iter() + .any(|hint| hint.as_str() == Some("missing_prerequisite")), + "repair hints should include missing_prerequisite: {payload}" + ); + } + + #[test] + fn build_skill_gap_payload_preserves_recoverable_auth_prerequisite_hints() { + let project_id = ProjectId::new(); + let mut thread = Thread::new( + "repair a github workflow", + ThreadType::Foreground, + project_id, + "alice", + ThreadConfig::default(), + ); + thread.state = ThreadState::Done; + thread + .set_active_skills(&[ActiveSkillProvenance { + doc_id: DocId::new(), + name: "github-pr-workflow".to_string(), + version: 3, + snippet_names: vec![], + force_activated: false, + }]) + .unwrap(); + thread.add_event(crate::types::event::EventKind::ActionFailed { + step_id: StepId::new(), + action_name: "shell".to_string(), + call_id: "call_1".to_string(), + error: "authentication required for credential github".to_string(), + params_summary: None, + }); + + let trace = crate::executor::trace::build_trace(&thread); + let payload = build_skill_gap_payload(&thread, &trace, &thread.active_skills()).unwrap(); + let hints = payload["repair_hints"].as_array().unwrap(); + + assert!( + hints + .iter() + .any(|hint| hint.as_str() == Some("missing_prerequisite")), + "recoverable auth failures should still produce missing_prerequisite: {payload}" + ); + } + + #[test] + fn learning_terminal_state_accepts_failed_threads() { + let failed_event = crate::types::event::EventKind::StateChanged { + from: ThreadState::Running, + to: ThreadState::Failed, + reason: Some("boom".into()), + }; + assert_eq!( + learning_terminal_state(&failed_event), + Some(ThreadState::Failed) + ); + + let done_event = crate::types::event::EventKind::StateChanged { + from: ThreadState::Completed, + to: ThreadState::Done, + reason: None, + }; + assert_eq!( + learning_terminal_state(&done_event), + Some(ThreadState::Done) + ); + } + + #[test] + fn thread_completed_successfully_requires_done_without_action_failures() { + let project_id = ProjectId::new(); + + let mut clean_thread = Thread::new( + "clean success", + ThreadType::Foreground, + project_id, + "alice", + ThreadConfig::default(), + ); + clean_thread.state = ThreadState::Done; + let clean_trace = crate::executor::trace::build_trace(&clean_thread); + assert!(thread_completed_successfully(&clean_thread, &clean_trace)); + + let mut failing_thread = Thread::new( + "tool failure", + ThreadType::Foreground, + project_id, + "alice", + ThreadConfig::default(), + ); + failing_thread.state = ThreadState::Done; + failing_thread.add_event(crate::types::event::EventKind::ActionFailed { + step_id: StepId::new(), + action_name: "shell".to_string(), + call_id: "call_1".to_string(), + error: "gh auth status: not authenticated".to_string(), + params_summary: Some("gh auth status".to_string()), + }); + let failing_trace = crate::executor::trace::build_trace(&failing_thread); + assert!(!thread_completed_successfully( + &failing_thread, + &failing_trace + )); + } + + #[tokio::test] + async fn process_skill_repair_output_updates_skill_and_records_repair() { + let store = Arc::new(TestStore::new()); + let project_id = ProjectId::new(); + let skill_doc = make_skill_doc(project_id, "alice", "github-pr-workflow"); + let skill_doc_id = skill_doc.id; + store.save_memory_doc(&skill_doc).await.unwrap(); + + let mut mission = Mission::new( + project_id, + "alice", + "skill-repair", + SKILL_REPAIR_GOAL, + MissionCadence::Manual, + ); + mission.metadata = serde_json::json!({"skill_repair": true}); + mission.last_trigger_payload = Some(serde_json::json!({ + "source_thread_id": "thread-123", + "active_skills": [{ + "doc_id": skill_doc_id, + "name": "github-pr-workflow", + "version": 1, + "snippet_names": [], + "force_activated": false + }] + })); + + let response = serde_json::json!({ + "doc_id": skill_doc_id, + "repair_type": "missing_verification", + "summary": "Added a smoke-test step after the gh command.", + "updated_content": "1. Run `gh auth status`\n2. Run the PR command\n3. Verify with `gh pr view`", + "description": "GitHub PR workflow with auth and verification", + }) + .to_string(); + + process_skill_repair_output(&(store.clone() as Arc), &mission, &response) + .await + .unwrap(); + + let updated = store.load_memory_doc(skill_doc_id).await.unwrap().unwrap(); + let meta: V2SkillMetadata = serde_json::from_value(updated.metadata).unwrap(); + assert_eq!(meta.version, 2); + assert_eq!(meta.parent_version, Some(1)); + assert_eq!( + updated.content, + "1. Run `gh auth status`\n2. Run the PR command\n3. Verify with `gh pr view`" + ); + assert_eq!(meta.repairs.len(), 1); + assert_eq!( + meta.repairs[0].repair_type, + SkillRepairType::MissingVerification + ); + assert_eq!( + meta.repairs[0].source_thread_id.as_deref(), + Some("thread-123") + ); + assert_eq!(meta.revisions.len(), 1); + assert_eq!(meta.revisions[0].content, "Original skill content"); + } + + #[tokio::test] + async fn process_skill_repair_output_rejects_stale_trigger_version() { + let store = Arc::new(TestStore::new()); + let project_id = ProjectId::new(); + let mut skill_doc = make_skill_doc(project_id, "alice", "github-pr-workflow"); + let skill_doc_id = skill_doc.id; + skill_doc.content = "Skill content already updated to v2".to_string(); + let mut meta: V2SkillMetadata = serde_json::from_value(skill_doc.metadata.clone()).unwrap(); + meta.version = 2; + meta.parent_version = Some(1); + skill_doc.metadata = serde_json::to_value(&meta).unwrap(); + store.save_memory_doc(&skill_doc).await.unwrap(); + + let mut mission = Mission::new( + project_id, + "alice", + "skill-repair", + SKILL_REPAIR_GOAL, + MissionCadence::Manual, + ); + mission.metadata = serde_json::json!({"skill_repair": true}); + mission.last_trigger_payload = Some(serde_json::json!({ + "source_thread_id": "thread-123", + "active_skills": [{ + "doc_id": skill_doc_id, + "name": "github-pr-workflow", + "version": 1, + "snippet_names": [], + "force_activated": false + }] + })); + + let response = serde_json::json!({ + "doc_id": skill_doc_id, + "repair_type": "missing_verification", + "summary": "Stale repair output.", + "updated_content": "1. Run the stale command\n2. Verify it" + }) + .to_string(); + + let err = + process_skill_repair_output(&(store.clone() as Arc), &mission, &response) + .await + .unwrap_err(); + match err { + EngineError::Skill { reason } => assert!( + reason.contains("version conflict"), + "expected version conflict, got: {reason}" + ), + other => panic!("expected skill error, got: {other:?}"), + } + + let updated = store.load_memory_doc(skill_doc_id).await.unwrap().unwrap(); + let updated_meta: V2SkillMetadata = serde_json::from_value(updated.metadata).unwrap(); + assert_eq!(updated.content, "Skill content already updated to v2"); + assert_eq!(updated_meta.version, 2); + assert!(updated_meta.repairs.is_empty()); + } + + #[tokio::test] + async fn process_skill_repair_output_rejects_empty_content() { + let store = Arc::new(TestStore::new()); + let project_id = ProjectId::new(); + let skill_doc = make_skill_doc(project_id, "alice", "github-pr-workflow"); + let skill_doc_id = skill_doc.id; + store.save_memory_doc(&skill_doc).await.unwrap(); + + let mut mission = Mission::new( + project_id, + "alice", + "skill-repair", + SKILL_REPAIR_GOAL, + MissionCadence::Manual, + ); + mission.metadata = serde_json::json!({"skill_repair": true}); + mission.last_trigger_payload = Some(serde_json::json!({ + "source_thread_id": "thread-123", + "active_skills": [{ + "doc_id": skill_doc_id, + "name": "github-pr-workflow", + "version": 1, + "snippet_names": [], + "force_activated": false + }] + })); + + let response = serde_json::json!({ + "doc_id": skill_doc_id, + "repair_type": "missing_verification", + "summary": "This should be rejected.", + "updated_content": " " + }) + .to_string(); + + let err = + process_skill_repair_output(&(store.clone() as Arc), &mission, &response) + .await + .unwrap_err(); + match err { + EngineError::Skill { reason } => assert!( + reason.contains("empty updated_content"), + "expected empty-content validation, got: {reason}" + ), + other => panic!("expected skill error, got: {other:?}"), + } + + let updated = store.load_memory_doc(skill_doc_id).await.unwrap().unwrap(); + let updated_meta: V2SkillMetadata = serde_json::from_value(updated.metadata).unwrap(); + assert_eq!(updated.content, "Original skill content"); + assert_eq!(updated_meta.version, 1); + assert!(updated_meta.repairs.is_empty()); + } + + #[tokio::test] + async fn process_skill_repair_output_rejects_shared_skill_updates() { + let store = Arc::new(TestStore::new()); + let project_id = ProjectId::new(); + let skill_doc = make_skill_doc(project_id, shared_owner_id(), "github-pr-workflow"); + let skill_doc_id = skill_doc.id; + store.save_memory_doc(&skill_doc).await.unwrap(); + + let mut mission = Mission::new( + project_id, + "alice", + "skill-repair", + SKILL_REPAIR_GOAL, + MissionCadence::Manual, + ); + mission.metadata = serde_json::json!({"skill_repair": true}); + mission.last_trigger_payload = Some(serde_json::json!({ + "source_thread_id": "thread-123", + "active_skills": [{ + "doc_id": skill_doc_id, + "name": "github-pr-workflow", + "version": 1, + "snippet_names": [], + "force_activated": false + }] + })); + + let response = serde_json::json!({ + "doc_id": skill_doc_id, + "repair_type": "missing_verification", + "summary": "Attempted shared skill update.", + "updated_content": "1. Verify auth\n2. Run the command" + }) + .to_string(); + + let err = + process_skill_repair_output(&(store.clone() as Arc), &mission, &response) + .await + .unwrap_err(); + match err { + EngineError::AccessDenied { user_id, entity } => { + assert_eq!(user_id, "alice"); + assert!(entity.contains(&skill_doc_id.0.to_string())); + } + other => panic!("expected access denied, got: {other:?}"), + } + + let unchanged = store.load_memory_doc(skill_doc_id).await.unwrap().unwrap(); + let meta: V2SkillMetadata = serde_json::from_value(unchanged.metadata).unwrap(); + assert_eq!(unchanged.content, "Original skill content"); + assert_eq!(meta.version, 1); + assert!(meta.repairs.is_empty()); + } + + #[test] + fn build_skill_gap_payload_skips_read_only_shell_workflows() { + let project_id = ProjectId::new(); + let mut thread = Thread::new( + "inspect github pull requests", + ThreadType::Foreground, + project_id, + "alice", + ThreadConfig::default(), + ); + thread.state = ThreadState::Done; + thread + .set_active_skills(&[ActiveSkillProvenance { + doc_id: DocId::new(), + name: "github-pr-workflow".to_string(), + version: 1, + snippet_names: vec![], + force_activated: false, + }]) + .unwrap(); + thread.add_event(crate::types::event::EventKind::ActionExecuted { + step_id: StepId::new(), + action_name: "shell".to_string(), + call_id: "call_1".to_string(), + params_summary: Some("gh pr list --repo nearai/ironclaw".to_string()), + duration_ms: 15, + }); + + let trace = crate::executor::trace::build_trace(&thread); + assert!( + build_skill_gap_payload(&thread, &trace, &thread.active_skills()).is_none(), + "read-only shell workflows should not trigger skill repair" + ); + } } diff --git a/crates/ironclaw_engine/src/types/thread.rs b/crates/ironclaw_engine/src/types/thread.rs index 919c9cffa31..5154fdde1fb 100644 --- a/crates/ironclaw_engine/src/types/thread.rs +++ b/crates/ironclaw_engine/src/types/thread.rs @@ -12,6 +12,7 @@ use uuid::Uuid; use crate::types::capability::LeaseId; use crate::types::error::EngineError; use crate::types::event::{EventKind, ThreadEvent}; +use crate::types::memory::DocId; use crate::types::message::ThreadMessage; use crate::types::project::ProjectId; @@ -166,6 +167,20 @@ impl Default for ThreadConfig { } } +/// Provenance for a skill that was active during thread execution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActiveSkillProvenance { + pub doc_id: DocId, + pub name: String, + pub version: u32, + #[serde(default)] + pub snippet_names: Vec, + #[serde(default)] + pub force_activated: bool, +} + +const ACTIVE_SKILLS_METADATA_KEY: &str = "active_skills"; + // ── Thread ────────────────────────────────────────────────── /// A thread — the unit of work. @@ -246,6 +261,36 @@ impl Thread { self.owner_id().matches_user(user_id) } + /// Persist active skill provenance in thread metadata. + pub fn set_active_skills( + &mut self, + active_skills: &[ActiveSkillProvenance], + ) -> Result<(), EngineError> { + let metadata = self + .metadata + .as_object_mut() + .ok_or_else(|| EngineError::Store { + reason: "thread metadata is not a JSON object".into(), + })?; + metadata.insert( + ACTIVE_SKILLS_METADATA_KEY.into(), + serde_json::to_value(active_skills).map_err(|e| EngineError::Store { + reason: format!("failed to serialize active skill provenance: {e}"), + })?, + ); + self.updated_at = Utc::now(); + Ok(()) + } + + /// Load active skill provenance from thread metadata. + pub fn active_skills(&self) -> Vec { + self.metadata + .get(ACTIVE_SKILLS_METADATA_KEY) + .cloned() + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default() + } + /// Transition to a new state, recording an event. pub fn transition_to( &mut self, @@ -310,6 +355,7 @@ impl Thread { #[cfg(test)] mod tests { use super::*; + use crate::types::memory::DocId; fn make_thread() -> Thread { Thread::new( @@ -463,4 +509,20 @@ mod tests { .with_parent(parent.id); assert_eq!(child.parent_id, Some(parent.id)); } + + #[test] + fn active_skill_provenance_roundtrips_through_metadata() { + let mut thread = make_thread(); + let skills = vec![ActiveSkillProvenance { + doc_id: DocId::new(), + name: "github-pr-workflow".to_string(), + version: 3, + snippet_names: vec!["list_prs".to_string()], + force_activated: true, + }]; + + thread.set_active_skills(&skills).unwrap(); + + assert_eq!(thread.active_skills(), skills); + } } diff --git a/crates/ironclaw_skills/src/catalog.rs b/crates/ironclaw_skills/src/catalog.rs index bd23b4ebdd4..105a4e8fc3e 100644 --- a/crates/ironclaw_skills/src/catalog.rs +++ b/crates/ironclaw_skills/src/catalog.rs @@ -13,6 +13,8 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; +use crate::validation::normalize_skill_identifier; + /// Default ClawHub registry URL. /// /// Points directly at the Convex backend, bypassing Vercel's edge which @@ -70,6 +72,88 @@ pub struct CatalogEntry { pub owner: Option, } +/// Error when a human-readable catalog name cannot be resolved safely. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CatalogResolveError { + #[error("Skill name '{name}' matches multiple catalog entries; use a slug instead ({matches})")] + AmbiguousName { name: String, matches: String }, +} + +fn normalize_catalog_identity(value: &str) -> String { + value + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +fn slug_suffix(slug: &str) -> &str { + slug.rsplit('/').next().unwrap_or(slug) +} + +/// Resolve a display name or suffix-like query to a unique catalog slug. +pub fn resolve_catalog_slug_for_name( + name: &str, + entries: &[CatalogEntry], +) -> Result, CatalogResolveError> { + let normalized_name = normalize_catalog_identity(name); + if normalized_name.is_empty() { + return Ok(None); + } + + let collect_matches = |predicate: &dyn Fn(&CatalogEntry) -> bool| -> Vec { + let mut matches: Vec = entries + .iter() + .filter(|entry| predicate(entry)) + .map(|entry| entry.slug.clone()) + .collect(); + + matches.sort(); + matches.dedup(); + matches + }; + + let exact_name = name.to_ascii_lowercase(); + let matches = collect_matches(&|entry| entry.name.to_ascii_lowercase() == exact_name); + if matches.len() == 1 { + return Ok(matches.into_iter().next()); + } + if matches.len() > 1 { + return Err(CatalogResolveError::AmbiguousName { + name: name.to_string(), + matches: matches.join(", "), + }); + } + + let matches = collect_matches(&|entry| { + normalize_catalog_identity(&entry.name) == normalized_name + || normalize_catalog_identity(slug_suffix(&entry.slug)) == normalized_name + }); + + match matches.len() { + 0 => Ok(None), + 1 => Ok(matches.into_iter().next()), + _ => Err(CatalogResolveError::AmbiguousName { + name: name.to_string(), + matches: matches.join(", "), + }), + } +} + +/// Whether a catalog entry should be marked as installed for a set of local names. +pub fn catalog_entry_is_installed(slug: &str, name: &str, installed_names: &[String]) -> bool { + let normalized_slug_name = normalize_skill_identifier(slug); + let slug_suffix = slug_suffix(slug); + installed_names.iter().any(|installed| { + slug.eq_ignore_ascii_case(installed) + || slug_suffix.eq_ignore_ascii_case(installed) + || name.eq_ignore_ascii_case(installed) + || normalized_slug_name + .as_deref() + .is_some_and(|n| n.eq_ignore_ascii_case(installed)) + }) +} + /// Top-level wrapper from the ClawHub `/api/v1/skills/{slug}` response. /// /// The API returns `{"skill": {...}, "owner": {...}, "latestVersion": {...}}`. @@ -521,6 +605,122 @@ mod tests { assert!(url.contains("slug=foo%26bar%3Dbaz%23frag")); } + #[test] + fn test_resolve_catalog_slug_for_name_unique_match() { + let entries = vec![CatalogEntry { + slug: "finance/mortgage-calculator".to_string(), + name: "Mortgage Calculator".to_string(), + description: String::new(), + version: String::new(), + score: 1.0, + updated_at: None, + stars: None, + downloads: None, + installs_current: None, + owner: None, + }]; + + assert_eq!( + resolve_catalog_slug_for_name("Mortgage Calculator", &entries).unwrap(), + Some("finance/mortgage-calculator".to_string()) + ); + assert_eq!( + resolve_catalog_slug_for_name("mortgage-calculator", &entries).unwrap(), + Some("finance/mortgage-calculator".to_string()) + ); + } + + #[test] + fn test_resolve_catalog_slug_for_name_ambiguous() { + let entries = vec![ + CatalogEntry { + slug: "alice/mortgage-calculator".to_string(), + name: "Mortgage Calculator".to_string(), + description: String::new(), + version: String::new(), + score: 1.0, + updated_at: None, + stars: None, + downloads: None, + installs_current: None, + owner: None, + }, + CatalogEntry { + slug: "bob/mortgage-calculator".to_string(), + name: "Mortgage Calculator".to_string(), + description: String::new(), + version: String::new(), + score: 0.9, + updated_at: None, + stars: None, + downloads: None, + installs_current: None, + owner: None, + }, + ]; + + let err = resolve_catalog_slug_for_name("Mortgage Calculator", &entries).unwrap_err(); + assert!(matches!(err, CatalogResolveError::AmbiguousName { .. })); + assert!(err.to_string().contains("use a slug instead")); + } + + #[test] + fn test_resolve_catalog_slug_for_name_prefers_exact_display_name() { + let entries = vec![ + CatalogEntry { + slug: "alice/ab".to_string(), + name: "AB".to_string(), + description: String::new(), + version: String::new(), + score: 1.0, + updated_at: None, + stars: None, + downloads: None, + installs_current: None, + owner: None, + }, + CatalogEntry { + slug: "bob/a-b".to_string(), + name: "A-B".to_string(), + description: String::new(), + version: String::new(), + score: 0.9, + updated_at: None, + stars: None, + downloads: None, + installs_current: None, + owner: None, + }, + ]; + + assert_eq!( + resolve_catalog_slug_for_name("AB", &entries).unwrap(), + Some("alice/ab".to_string()) + ); + } + + #[test] + fn test_catalog_entry_is_installed_matches_normalized_slug_name() { + let installed = vec!["finance-mortgage-calculator".to_string()]; + + assert!(catalog_entry_is_installed( + "finance/mortgage-calculator", + "Mortgage Calculator", + &installed, + )); + } + + #[test] + fn test_catalog_entry_is_installed_does_not_match_partial_suffix() { + let installed = vec!["calculator".to_string()]; + + assert!(!catalog_entry_is_installed( + "alice/mortgage-calculator", + "Mortgage Calculator", + &installed, + )); + } + #[test] fn test_parse_wrapped_response() { // ClawHub returns {"results": [...]} format diff --git a/crates/ironclaw_skills/src/lib.rs b/crates/ironclaw_skills/src/lib.rs index 7c0045ab928..60e7524376d 100644 --- a/crates/ironclaw_skills/src/lib.rs +++ b/crates/ironclaw_skills/src/lib.rs @@ -64,6 +64,9 @@ pub use validation::{ }; #[cfg(feature = "catalog")] -pub use catalog::{CatalogEntry, CatalogSearchOutcome, SkillCatalog, shared_catalog}; +pub use catalog::{ + CatalogEntry, CatalogResolveError, CatalogSearchOutcome, SkillCatalog, + catalog_entry_is_installed, resolve_catalog_slug_for_name, shared_catalog, +}; #[cfg(feature = "registry")] pub use registry::{SkillRegistry, SkillRegistryError, compute_hash}; diff --git a/crates/ironclaw_skills/src/parser.rs b/crates/ironclaw_skills/src/parser.rs index d66c79911e2..5a414ed759b 100644 --- a/crates/ironclaw_skills/src/parser.rs +++ b/crates/ironclaw_skills/src/parser.rs @@ -45,6 +45,62 @@ pub struct ParsedSkill { /// You are a helpful assistant that... /// ``` pub fn parse_skill_md(content: &str) -> Result { + parse_skill_md_impl(content, true) +} + +/// Parse a SKILL.md file for install recovery without validating the `name` field. +/// +/// Used by install paths that need to recover from invalid published names by +/// rewriting them to a safe internal identifier before persisting to disk. +/// +/// This is intentionally crate-private and should remain limited to the +/// install-recovery path. Normal discovery/loading must keep using +/// [`parse_skill_md`] so invalid names are rejected. +pub(crate) fn parse_skill_md_for_install_recovery( + content: &str, +) -> Result { + parse_skill_md_impl(content, false) +} + +/// Split a SKILL.md file into its raw YAML frontmatter and prompt body without +/// deserializing into a typed [`SkillManifest`]. +/// +/// Used by install recovery to mutate a single field (`name`) while preserving +/// any unknown YAML keys that the typed `SkillManifest` would otherwise drop. +pub(crate) fn split_skill_md_frontmatter( + content: &str, +) -> Result<(String, String), SkillParseError> { + let normalized = content.replace("\r\n", "\n").replace('\r', "\n"); + let stripped = normalized.strip_prefix('\u{feff}').unwrap_or(&normalized); + + let trimmed = stripped.trim_start_matches(['\n', '\r']); + if !trimmed.starts_with("---") { + return Err(SkillParseError::MissingFrontmatter); + } + + let after_first = &trimmed[3..]; + let after_first_line = match after_first.find('\n') { + Some(pos) => &after_first[pos + 1..], + None => return Err(SkillParseError::MissingFrontmatter), + }; + + let yaml_end = + find_closing_delimiter(after_first_line).ok_or(SkillParseError::MissingFrontmatter)?; + let yaml_str = after_first_line[..yaml_end].to_string(); + + let after_yaml = &after_first_line[yaml_end..]; + let prompt_start = after_yaml + .find('\n') + .map(|p| p + 1) + .unwrap_or(after_yaml.len()); + let prompt_content = after_yaml[prompt_start..] + .trim_start_matches('\n') + .to_string(); + + Ok((yaml_str, prompt_content)) +} + +fn parse_skill_md_impl(content: &str, validate_name: bool) -> Result { // Normalize line endings before parsing to handle CRLF (callers may not // have pre-normalized). This also makes `find_closing_delimiter`'s byte // offset arithmetic correct since it assumes single-byte `\n` separators. @@ -78,7 +134,7 @@ pub fn parse_skill_md(content: &str) -> Result { serde_yml::from_str(yaml_str).map_err(|e| SkillParseError::InvalidYaml(e.to_string()))?; // Validate skill name - if !validate_skill_name(&manifest.name) { + if validate_name && !validate_skill_name(&manifest.name) { return Err(SkillParseError::InvalidName { name: manifest.name.clone(), }); diff --git a/crates/ironclaw_skills/src/registry.rs b/crates/ironclaw_skills/src/registry.rs index e7bf47dbede..8da62771a89 100644 --- a/crates/ironclaw_skills/src/registry.rs +++ b/crates/ironclaw_skills/src/registry.rs @@ -19,11 +19,14 @@ use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; use crate::gating; -use crate::parser::{SkillParseError, parse_skill_md}; +use crate::parser::{ + SkillParseError, parse_skill_md, parse_skill_md_for_install_recovery, + split_skill_md_frontmatter, +}; use crate::types::{ GatingRequirements, LoadedSkill, MAX_PROMPT_FILE_SIZE, SkillSource, SkillTrust, }; -use crate::validation::normalize_line_endings; +use crate::validation::{normalize_line_endings, normalize_skill_identifier}; /// Maximum total number of skills that can be discovered across all sources. /// Shared across workspace, user, and installed directories. @@ -37,6 +40,111 @@ fn to_lowercase_vec(items: &[String]) -> Vec { items.iter().map(|s| s.to_lowercase()).collect() } +fn parse_error_for_install(error_label: &str, error: SkillParseError) -> SkillRegistryError { + let reason = error.to_string(); + match error { + SkillParseError::InvalidName { name } => SkillRegistryError::ParseError { name, reason }, + _ => SkillRegistryError::ParseError { + name: error_label.to_string(), + reason, + }, + } +} + +/// Rewrite the `name` field in raw YAML frontmatter while preserving every +/// other key and value in the original mapping. +/// +/// We deliberately operate on `serde_yml::Value` instead of the typed +/// `SkillManifest`: re-serializing through the typed struct silently drops +/// any unknown frontmatter fields published upstream (custom metadata, future +/// fields, vendor extensions). The recovery path must be lossless except for +/// the single field we are rewriting. +fn rewrite_frontmatter_name( + frontmatter: &str, + new_name: &str, + error_label: &str, +) -> Result { + let mut value: serde_yml::Value = + serde_yml::from_str(frontmatter).map_err(|e| SkillRegistryError::ParseError { + name: error_label.to_string(), + reason: format!("Failed to parse SKILL.md frontmatter for rewrite: {}", e), + })?; + + let mapping = value + .as_mapping_mut() + .ok_or_else(|| SkillRegistryError::ParseError { + name: error_label.to_string(), + reason: "SKILL.md frontmatter is not a YAML mapping".to_string(), + })?; + + mapping.insert( + serde_yml::Value::String("name".to_string()), + serde_yml::Value::String(new_name.to_string()), + ); + + let yaml = serde_yml::to_string(&value).map_err(|e| SkillRegistryError::ParseError { + name: error_label.to_string(), + reason: format!("Failed to rewrite normalized SKILL.md: {}", e), + })?; + + let yaml = yaml.strip_suffix("...\n").unwrap_or(&yaml); + let yaml = yaml.strip_suffix("...").unwrap_or(yaml); + Ok(yaml.to_string()) +} + +fn assemble_skill_md(yaml: &str, prompt_content: &str) -> String { + let mut rendered = String::from("---\n"); + rendered.push_str(yaml); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered.push_str("---\n\n"); + rendered.push_str(prompt_content); + rendered +} + +fn normalize_install_content( + normalized_content: &str, + requested_identifier: Option<&str>, +) -> Result<(String, String), SkillRegistryError> { + match parse_skill_md(normalized_content) { + Ok(parsed) => Ok((parsed.manifest.name, normalized_content.to_string())), + Err(SkillParseError::InvalidName { .. }) => { + // Re-parse the typed manifest only to recover the original name and + // confirm structural validity; the actual rewrite operates on raw + // YAML below to preserve any unknown frontmatter fields. + let parsed = parse_skill_md_for_install_recovery(normalized_content) + .map_err(|e| parse_error_for_install("(install)", e))?; + let original_name = parsed.manifest.name.clone(); + let normalized_name = requested_identifier + .and_then(normalize_skill_identifier) + .or_else(|| normalize_skill_identifier(&original_name)) + .ok_or_else(|| SkillRegistryError::ParseError { + name: original_name.clone(), + reason: format!( + "Invalid skill name '{}' could not be normalized to a safe install name", + original_name + ), + })?; + + tracing::debug!( + original_name = %original_name, + normalized_name = %normalized_name, + requested_identifier = requested_identifier.unwrap_or(""), + "Normalizing invalid skill name during install" + ); + + let (frontmatter, prompt_content) = split_skill_md_frontmatter(normalized_content) + .map_err(|e| parse_error_for_install("(install)", e))?; + let rewritten_yaml = + rewrite_frontmatter_name(&frontmatter, &normalized_name, &original_name)?; + let rendered = assemble_skill_md(&rewritten_yaml, &prompt_content); + Ok((normalized_name, rendered)) + } + Err(e) => Err(parse_error_for_install("(install)", e)), + } +} + /// Error type for skill registry operations. #[derive(Debug, thiserror::Error)] pub enum SkillRegistryError { @@ -426,6 +534,18 @@ impl SkillRegistry { self.skills.iter().find(|s| s.manifest.name == name) } + /// Resolve the on-disk install content and final in-memory skill name. + /// + /// Install flows use this to recover from invalid published names (for + /// example, catalog display names containing spaces) without relaxing the + /// parser for ordinary local skill discovery. + pub fn resolve_install_content( + normalized_content: &str, + requested_identifier: Option<&str>, + ) -> Result<(String, String), SkillRegistryError> { + normalize_install_content(normalized_content, requested_identifier) + } + /// Perform the disk I/O and loading for a skill install. /// /// This is a static method so it doesn't borrow `&self`, allowing callers @@ -484,23 +604,15 @@ impl SkillRegistry { /// hold time. pub async fn install_skill(&mut self, content: &str) -> Result { let normalized = normalize_line_endings(content); - let parsed = parse_skill_md(&normalized).map_err(|e: SkillParseError| match e { - SkillParseError::InvalidName { ref name } => SkillRegistryError::ParseError { - name: name.clone(), - reason: e.to_string(), - }, - _ => SkillRegistryError::ParseError { - name: "(install)".to_string(), - reason: e.to_string(), - }, - })?; - let skill_name = parsed.manifest.name.clone(); + let (skill_name, install_content) = normalize_install_content(&normalized, None)?; if self.has(&skill_name) { - return Err(SkillRegistryError::AlreadyExists { name: skill_name }); + return Err(SkillRegistryError::AlreadyExists { + name: skill_name.clone(), + }); } let user_dir = self.user_dir.clone(); let (name, skill) = - Self::prepare_install_to_disk(&user_dir, &skill_name, &normalized).await?; + Self::prepare_install_to_disk(&user_dir, &skill_name, &install_content).await?; self.commit_install(&name, skill)?; Ok(name) } @@ -978,6 +1090,89 @@ mod tests { assert!(skill_path.exists()); } + #[test] + fn test_resolve_install_content_prefers_requested_slug_for_invalid_name() { + let content = "---\nname: Mortgage Calculator\ndescription: Installed skill\n---\n\nInstalled prompt.\n"; + + let (name, rewritten) = + SkillRegistry::resolve_install_content(content, Some("finance/mortgage-calculator")) + .unwrap(); + + assert_eq!(name, "finance-mortgage-calculator"); + assert!(rewritten.contains("name: finance-mortgage-calculator")); + assert!(rewritten.contains("Installed prompt.")); + } + + #[test] + fn test_resolve_install_content_slugifies_invalid_name_without_slug() { + let content = "---\nname: Mortgage Calculator\n---\n\nPrompt.\n"; + + let (name, rewritten) = SkillRegistry::resolve_install_content(content, None).unwrap(); + + assert_eq!(name, "mortgage-calculator"); + assert!(rewritten.contains("name: mortgage-calculator")); + } + + #[tokio::test] + async fn test_install_skill_normalizes_invalid_name() { + let dir = tempfile::tempdir().unwrap(); + let mut registry = SkillRegistry::new(dir.path().to_path_buf()); + + let content = "---\nname: Mortgage Calculator\ndescription: Installed skill\n---\n\nInstalled prompt.\n"; + let name = registry.install_skill(content).await.unwrap(); + + assert_eq!(name, "mortgage-calculator"); + assert!(registry.has("mortgage-calculator")); + + let skill_path = dir.path().join("mortgage-calculator").join("SKILL.md"); + assert!(skill_path.exists()); + + let written = fs::read_to_string(skill_path).unwrap(); + assert!(written.contains("name: mortgage-calculator")); + } + + #[test] + fn test_resolve_install_content_preserves_unknown_frontmatter_fields() { + // Published manifests may carry custom keys (vendor extensions, future + // fields) that the typed `SkillManifest` does not know about. Recovery + // must rewrite only `name` without dropping unknown keys. + let content = "---\nname: Mortgage Calculator\ndescription: Computes payments\nx-publisher: acme\ncustom_meta:\n rating: 5\n tags:\n - finance\n - calculator\n---\n\nInstalled prompt.\n"; + + let (name, rewritten) = SkillRegistry::resolve_install_content(content, None).unwrap(); + + assert_eq!(name, "mortgage-calculator"); + assert!(rewritten.contains("name: mortgage-calculator")); + assert!( + rewritten.contains("x-publisher: acme"), + "unknown top-level key was dropped: {rewritten}" + ); + assert!( + rewritten.contains("custom_meta:"), + "unknown nested mapping was dropped: {rewritten}" + ); + assert!( + rewritten.contains("rating: 5"), + "nested scalar was dropped: {rewritten}" + ); + assert!( + rewritten.contains("- finance") && rewritten.contains("- calculator"), + "nested sequence was dropped: {rewritten}" + ); + assert!(rewritten.contains("Installed prompt.")); + } + + #[test] + fn test_resolve_install_content_preserves_owner_for_invalid_slug_name() { + let content = "---\nname: Mortgage Calculator\n---\n\nPrompt.\n"; + + let (name, rewritten) = + SkillRegistry::resolve_install_content(content, Some("alice/mortgage-calculator")) + .unwrap(); + + assert_eq!(name, "alice-mortgage-calculator"); + assert!(rewritten.contains("name: alice-mortgage-calculator")); + } + #[tokio::test] async fn test_install_duplicate_rejected() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ironclaw_skills/src/v2.rs b/crates/ironclaw_skills/src/v2.rs index c993fda572a..528ce94270a 100644 --- a/crates/ironclaw_skills/src/v2.rs +++ b/crates/ironclaw_skills/src/v2.rs @@ -70,6 +70,64 @@ impl SkillMetrics { } } +/// Structured repair categories for versioned skill updates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SkillRepairType { + MissingPrerequisite, + WrongOrdering, + StaleCommandPath, + MissingBranch, + MissingPitfall, + MissingVerification, +} + +/// Archived pre-update skill state used for content-aware rollback. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillRevision { + /// Version number represented by this archived snapshot. + pub version: u32, + /// Prompt content for that version. + pub content: String, + /// Description at that version. + #[serde(default)] + pub description: String, + /// Activation criteria at that version. + #[serde(default)] + pub activation: ActivationCriteria, + /// Code snippets at that version. + #[serde(default)] + pub code_snippets: Vec, + /// Content hash at that version. + #[serde(default)] + pub content_hash: String, + /// When this snapshot was archived. + #[serde(default)] + pub archived_at: Option>, +} + +/// Metadata describing a repair that updated this skill. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillRepairRecord { + /// Source thread that exposed the gap, if known. + #[serde(default)] + pub source_thread_id: Option, + /// Version before the repair. + #[serde(default)] + pub from_version: u32, + /// Version after the repair. + #[serde(default)] + pub to_version: u32, + /// Type of gap that was repaired. + pub repair_type: SkillRepairType, + /// Short human-readable summary of the fix. + #[serde(default)] + pub summary: String, + /// When the repair was applied. + #[serde(default)] + pub repaired_at: Option>, +} + /// Full metadata for a v2 skill. /// /// Serialized to/from the `metadata` JSON field of a `MemoryDoc` with @@ -104,6 +162,12 @@ pub struct V2SkillMetadata { /// Previous version number (for rollback). #[serde(default)] pub parent_version: Option, + /// Archived revisions used for content-aware rollback. + #[serde(default)] + pub revisions: Vec, + /// Repair history for this skill. + #[serde(default)] + pub repairs: Vec, /// SHA-256 hash of the prompt content. #[serde(default)] pub content_hash: String, @@ -181,6 +245,23 @@ mod tests { last_used: None, }, parent_version: Some(2), + revisions: vec![SkillRevision { + version: 2, + content: "previous content".to_string(), + description: "old".to_string(), + activation: ActivationCriteria::default(), + code_snippets: vec![], + content_hash: "sha256:def".to_string(), + archived_at: None, + }], + repairs: vec![SkillRepairRecord { + source_thread_id: Some("thread-123".to_string()), + from_version: 2, + to_version: 3, + repair_type: SkillRepairType::MissingVerification, + summary: "added smoke test step".to_string(), + repaired_at: None, + }], content_hash: "sha256:abc".to_string(), }; @@ -193,6 +274,8 @@ mod tests { assert_eq!(parsed.code_snippets.len(), 1); assert_eq!(parsed.metrics.success_count, 4); assert_eq!(parsed.parent_version, Some(2)); + assert_eq!(parsed.revisions.len(), 1); + assert_eq!(parsed.repairs.len(), 1); } #[test] @@ -205,5 +288,7 @@ mod tests { assert_eq!(parsed.trust, SkillTrust::Installed); assert!(parsed.code_snippets.is_empty()); assert!((parsed.metrics.confidence() - 1.0).abs() < f64::EPSILON); + assert!(parsed.revisions.is_empty()); + assert!(parsed.repairs.is_empty()); } } diff --git a/crates/ironclaw_skills/src/validation.rs b/crates/ironclaw_skills/src/validation.rs index 0140d7fdbd0..7ecc6936de4 100644 --- a/crates/ironclaw_skills/src/validation.rs +++ b/crates/ironclaw_skills/src/validation.rs @@ -13,6 +13,62 @@ pub fn validate_skill_name(name: &str) -> bool { SKILL_NAME_PATTERN.is_match(name) } +/// Normalize an external identifier into a safe skill name when possible. +/// +/// This is used for recovery paths where a published identifier or display name +/// needs to be turned into a valid on-disk/internal skill name. Valid names are +/// preserved; invalid identifiers are lowercased and non-alphanumeric runs are +/// collapsed into `-`, `_`, or `.` separators as allowed by the skill-name +/// grammar. +/// +/// Non-ASCII characters (accented letters, CJK, emoji) are treated as separators +/// and effectively dropped: e.g. `"café"` becomes `"caf"`, `"中文-skill"` becomes +/// `"skill"`. Identifiers that normalize to an empty or otherwise invalid name +/// return `None`. +pub fn normalize_skill_identifier(value: &str) -> Option { + let trimmed = value.trim(); + if validate_skill_name(trimmed) { + return Some(trimmed.to_string()); + } + + let mut sanitized = String::with_capacity(trimmed.len().min(64)); + let mut last_was_separator = false; + + for ch in trimmed.chars() { + if ch.is_ascii_alphanumeric() { + sanitized.push(ch.to_ascii_lowercase()); + last_was_separator = false; + continue; + } + + if matches!(ch, '.' | '_' | '-') { + if !sanitized.is_empty() && !last_was_separator { + sanitized.push(ch); + last_was_separator = true; + } + continue; + } + + if !sanitized.is_empty() && !last_was_separator { + sanitized.push('-'); + last_was_separator = true; + } + } + + while sanitized.ends_with(['-', '_', '.']) { + sanitized.pop(); + } + + if sanitized.len() > 64 { + sanitized.truncate(64); + while sanitized.ends_with(['-', '_', '.']) { + sanitized.pop(); + } + } + + validate_skill_name(&sanitized).then_some(sanitized) +} + /// Escape a string for safe inclusion in XML attributes. /// Prevents attribute injection attacks via skill name/version fields. pub fn escape_xml_attr(s: &str) -> String { @@ -162,6 +218,23 @@ mod tests { )); } + #[test] + fn test_normalize_skill_identifier() { + assert_eq!( + normalize_skill_identifier("finance/mortgage-calculator").as_deref(), + Some("finance-mortgage-calculator") + ); + assert_eq!( + normalize_skill_identifier("Mortgage Calculator").as_deref(), + Some("mortgage-calculator") + ); + assert_eq!( + normalize_skill_identifier("already-valid_name").as_deref(), + Some("already-valid_name") + ); + assert_eq!(normalize_skill_identifier("!!!"), None); + } + #[test] fn test_escape_xml_attr() { assert_eq!(escape_xml_attr("normal"), "normal"); diff --git a/crates/ironclaw_tui/CLAUDE.md b/crates/ironclaw_tui/CLAUDE.md new file mode 100644 index 00000000000..57caaba5f26 --- /dev/null +++ b/crates/ironclaw_tui/CLAUDE.md @@ -0,0 +1,41 @@ +# ironclaw_tui — Module Spec + +## Overview + +Ratatui-based terminal UI for IronClaw. Self-contained crate that provides: +- Widget system (`TuiWidget` trait) with built-in widgets (header, conversation, input, status bar, tool panel, thread list, approval modal) +- Layout engine with user-configurable JSON (`tui/layout.json` in workspace) +- Theme system (dark/light, custom colors) +- Event loop with crossterm input polling + external event merging + +## Dependencies + +- No dependency on the main `ironclaw` crate (avoids circular dependency) +- Channel trait bridge lives in `src/channels/tui.rs` in the main crate + +## Communication + +``` +Main Crate (TuiChannel) ironclaw_tui (TuiApp) +───────────────────── ─────────────────── +event_tx: Sender ────→ event_rx: renders UI +msg_rx: Receiver ←──── msg_tx: user input +``` + +## Key Bindings + +| Key | Action | +|----------|----------------------| +| Enter | Submit input | +| Ctrl-C | Quit | +| Ctrl-B | Toggle sidebar | +| Esc | Interrupt/cancel | +| PgUp/Dn | Scroll conversation | +| y/n/a | Approval shortcuts | + +## Adding a Widget + +1. Create `src/widgets/my_widget.rs` +2. Implement `TuiWidget` trait +3. Add to `BuiltinWidgets` in `registry.rs` +4. Wire into `render_frame()` in `app.rs` diff --git a/crates/ironclaw_tui/Cargo.toml b/crates/ironclaw_tui/Cargo.toml new file mode 100644 index 00000000000..11d3e7b6211 --- /dev/null +++ b/crates/ironclaw_tui/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "ironclaw_tui" +version = "0.1.0" +edition = "2024" +rust-version = "1.92" +description = "Modular Ratatui-based TUI for IronClaw" +authors = ["NEAR AI "] +license = "MIT OR Apache-2.0" +homepage = "https://github.com/nearai/ironclaw" +repository = "https://github.com/nearai/ironclaw" + +[package.metadata.dist] +dist = false + +[dependencies] +ratatui = { version = "0.29", features = ["crossterm"] } +tui-textarea = { version = "0.7", features = ["crossterm"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["sync", "macros", "rt", "time"] } +chrono = "0.4" +unicode-width = "0.2" +pulldown-cmark = { version = "0.12", default-features = false } +thiserror = "2" +tracing = "0.1" +arboard = "3" +image = { version = "0.25", default-features = false, features = ["png"] } + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } diff --git a/crates/ironclaw_tui/examples/dev.rs b/crates/ironclaw_tui/examples/dev.rs new file mode 100644 index 00000000000..6002ca2e951 --- /dev/null +++ b/crates/ironclaw_tui/examples/dev.rs @@ -0,0 +1,283 @@ +//! Standalone TUI dev harness — renders the full TUI with mock data. +//! +//! Usage: +//! cargo run -p ironclaw_tui --example dev +//! +//! Hot-reload loop (recompiles + restarts on any source change): +//! cargo watch -x 'run -p ironclaw_tui --example dev' -w crates/ironclaw_tui/src +//! +//! This compiles in ~5s instead of minutes because it skips the entire +//! ironclaw binary (database, LLM, WASM, Docker, etc.). + +use std::time::Duration; + +use ironclaw_tui::{SkillCategory, ToolCategory, TuiAppConfig, TuiEvent, TuiLayout, start_tui}; + +fn mock_tool_categories() -> Vec { + vec![ + ToolCategory { + name: "browser".into(), + tools: vec![ + "back".into(), + "click".into(), + "navigate".into(), + "screenshot".into(), + ], + }, + ToolCategory { + name: "file".into(), + tools: vec!["read".into(), "write".into(), "search".into()], + }, + ToolCategory { + name: "general".into(), + tools: vec![ + "echo".into(), + "github".into(), + "gmail".into(), + "http".into(), + "json".into(), + "time".into(), + ], + }, + ToolCategory { + name: "memory".into(), + tools: vec![ + "read".into(), + "search".into(), + "tree".into(), + "write".into(), + ], + }, + ToolCategory { + name: "routine".into(), + tools: vec![ + "create".into(), + "delete".into(), + "list".into(), + "update".into(), + ], + }, + ToolCategory { + name: "secret".into(), + tools: vec!["delete".into(), "list".into()], + }, + ToolCategory { + name: "shell".into(), + tools: vec!["exec".into()], + }, + ToolCategory { + name: "skill".into(), + tools: vec![ + "install".into(), + "list".into(), + "remove".into(), + "search".into(), + ], + }, + ToolCategory { + name: "tool".into(), + tools: vec![ + "activate".into(), + "auth".into(), + "info".into(), + "install".into(), + "list".into(), + "remove".into(), + "search".into(), + "upgrade".into(), + ], + }, + ToolCategory { + name: "web".into(), + tools: vec!["fetch".into()], + }, + ] +} + +fn mock_skill_categories() -> Vec { + vec![ + SkillCategory { + name: "apple".into(), + skills: vec![ + "apple-notes".into(), + "apple-reminders".into(), + "findmy".into(), + ], + }, + SkillCategory { + name: "creative".into(), + skills: vec![ + "ascii-art".into(), + "ascii-video".into(), + "excalidraw".into(), + ], + }, + SkillCategory { + name: "data-science".into(), + skills: vec!["jupyter-live-kernel".into()], + }, + SkillCategory { + name: "github".into(), + skills: vec![ + "codebase-inspection".into(), + "github-auth".into(), + "github-code-r...".into(), + ], + }, + SkillCategory { + name: "media".into(), + skills: vec!["gif-search".into(), "heartmula".into(), "songsee".into()], + }, + SkillCategory { + name: "productivity".into(), + skills: vec![ + "google-workspace".into(), + "linear".into(), + "notion".into(), + "ocr".into(), + ], + }, + SkillCategory { + name: "research".into(), + skills: vec!["arxiv".into(), "blogwatcher".into(), "domain-intel".into()], + }, + SkillCategory { + name: "software-dev".into(), + skills: vec!["code-review".into(), "plan".into(), "remote-pr".into()], + }, + ] +} + +fn main() { + let config = TuiAppConfig { + version: "0.22.0-dev".into(), + model: "gpt-5.4".into(), + layout: TuiLayout::default(), + context_window: 128_000, + tools: mock_tool_categories(), + skills: mock_skill_categories(), + workspace_path: std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "~/projects/ironclaw".into()), + memory_count: 42, + identity_files: vec!["AGENTS.md".into(), "SOUL.md".into(), "USER.md".into()], + available_models: vec![ + "gpt-4o".into(), + "gpt-5.3-codex".into(), + "gpt-5.4".into(), + "claude-sonnet-4-6".into(), + "gemini-2.5-pro".into(), + ], + }; + + let handle = start_tui(config); + let event_tx = handle.event_tx; + let mut msg_rx = handle.msg_rx; + + // Spawn a thread that simulates agent responses to user input + let sim_tx = event_tx.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime"); // safety: example binary, not library code + + rt.block_on(async move { + // Simulate initial status events after a short delay + tokio::time::sleep(Duration::from_millis(500)).await; + let _ = sim_tx + .send(TuiEvent::SandboxStatus { + docker_available: true, + running_containers: 0, + status: "ready".into(), + }) + .await; + let _ = sim_tx + .send(TuiEvent::SecretsStatus { + count: 3, + vault_unlocked: true, + }) + .await; + + // Echo user messages back as mock agent responses + while let Some(user_msg) = msg_rx.recv().await { + let msg = &user_msg.text; + // Simulate thinking + let _ = sim_tx + .send(TuiEvent::Thinking("Processing...".into())) + .await; + tokio::time::sleep(Duration::from_millis(300)).await; + + // Simulate tool call + let truncated: String = msg.chars().take(40).collect(); + let _ = sim_tx + .send(TuiEvent::ToolStarted { + name: "echo".into(), + detail: Some(format!("\"{truncated}\"")), + call_id: None, + }) + .await; + tokio::time::sleep(Duration::from_millis(200)).await; + let _ = sim_tx + .send(TuiEvent::ToolCompleted { + name: "echo".into(), + success: true, + error: None, + call_id: None, + }) + .await; + let _ = sim_tx + .send(TuiEvent::ToolResult { + name: "echo".into(), + preview: msg.clone(), + call_id: None, + }) + .await; + + // Simulate streaming response + let _ = sim_tx.send(TuiEvent::Thinking(String::new())).await; + let response = format!( + "You said: **{msg}**\n\nThis is a mock response from the dev harness. \ + Edit `crates/ironclaw_tui/src/` and watch it reload.", + ); + for chunk in response.as_bytes().chunks(20) { + let _ = sim_tx + .send(TuiEvent::StreamChunk( + String::from_utf8_lossy(chunk).to_string(), + )) + .await; + tokio::time::sleep(Duration::from_millis(30)).await; + } + let _ = sim_tx + .send(TuiEvent::Response { + content: response, + thread_id: None, + }) + .await; + + // Simulate cost + let _ = sim_tx + .send(TuiEvent::TurnCost { + input_tokens: 1200, + output_tokens: 340, + cost_usd: "$0.002".into(), + }) + .await; + + // Suggestions + let _ = sim_tx + .send(TuiEvent::Suggestions { + suggestions: vec![ + "Tell me more".into(), + "Show available tools".into(), + "Search memory".into(), + ], + }) + .await; + } + }); + }); + + // Block main thread until TUI exits + handle.join_handle.join().expect("TUI thread panicked"); // safety: example binary, not library code +} diff --git a/crates/ironclaw_tui/src/app.rs b/crates/ironclaw_tui/src/app.rs new file mode 100644 index 00000000000..a71a4b507eb --- /dev/null +++ b/crates/ironclaw_tui/src/app.rs @@ -0,0 +1,3337 @@ +//! TuiApp: main event loop, frame rendering, and input dispatch. +//! +//! The TUI runs in a dedicated blocking thread (crossterm needs raw mode +//! control of stdin). It communicates with the agent via channels: +//! +//! - `event_rx`: receives [`TuiEvent`]s (key input, status updates, responses) +//! - `msg_tx`: sends user messages to the agent loop +//! +//! The app owns the terminal, manages alternate screen / raw mode, and +//! renders frames at ~30fps using a tick timer. + +use std::io::{self, Write}; +use std::time::Duration; + +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::crossterm::cursor::Show; +use ratatui::crossterm::event::{ + self, DisableBracketedPaste, EnableBracketedPaste, Event as CtEvent, KeyCode, KeyEventKind, + KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::crossterm::execute; +use ratatui::crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use tokio::sync::mpsc; + +use crate::event::{TuiAttachment, TuiEvent, TuiLogEntry, TuiUserMessage}; +use crate::input::{InputAction, map_key}; +use crate::layout::TuiLayout; +use crate::widgets::approval::{ApprovalAction, ApprovalWidget}; +use crate::widgets::command_palette::CommandPaletteWidget; +use crate::widgets::help_overlay::HelpOverlayWidget; +use crate::widgets::logs::LogsWidget; +use crate::widgets::model_picker::{ModelPickerState, ModelPickerWidget}; +use crate::widgets::registry::{BuiltinWidgets, create_default_widgets}; +use crate::widgets::thread_list::engine_thread_index_at; +use crate::widgets::thread_picker::ThreadPickerWidget; +use crate::widgets::{ + ActiveTab, AppState, ApprovalRequest, ChatMessage, ContextPressureInfo, CostGuardInfo, + EngineThreadInfo, JobInfo, JobStatus, MessageRole, RoutineInfo, SandboxInfo, ScreenSnapshot, + SecretsInfo, SelectionPoint, SkillCategory, TextSelection, ThreadStatus, Toast, ToastKind, + ToolActivity, ToolCategory, ToolDetailModal, ToolStatus, TuiWidget, TurnCostSummary, +}; + +/// Handle returned when the TUI is started. The main crate uses this to +/// send events and receive user messages. +pub struct TuiAppHandle { + /// Send events (status updates, responses) into the TUI. + pub event_tx: mpsc::Sender, + /// Receive user messages from the TUI input. + pub msg_rx: mpsc::Receiver, + /// Join handle for the TUI thread. + pub join_handle: std::thread::JoinHandle<()>, +} + +/// Configuration for creating a TuiApp. +pub struct TuiAppConfig { + pub version: String, + pub model: String, + pub layout: TuiLayout, + /// Maximum context window size in tokens (e.g., 128_000, 200_000). + pub context_window: u64, + /// Tool categories for the welcome screen. + pub tools: Vec, + /// Skill categories for the welcome screen. + pub skills: Vec, + /// Workspace directory path. + pub workspace_path: String, + /// Number of memory entries in the workspace. + pub memory_count: usize, + /// Identity files loaded at startup (e.g. "AGENTS.md", "SOUL.md"). + pub identity_files: Vec, + /// Best-effort model list for the `/model` picker. + pub available_models: Vec, +} + +/// Start the TUI application. Returns a handle for bi-directional communication. +/// +/// The TUI runs in a dedicated OS thread because crossterm raw mode requires +/// exclusive stdin access. +pub fn start_tui(config: TuiAppConfig) -> TuiAppHandle { + let (event_tx, event_rx) = mpsc::channel::(256); + let (msg_tx, msg_rx) = mpsc::channel::(32); + + // Clone event_tx for the crossterm polling task + let input_event_tx = event_tx.clone(); + + let join_handle = std::thread::spawn(move || { + // Build a single-threaded tokio runtime for the TUI thread + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + tracing::error!("Failed to build tokio runtime for TUI: {e}"); + return; + } + }; + + rt.block_on(async move { + if let Err(e) = run_tui(config, event_rx, input_event_tx, msg_tx).await { + tracing::error!("TUI error: {}", e); + } + }); + }); + + TuiAppHandle { + event_tx, + msg_rx, + join_handle, + } +} + +/// Internal TUI run loop. +async fn run_tui( + config: TuiAppConfig, + mut event_rx: mpsc::Receiver, + input_event_tx: mpsc::Sender, + msg_tx: mpsc::Sender, +) -> io::Result<()> { + // Terminal setup + enable_raw_mode()?; + let mut restore_guard = TerminalRestoreGuard::new(); + let mut stdout = io::stdout(); + execute!( + stdout, + EnterAlternateScreen, + ratatui::crossterm::event::EnableMouseCapture, + EnableBracketedPaste + )?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + // State + let mut state = AppState { + version: config.version, + model: config.model, + sidebar_visible: config.layout.sidebar.visible, + context_window: config.context_window, + welcome_tools: config.tools, + welcome_skills: config.skills, + workspace_path: config.workspace_path, + memory_count: config.memory_count, + identity_files: config.identity_files, + model_picker: ModelPickerState::with_models(config.available_models), + ..AppState::default() + }; + + let mut widgets = create_default_widgets(&config.layout); + let layout = config.layout; + + // Spawn crossterm input poller + let poll_tx = input_event_tx; + tokio::spawn(async move { + loop { + // Poll crossterm events with a short timeout + match tokio::task::spawn_blocking(|| { + if event::poll(Duration::from_millis(33)).unwrap_or(false) { + event::read().ok() + } else { + None + } + }) + .await + { + Ok(Some(CtEvent::Key(key))) => { + if key.kind == KeyEventKind::Press + && poll_tx.send(TuiEvent::Key(key)).await.is_err() + { + break; + } + } + Ok(Some(CtEvent::Resize(w, h))) => { + if poll_tx.send(TuiEvent::Resize(w, h)).await.is_err() { + break; + } + } + Ok(Some(CtEvent::Mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + .. + }))) => { + if poll_tx.send(TuiEvent::MouseScroll(-1)).await.is_err() { + break; + } + } + Ok(Some(CtEvent::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + .. + }))) => { + if poll_tx.send(TuiEvent::MouseScroll(1)).await.is_err() { + break; + } + } + Ok(Some(CtEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + .. + }))) => { + if poll_tx + .send(TuiEvent::MouseClick { column, row }) + .await + .is_err() + { + break; + } + } + Ok(Some(CtEvent::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column, + row, + .. + }))) => { + if poll_tx + .send(TuiEvent::MouseDrag { column, row }) + .await + .is_err() + { + break; + } + } + Ok(Some(CtEvent::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column, + row, + .. + }))) => { + if poll_tx + .send(TuiEvent::MouseRelease { column, row }) + .await + .is_err() + { + break; + } + } + Ok(Some(CtEvent::Paste(text))) => { + if poll_tx.send(TuiEvent::Paste(text)).await.is_err() { + break; + } + } + Ok(_) => {} + Err(_) => break, + } + } + }); + + let mut tick_interval = tokio::time::interval(Duration::from_millis(33)); + + // Main loop + loop { + // Render + terminal.draw(|frame| { + render_frame(frame, &mut state, &widgets, &layout); + })?; + + // Wait for event + tokio::select! { + _ = tick_interval.tick() => { + // Tick — just triggers a re-render + } + event = event_rx.recv() => { + let Some(event) = event else { + break; // Channel closed + }; + handle_event(event, &mut state, &mut widgets, &msg_tx, &layout).await; + } + } + + if state.should_quit { + break; + } + } + + // Teardown + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + DisableBracketedPaste, + ratatui::crossterm::event::DisableMouseCapture, + LeaveAlternateScreen + )?; + terminal.show_cursor()?; + restore_guard.disarm(); + Ok(()) +} + +/// Count the number of case-insensitive matches of `query` across all messages. +fn count_search_matches(messages: &[ChatMessage], query: &str) -> usize { + if query.is_empty() { + return 0; + } + let query_lower = query.to_lowercase(); + messages + .iter() + .map(|m| { + let content_lower = m.content.to_lowercase(); + content_lower.matches(&query_lower).count() + }) + .sum() +} + +fn outgoing_thread_scope(text: &str, current_thread_id: Option<&str>) -> Option { + let trimmed = text.trim(); + if trimmed.eq_ignore_ascii_case("/new") + || trimmed.eq_ignore_ascii_case("/clear") + || trimmed.eq_ignore_ascii_case("/thread new") + || trimmed.to_ascii_lowercase().starts_with("/thread ") + { + return None; + } + + current_thread_id.map(str::to_owned) +} + +fn update_local_thread_scope_after_submit(state: &mut AppState, text: &str) { + let trimmed = text.trim(); + if trimmed.eq_ignore_ascii_case("/new") + || trimmed.eq_ignore_ascii_case("/clear") + || trimmed.eq_ignore_ascii_case("/thread new") + { + state.current_thread_id = None; + } +} + +fn parse_engine_thread_timestamp( + raw: &str, + field: &'static str, + thread_id: &str, +) -> Option> { + if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(raw) { + return Some(parsed.with_timezone(&chrono::Utc)); + } + + if let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M") { + return Some(chrono::DateTime::from_naive_utc_and_offset( + parsed, + chrono::Utc, + )); + } + + tracing::debug!( + thread_id, + field, + raw, + "Failed to parse engine thread timestamp" + ); + None +} + +/// Handle a single TUI event. +async fn handle_event( + event: TuiEvent, + state: &mut AppState, + widgets: &mut BuiltinWidgets, + msg_tx: &mpsc::Sender, + layout: &TuiLayout, +) { + match event { + TuiEvent::Paste(text) => { + let approval_active = state.pending_approval.is_some(); + let help_active = state.help_visible; + let tool_detail_active = state.tool_detail_modal.is_some(); + + if !approval_active && !help_active && !tool_detail_active { + widgets.input_box.insert_text(&text); + + if state.history_index.is_some() { + state.history_index = None; + state.history_draft = widgets.input_box.current_text(); + } + + update_input_overlays_from_input(&widgets.input_box, state); + + if state.search.active { + state.search.query = widgets.input_box.current_text(); + state.search.match_count = + count_search_matches(&state.messages, &state.search.query); + state.search.current_match = 0; + } + } + } + TuiEvent::Key(key) => { + let action = resolve_key_action(key, state, widgets); + + match action { + InputAction::Submit => { + let selected_model = if state.model_picker.visible { + state.model_picker.selected_model().map(str::to_owned) + } else { + None + }; + state.model_picker.close(); + state.command_palette.close(); + let text = widgets.input_box.take_input(); + let trimmed = if let Some(ref model) = selected_model { + format!("/model {model}") + } else { + text.trim().to_string() + }; + let attachments = std::mem::take(&mut state.pending_attachments); + if !trimmed.is_empty() || !attachments.is_empty() { + state.awaiting_model_list = selected_model.is_none() + && attachments.is_empty() + && trimmed == "/model"; + // Push to input history + if !trimmed.is_empty() { + state.input_history.push(trimmed.clone()); + } + state.history_index = None; + state.history_draft.clear(); + // Clear follow-up suggestions from previous turn + state.suggestions.clear(); + // Build display content with attachment labels + let display_content = if attachments.is_empty() { + trimmed.clone() + } else { + let labels: Vec<&str> = + attachments.iter().map(|a| a.label.as_str()).collect(); + if trimmed.is_empty() { + format!("[{}]", labels.join("] [")) + } else { + format!("{trimmed} [{}]", labels.join("] [")) + } + }; + // Add user message to conversation + state.messages.push(ChatMessage { + role: MessageRole::User, + content: display_content, + timestamp: chrono::Utc::now(), + cost_summary: None, + }); + state.scroll_offset = 0; + state.pinned_to_bottom = true; + if let Some(model) = selected_model { + state.model = model; + } + // Send to agent + update_local_thread_scope_after_submit(state, &trimmed); + let thread_id = + outgoing_thread_scope(&trimmed, state.current_thread_id.as_deref()); + let _ = msg_tx + .send(TuiUserMessage { + text: trimmed, + attachments, + thread_id, + ui_action: None, + }) + .await; + } + } + InputAction::Quit => { + let _ = msg_tx.send(TuiUserMessage::text_only("/quit")).await; + state.should_quit = true; + } + InputAction::ToggleSidebar => { + state.sidebar_visible = !state.sidebar_visible; + } + InputAction::ToggleLogs => { + state.active_tab = match state.active_tab { + ActiveTab::Conversation => ActiveTab::Logs, + ActiveTab::Logs => ActiveTab::Conversation, + }; + } + InputAction::ScrollUp => match state.active_tab { + ActiveTab::Conversation => { + let page = state.conversation_height.max(2).saturating_sub(2) as i16; + widgets.conversation.scroll(state, -page); + } + ActiveTab::Logs => { + LogsWidget::scroll(state, -5); + } + }, + InputAction::ScrollDown => match state.active_tab { + ActiveTab::Conversation => { + let page = state.conversation_height.max(2).saturating_sub(2) as i16; + widgets.conversation.scroll(state, page); + } + ActiveTab::Logs => { + LogsWidget::scroll(state, 5); + } + }, + InputAction::ScrollToBottom => { + state.scroll_offset = 0; + state.pinned_to_bottom = true; + } + InputAction::Interrupt => { + let _ = msg_tx + .send( + TuiUserMessage::text_only("/interrupt") + .with_thread_id(state.current_thread_id.clone()), + ) + .await; + state.status_text.clear(); + } + InputAction::ApprovalUp => { + if let Some(ref mut ap) = state.pending_approval { + let count = ApprovalWidget::options(ap.allow_always).len(); + ap.selected = if ap.selected == 0 { + count - 1 + } else { + ap.selected - 1 + }; + } + } + InputAction::ApprovalDown => { + if let Some(ref mut ap) = state.pending_approval { + let count = ApprovalWidget::options(ap.allow_always).len(); + ap.selected = (ap.selected + 1) % count; + } + } + InputAction::ApprovalConfirm => { + if let Some(ref ap) = state.pending_approval { + let options = ApprovalWidget::options(ap.allow_always); + let action = options + .get(ap.selected) + .copied() + .unwrap_or(ApprovalAction::Deny); + let _ = msg_tx + .send( + TuiUserMessage::text_only(action.as_response()) + .with_thread_id(state.current_thread_id.clone()), + ) + .await; + state.pending_approval = None; + } + } + InputAction::ApprovalCancel => { + if state.pending_approval.is_some() { + let _ = msg_tx + .send( + TuiUserMessage::text_only("n") + .with_thread_id(state.current_thread_id.clone()), + ) + .await; + state.pending_approval = None; + } + } + InputAction::QuickApprove => { + if state.pending_approval.is_some() { + let _ = msg_tx + .send( + TuiUserMessage::text_only("y") + .with_thread_id(state.current_thread_id.clone()), + ) + .await; + state.pending_approval = None; + } + } + InputAction::QuickAlways => { + if let Some(ref ap) = state.pending_approval { + if ap.allow_always { + let _ = msg_tx + .send( + TuiUserMessage::text_only("a") + .with_thread_id(state.current_thread_id.clone()), + ) + .await; + } else { + let _ = msg_tx + .send( + TuiUserMessage::text_only("y") + .with_thread_id(state.current_thread_id.clone()), + ) + .await; + } + state.pending_approval = None; + } + } + InputAction::QuickDeny => { + if state.pending_approval.is_some() { + let _ = msg_tx + .send( + TuiUserMessage::text_only("n") + .with_thread_id(state.current_thread_id.clone()), + ) + .await; + state.pending_approval = None; + } + } + InputAction::PaletteUp => { + if state.model_picker.visible { + state.model_picker.move_up(); + } else { + state.command_palette.move_up(); + } + } + InputAction::PaletteDown => { + if state.model_picker.visible { + state.model_picker.move_down(); + } else { + state.command_palette.move_down(); + } + } + InputAction::PaletteSelect => { + if state.model_picker.visible { + let command = state + .model_picker + .selected_model() + .map(|model| format!("/model {model}")) + .unwrap_or_else(|| widgets.input_box.current_text().trim().to_string()); + let attachments = std::mem::take(&mut state.pending_attachments); + let _ = widgets.input_box.take_input(); + state.model_picker.close(); + state.command_palette.close(); + + if !command.is_empty() || !attachments.is_empty() { + state.awaiting_model_list = + attachments.is_empty() && command == "/model"; + if !command.is_empty() { + state.input_history.push(command.clone()); + } + state.history_index = None; + state.history_draft.clear(); + state.suggestions.clear(); + + let display_content = if attachments.is_empty() { + command.clone() + } else { + let labels: Vec<&str> = + attachments.iter().map(|a| a.label.as_str()).collect(); + format!("{command} [{}]", labels.join("] [")) + }; + + state.messages.push(ChatMessage { + role: MessageRole::User, + content: display_content, + timestamp: chrono::Utc::now(), + cost_summary: None, + }); + state.scroll_offset = 0; + state.pinned_to_bottom = true; + + if let Some(model) = command.strip_prefix("/model ") { + state.model = model.to_string(); + } + + update_local_thread_scope_after_submit(state, &command); + let thread_id = + outgoing_thread_scope(&command, state.current_thread_id.as_deref()); + let _ = msg_tx + .send(TuiUserMessage { + text: command, + attachments, + thread_id, + ui_action: None, + }) + .await; + } + } else if let Some(cmd) = state.command_palette.selected_command() { + state.command_palette.close(); + if cmd == "/model" { + if state.model_picker.has_models() { + widgets.input_box.set_text("/model "); + state.model_picker.open(""); + } else { + let command = cmd.to_string(); + let attachments = std::mem::take(&mut state.pending_attachments); + let _ = widgets.input_box.take_input(); + + if !command.is_empty() || !attachments.is_empty() { + state.awaiting_model_list = + attachments.is_empty() && command == "/model"; + if !command.is_empty() { + state.input_history.push(command.clone()); + } + state.history_index = None; + state.history_draft.clear(); + state.suggestions.clear(); + + let display_content = if attachments.is_empty() { + command.clone() + } else { + let labels: Vec<&str> = + attachments.iter().map(|a| a.label.as_str()).collect(); + format!("{command} [{}]", labels.join("] [")) + }; + + state.messages.push(ChatMessage { + role: MessageRole::User, + content: display_content, + timestamp: chrono::Utc::now(), + cost_summary: None, + }); + state.scroll_offset = 0; + state.pinned_to_bottom = true; + + update_local_thread_scope_after_submit(state, &command); + let thread_id = outgoing_thread_scope( + &command, + state.current_thread_id.as_deref(), + ); + let _ = msg_tx + .send(TuiUserMessage { + text: command, + attachments, + thread_id, + ui_action: None, + }) + .await; + } + } + } else { + let text = format!("{cmd} "); + widgets.input_box.set_text(&text); + } + } + } + InputAction::PaletteClose => { + state.model_picker.close(); + state.command_palette.close(); + } + InputAction::SearchToggle => { + state.search.active = !state.search.active; + if !state.search.active { + state.search.query.clear(); + state.search.match_count = 0; + state.search.current_match = 0; + } + } + InputAction::SearchNext => { + if state.search.match_count > 0 { + state.search.current_match = + (state.search.current_match + 1) % state.search.match_count; + } + } + InputAction::SearchPrev => { + if state.search.match_count > 0 { + state.search.current_match = if state.search.current_match == 0 { + state.search.match_count - 1 + } else { + state.search.current_match - 1 + }; + } + } + InputAction::HistoryUp => { + if !state.input_history.is_empty() { + let new_idx = match state.history_index { + None => { + // Save current draft, start from most recent + state.history_draft = widgets.input_box.current_text(); + state.input_history.len() - 1 + } + Some(idx) => idx.saturating_sub(1), + }; + state.history_index = Some(new_idx); + if let Some(text) = state.input_history.get(new_idx) { + widgets.input_box.set_text(text); + update_input_overlays_from_input(&widgets.input_box, state); + } + } + } + InputAction::HistoryDown => { + if let Some(idx) = state.history_index { + if idx + 1 >= state.input_history.len() { + // Back to draft + state.history_index = None; + let draft = state.history_draft.clone(); + widgets.input_box.set_text(&draft); + update_input_overlays_from_input(&widgets.input_box, state); + } else { + let new_idx = idx + 1; + state.history_index = Some(new_idx); + if let Some(text) = state.input_history.get(new_idx) { + widgets.input_box.set_text(text); + update_input_overlays_from_input(&widgets.input_box, state); + } + } + } + } + InputAction::ToggleHelp => { + state.help_visible = !state.help_visible; + } + InputAction::ExpandTool => { + // Show the most recent tool with a result preview + if let Some(tool) = state + .recent_tools + .iter() + .rev() + .find(|t| t.result_preview.is_some()) + { + state.tool_detail_modal = Some(ToolDetailModal { + tool_name: tool.name.clone(), + content: tool.result_preview.clone().unwrap_or_default(), + scroll: 0, + }); + } + } + InputAction::ToolDetailClose => { + state.tool_detail_modal = None; + } + InputAction::ToolDetailScrollUp => { + if let Some(ref mut modal) = state.tool_detail_modal { + modal.scroll = modal.scroll.saturating_add(5); + } + } + InputAction::ToolDetailScrollDown => { + if let Some(ref mut modal) = state.tool_detail_modal { + modal.scroll = modal.scroll.saturating_sub(5); + } + } + InputAction::LogFilter(level) => { + state.log_level_filter = level; + } + InputAction::ClipboardPaste => { + if let Some(attachment) = try_paste_clipboard_image(state) { + state.toasts.push(Toast { + message: format!("Pasted: {}", attachment.label), + kind: ToastKind::Info, + created_at: chrono::Utc::now(), + }); + state.pending_attachments.push(attachment); + } + } + InputAction::ThreadPickerUp => { + if let Some(ref mut picker) = state.pending_thread_picker { + crate::widgets::thread_picker::thread_picker_up(picker); + } + } + InputAction::ThreadPickerDown => { + if let Some(ref mut picker) = state.pending_thread_picker { + crate::widgets::thread_picker::thread_picker_down(picker); + } + } + InputAction::ThreadPickerSelect => { + if let Some(ref picker) = state.pending_thread_picker + && let Some(id) = + crate::widgets::thread_picker::thread_picker_selected_id(picker) + { + let cmd = format!("/thread {id}"); + let _ = msg_tx + .send(TuiUserMessage::text_only(cmd).with_thread_id(None)) + .await; + state.current_thread_id = Some(id.to_string()); + } + state.pending_thread_picker = None; + } + InputAction::ThreadPickerClose => { + state.pending_thread_picker = None; + } + InputAction::Forward => { + if state.search.active { + // Update the search query with the key event + match (key.code, key.modifiers) { + (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => { + state.search.query.push(c); + } + (KeyCode::Backspace, _) => { + state.search.query.pop(); + } + _ => {} + } + // Recount matches + state.search.match_count = + count_search_matches(&state.messages, &state.search.query); + // Clamp current_match + if state.search.match_count == 0 { + state.search.current_match = 0; + } else if state.search.current_match >= state.search.match_count { + state.search.current_match = state.search.match_count - 1; + } + } else if key.code == KeyCode::Backspace + && widgets.input_box.is_empty() + && !state.pending_attachments.is_empty() + { + let removed = state.pending_attachments.pop(); + if let Some(att) = removed { + state.toasts.push(Toast { + message: format!("Removed: {}", att.label), + kind: ToastKind::Info, + created_at: chrono::Utc::now(), + }); + } + } else { + widgets.input_box.handle_key(key, state); + // Update command palette visibility based on input content + update_input_overlays_from_input(&widgets.input_box, state); + } + } + } + } + + TuiEvent::MouseClick { column, row } => { + handle_mouse_click(column, row, state, msg_tx, layout).await; + } + + TuiEvent::MouseDrag { column, row } => { + handle_mouse_drag(column, row, state); + } + + TuiEvent::MouseRelease { column, row } => { + handle_mouse_release(column, row, state); + } + + TuiEvent::MouseScroll(delta) => { + if let Some(ref mut modal) = state.tool_detail_modal { + if delta < 0 { + modal.scroll = modal.scroll.saturating_add(delta.unsigned_abs()); + } else { + modal.scroll = modal.scroll.saturating_sub(delta as u16); + } + } else if let Some(ref mut picker) = state.pending_thread_picker { + if delta < 0 { + crate::widgets::thread_picker::thread_picker_up(picker); + } else if delta > 0 { + crate::widgets::thread_picker::thread_picker_down(picker); + } + } else if let Some(ref mut approval) = state.pending_approval { + let count = ApprovalWidget::options(approval.allow_always).len(); + if delta < 0 { + approval.selected = if approval.selected == 0 { + count - 1 + } else { + approval.selected - 1 + }; + } else if delta > 0 { + approval.selected = (approval.selected + 1) % count; + } + } else if !state.help_visible { + match state.active_tab { + ActiveTab::Conversation => { + widgets.conversation.scroll(state, delta); + } + ActiveTab::Logs => { + LogsWidget::scroll(state, delta); + } + } + } + } + + TuiEvent::Resize(_, _) => { + // Terminal will re-render on next frame + } + + TuiEvent::Tick => { + state.tick_count = state.tick_count.wrapping_add(1); + } + + TuiEvent::Thinking(msg) => { + state.status_text = msg; + } + + TuiEvent::ToolStarted { + name, + detail, + call_id, + } => { + state.status_text = match &detail { + Some(d) => format!("Running {name}: {d}"), + None => format!("Running {name}..."), + }; + state.active_tools.push(ToolActivity { + call_id, + name, + started_at: chrono::Utc::now(), + duration_ms: None, + status: ToolStatus::Running, + detail, + result_preview: None, + }); + } + + TuiEvent::ToolCompleted { + name, + success, + error: _, + call_id, + } => { + // Move from active to recent + if let Some(pos) = state + .active_tools + .iter() + .position(|t| tool_activity_matches(t, &name, call_id.as_deref())) + { + let mut tool = state.active_tools.remove(pos); + tool.duration_ms = Some( + chrono::Utc::now() + .signed_duration_since(tool.started_at) + .num_milliseconds() + .unsigned_abs(), + ); + tool.status = if success { + ToolStatus::Success + } else { + ToolStatus::Failed + }; + state.recent_tools.push(tool); + // Keep recent list bounded + if state.recent_tools.len() > 20 { + state.recent_tools.remove(0); + } + } + if state.active_tools.is_empty() { + state.status_text.clear(); + } + } + + TuiEvent::ToolResult { + name, + preview, + call_id, + } => { + if let Some(tool) = state + .active_tools + .iter_mut() + .find(|t| tool_activity_matches(t, &name, call_id.as_deref())) + { + tool.result_preview = Some(preview); + } else if let Some(tool) = state + .recent_tools + .iter_mut() + .rev() + .find(|t| tool_activity_matches(t, &name, call_id.as_deref())) + { + tool.result_preview = Some(preview); + } + } + + TuiEvent::StreamChunk(chunk) => { + state.is_streaming = true; + // Append to the last assistant message, or create one + if let Some(last) = state.messages.last_mut() { + if last.role == MessageRole::Assistant { + last.content.push_str(&chunk); + } else { + state.messages.push(ChatMessage { + role: MessageRole::Assistant, + content: chunk, + timestamp: chrono::Utc::now(), + cost_summary: None, + }); + } + } else { + state.messages.push(ChatMessage { + role: MessageRole::Assistant, + content: chunk, + timestamp: chrono::Utc::now(), + cost_summary: None, + }); + } + state.scroll_offset = 0; + state.pinned_to_bottom = true; + } + + TuiEvent::Status(msg) => { + state.status_text = msg; + } + + TuiEvent::Response { content, thread_id } => { + if let Some(thread_id) = thread_id { + state.current_thread_id = Some(thread_id); + } + let was_streaming = state.is_streaming; + state.is_streaming = false; + state.status_text.clear(); + let parsed_model_response = if state.awaiting_model_list { + parse_model_list_response(&content) + } else { + None + }; + state.awaiting_model_list = false; + // Streaming responses accumulate via StreamChunk; non-streaming + // responses still need a fresh assistant message. + if let Some(last) = state.messages.last_mut() { + if last.role == MessageRole::Assistant && was_streaming { + // Streaming finished — content was already accumulated + } else { + state.messages.push(ChatMessage { + role: MessageRole::Assistant, + content, + timestamp: chrono::Utc::now(), + cost_summary: None, + }); + } + } else { + state.messages.push(ChatMessage { + role: MessageRole::Assistant, + content, + timestamp: chrono::Utc::now(), + cost_summary: None, + }); + } + state.scroll_offset = 0; + state.pinned_to_bottom = true; + state.active_tools.clear(); + + if let Some((active_model, models)) = parsed_model_response { + state.model = active_model; + state.model_picker.set_models(models); + widgets.input_box.set_text("/model "); + update_input_overlays_from_input(&widgets.input_box, state); + } + } + + TuiEvent::JobStarted { job_id, title } => { + let now = chrono::Utc::now(); + state.messages.push(ChatMessage { + role: MessageRole::System, + content: format!("[job] {title} ({job_id})"), + timestamp: now, + cost_summary: None, + }); + state.toasts.push(Toast { + message: format!("Job started: {title}"), + kind: ToastKind::Info, + created_at: now, + }); + state.jobs.push(JobInfo { + id: job_id.clone(), + title: title.clone(), + status: JobStatus::Running, + started_at: now, + }); + } + + TuiEvent::JobStatus { job_id, status } => { + let new_status = match status.as_str() { + "running" | "in_progress" => JobStatus::Running, + "completed" | "done" => JobStatus::Completed, + "failed" => JobStatus::Failed, + _ => JobStatus::Running, + }; + if let Some(job) = state.jobs.iter_mut().find(|j| j.id == job_id) { + job.status = new_status; + } + } + + TuiEvent::JobResult { job_id, status } => { + let new_status = if status == "failed" { + JobStatus::Failed + } else { + JobStatus::Completed + }; + if let Some(job) = state.jobs.iter_mut().find(|j| j.id == job_id) { + job.status = new_status; + } + } + + TuiEvent::RoutineUpdate { + id, + name, + trigger_type, + enabled, + last_run, + next_fire, + } => { + // Upsert: update existing or insert new + if let Some(routine) = state.routines.iter_mut().find(|r| r.id == id) { + routine.name = name; + routine.trigger_type = trigger_type; + routine.enabled = enabled; + routine.last_run = last_run; + routine.next_fire = next_fire; + } else { + state.routines.push(RoutineInfo { + id, + name, + trigger_type, + enabled, + last_run, + next_fire, + }); + } + } + + TuiEvent::ApprovalNeeded { + request_id, + tool_name, + description, + parameters, + allow_always, + } => { + state.pending_approval = Some(super::widgets::ApprovalRequest { + request_id, + tool_name, + description, + parameters, + allow_always, + selected: 0, + }); + } + + TuiEvent::AuthRequired { + extension_name, + instructions, + } => { + let msg = if let Some(instr) = instructions { + format!("Authentication required for {extension_name}: {instr}") + } else { + format!("Authentication required for {extension_name}") + }; + state.toasts.push(Toast { + message: format!("Auth needed: {extension_name}"), + kind: ToastKind::Warning, + created_at: chrono::Utc::now(), + }); + state.messages.push(ChatMessage { + role: MessageRole::System, + content: msg, + timestamp: chrono::Utc::now(), + cost_summary: None, + }); + } + + TuiEvent::AuthCompleted { + extension_name, + success, + message, + } => { + let prefix = if success { "\u{2713}" } else { "\u{2717}" }; + state.toasts.push(Toast { + message: format!("{prefix} {extension_name}"), + kind: if success { + ToastKind::Success + } else { + ToastKind::Error + }, + created_at: chrono::Utc::now(), + }); + state.messages.push(ChatMessage { + role: MessageRole::System, + content: format!("{prefix} {extension_name}: {message}"), + timestamp: chrono::Utc::now(), + cost_summary: None, + }); + } + + TuiEvent::ReasoningUpdate { narrative } => { + if !narrative.is_empty() { + state.status_text = narrative; + } + } + + TuiEvent::TurnCost { + input_tokens, + output_tokens, + cost_usd, + } => { + state.total_input_tokens += input_tokens; + state.total_output_tokens += output_tokens; + state.total_cost_usd = cost_usd.clone(); + // Attach to last assistant message + if let Some(msg) = state + .messages + .iter_mut() + .rev() + .find(|m| m.role == MessageRole::Assistant) + { + msg.cost_summary = Some(TurnCostSummary { + input_tokens, + output_tokens, + cost_usd, + }); + } + } + + TuiEvent::Suggestions { suggestions } => { + state.suggestions = suggestions; + } + + TuiEvent::ContextPressure { + used_tokens, + max_tokens, + percentage, + warning, + } => { + // Update context_window from the engine's actual value + if max_tokens > 0 { + state.context_window = max_tokens; + } + state.context_pressure = Some(ContextPressureInfo { + used_tokens, + max_tokens, + percentage, + warning, + }); + } + + TuiEvent::SandboxStatus { + docker_available, + running_containers, + status, + } => { + state.sandbox_status = Some(SandboxInfo { + docker_available, + running_containers, + status, + }); + } + + TuiEvent::SecretsStatus { + count, + vault_unlocked, + } => { + state.secrets_status = Some(SecretsInfo { + count, + vault_unlocked, + }); + } + + TuiEvent::CostGuard { + session_budget_usd, + spent_usd, + remaining_usd, + limit_reached, + } => { + if limit_reached { + state.toasts.push(Toast { + message: "Cost limit reached".to_string(), + kind: ToastKind::Error, + created_at: chrono::Utc::now(), + }); + } + state.cost_guard = Some(CostGuardInfo { + session_budget_usd, + spent_usd, + remaining_usd, + limit_reached, + }); + } + + TuiEvent::Log { + level, + target, + message, + timestamp, + } => { + state.log_entries.push(TuiLogEntry { + level, + target, + message, + timestamp, + }); + } + + TuiEvent::ThreadList { threads } => { + // ThreadList only populates the /resume picker, not the sidebar. + // The sidebar THREADS section uses EngineThreadList instead. + state.pending_thread_picker = if threads.is_empty() { + None + } else { + Some(super::widgets::ThreadPickerState { + threads, + selected: 0, + }) + }; + } + + TuiEvent::EngineThreadList { threads } => { + state.engine_threads = threads + .iter() + .map(|t| EngineThreadInfo { + id: t.id.clone(), + goal: t.goal.clone(), + thread_type: t.thread_type.clone(), + status: match t.state.as_str() { + "Running" => ThreadStatus::Active, + "Completed" | "Done" => ThreadStatus::Completed, + "Failed" => ThreadStatus::Failed, + _ => ThreadStatus::Idle, + }, + step_count: t.step_count, + total_tokens: t.total_tokens, + started_at: parse_engine_thread_timestamp(&t.created_at, "created_at", &t.id), + updated_at: parse_engine_thread_timestamp(&t.updated_at, "updated_at", &t.id), + }) + .collect(); + } + + TuiEvent::EngineThreadDetail { detail } => { + state.tool_detail_modal = Some(ToolDetailModal { + tool_name: format!("Thread {}", detail.thread_type), + content: format_engine_thread_detail(&detail), + scroll: 0, + }); + } + + TuiEvent::ConversationHistory { + thread_id, + messages, + pending_approval, + } => { + state.current_thread_id = Some(thread_id.clone()); + state.messages.clear(); + state.active_tools.clear(); + state.recent_tools.clear(); + state.is_streaming = false; + state.status_text.clear(); + state.pending_approval = pending_approval.map(|approval| ApprovalRequest { + request_id: approval.request_id, + tool_name: approval.tool_name, + description: approval.description, + parameters: approval.parameters, + allow_always: approval.allow_always, + selected: 0, + }); + state.suggestions.clear(); + for thread in &mut state.threads { + thread.is_foreground = thread.id == thread_id; + thread.status = if thread.is_foreground { + ThreadStatus::Active + } else { + ThreadStatus::Idle + }; + } + + for msg in &messages { + let role = match msg.role.as_str() { + "user" => MessageRole::User, + "assistant" => MessageRole::Assistant, + _ => MessageRole::System, + }; + state.messages.push(ChatMessage { + role, + content: msg.content.clone(), + timestamp: msg.timestamp, + cost_summary: None, + }); + } + + state.scroll_offset = 0; + state.pinned_to_bottom = true; + state.toasts.push(Toast { + message: format!("Resumed conversation ({} messages)", state.messages.len()), + kind: ToastKind::Info, + created_at: chrono::Utc::now(), + }); + } + } +} + +fn resolve_key_action( + key: event::KeyEvent, + state: &AppState, + widgets: &BuiltinWidgets, +) -> InputAction { + let approval_active = state.pending_approval.is_some(); + let palette_active = state.command_palette.visible || state.model_picker.visible; + let search_active = state.search.active; + let help_active = state.help_visible; + let tool_detail_active = state.tool_detail_modal.is_some(); + let logs_active = state.active_tab == ActiveTab::Logs; + let thread_picker_active = state.pending_thread_picker.is_some(); + + let action = map_key( + key, + approval_active, + palette_active, + search_active, + help_active, + tool_detail_active, + logs_active, + thread_picker_active, + ); + + if action != InputAction::Forward { + return action; + } + + if key.modifiers != KeyModifiers::NONE + || approval_active + || palette_active + || search_active + || help_active + || tool_detail_active + || thread_picker_active + { + return InputAction::Forward; + } + + match key.code { + KeyCode::Up if widgets.input_box.is_cursor_on_first_line() => InputAction::HistoryUp, + KeyCode::Down + if state.history_index.is_some() || widgets.input_box.is_cursor_on_last_line() => + { + InputAction::HistoryDown + } + _ => InputAction::Forward, + } +} + +fn tool_activity_matches(tool: &ToolActivity, name: &str, call_id: Option<&str>) -> bool { + match call_id { + Some(call_id) => tool.call_id.as_deref() == Some(call_id), + None => tool.name == name, + } +} + +fn parse_model_list_response(content: &str) -> Option<(String, Vec)> { + let mut lines = content.lines(); + let active_model = lines + .next()? + .strip_prefix("Active model: ")? + .trim() + .to_string(); + + let mut in_model_section = false; + let mut models = Vec::new(); + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed == "Available models:" { + in_model_section = true; + continue; + } + + if !in_model_section { + continue; + } + + if trimmed.is_empty() || trimmed.starts_with("Use /model ") { + break; + } + + let model = trimmed + .strip_suffix(" (active)") + .unwrap_or(trimmed) + .trim() + .to_string(); + if !model.is_empty() { + models.push(model); + } + } + + if models.is_empty() { + None + } else { + Some((active_model, models)) + } +} + +fn format_detail_timestamp(raw: &str) -> String { + chrono::DateTime::parse_from_rfc3339(raw) + .map(|dt| dt.with_timezone(&chrono::Local)) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S %Z").to_string()) + .unwrap_or_else(|_| raw.to_string()) +} + +fn format_engine_thread_detail(detail: &crate::event::EngineThreadDetailEntry) -> String { + use std::fmt::Write as _; + + let mut content = String::new(); + let _ = writeln!(content, "Goal"); + let _ = writeln!(content, "{}", detail.goal); + let _ = writeln!(content); + + let _ = writeln!(content, "Overview"); + let _ = writeln!(content, " Thread ID: {}", detail.id); + let _ = writeln!(content, " Type: {}", detail.thread_type); + let _ = writeln!(content, " State: {}", detail.state); + let _ = writeln!(content, " Steps: {}", detail.step_count); + let _ = writeln!(content, " Tokens: {}", detail.total_tokens); + let _ = writeln!(content, " Cost: ${:.4}", detail.total_cost_usd); + let _ = writeln!(content, " Max iterations: {}", detail.max_iterations); + let _ = writeln!( + content, + " Created: {}", + format_detail_timestamp(&detail.created_at) + ); + let _ = writeln!( + content, + " Updated: {}", + format_detail_timestamp(&detail.updated_at) + ); + let completed = detail + .completed_at + .as_deref() + .map(format_detail_timestamp) + .unwrap_or_else(|| "-".to_string()); + let _ = writeln!(content, " Completed: {completed}"); + let _ = writeln!(content, " Project: {}", detail.project_id); + let _ = writeln!( + content, + " Parent: {}", + detail.parent_id.as_deref().unwrap_or("-") + ); + + if detail.messages.is_empty() { + return content; + } + + let _ = writeln!(content); + let _ = writeln!(content, "Messages ({})", detail.messages.len()); + for message in &detail.messages { + let _ = writeln!( + content, + "\n[{}] {}", + message.role, + format_detail_timestamp(&message.timestamp) + ); + let _ = writeln!(content, "{}", message.content); + } + + content +} + +struct TerminalRestoreGuard { + active: bool, +} + +impl TerminalRestoreGuard { + fn new() -> Self { + Self { active: true } + } + + fn disarm(&mut self) { + self.active = false; + } +} + +impl Drop for TerminalRestoreGuard { + fn drop(&mut self) { + if !self.active { + return; + } + + let _ = disable_raw_mode(); + let mut stdout = io::stdout(); + let _ = execute!( + stdout, + Show, + DisableBracketedPaste, + ratatui::crossterm::event::DisableMouseCapture, + LeaveAlternateScreen + ); + let _ = stdout.flush(); + } +} + +#[cfg(test)] +fn terminal_area() -> Rect { + Rect::new(0, 0, 80, 24) +} + +#[cfg(not(test))] +fn terminal_area() -> Rect { + ratatui::crossterm::terminal::size() + .map(|(width, height)| Rect::new(0, 0, width, height)) + .unwrap_or_else(|_| Rect::new(0, 0, 80, 24)) +} + +#[cfg(test)] +static LAST_COPIED_TEXT: std::sync::Mutex> = std::sync::Mutex::new(None); + +#[cfg(test)] +fn take_last_copied_text_for_test() -> Option { + LAST_COPIED_TEXT + .lock() + .expect("copied text mutex poisoned") + .take() +} + +fn copy_text_to_clipboard(text: &str) -> bool { + #[cfg(test)] + { + *LAST_COPIED_TEXT.lock().unwrap_or_else(|e| e.into_inner()) = Some(text.to_string()); + true + } + + #[cfg(not(test))] + { + arboard::Clipboard::new() + .and_then(|mut clipboard| clipboard.set_text(text.to_string())) + .is_ok() + } +} + +async fn handle_mouse_click( + column: u16, + row: u16, + state: &mut AppState, + msg_tx: &mpsc::Sender, + layout: &TuiLayout, +) { + let terminal = terminal_area(); + + if let Some(ref approval) = state.pending_approval + && let Some(action) = approval_action_at(terminal, approval, column, row) + { + let _ = msg_tx + .send( + TuiUserMessage::text_only(action.as_response()) + .with_thread_id(state.current_thread_id.clone()), + ) + .await; + state.pending_approval = None; + state.text_selection = None; + return; + } + + if let Some(ref picker) = state.pending_thread_picker { + if let Some(index) = thread_picker_index_at(terminal, picker, column, row) { + if let Some(thread) = picker.threads.get(index) { + let _ = msg_tx + .send( + TuiUserMessage::text_only(format!("/thread {}", thread.id)) + .with_thread_id(None), + ) + .await; + state.current_thread_id = Some(thread.id.clone()); + } + state.pending_thread_picker = None; + state.text_selection = None; + return; + } + + if !rect_contains( + ThreadPickerWidget::modal_area(terminal, picker.threads.len()), + column, + row, + ) { + state.pending_thread_picker = None; + } + state.text_selection = None; + return; + } + + if state.help_visible { + state.help_visible = false; + state.text_selection = None; + return; + } + + if let Some(tab) = tab_at(terminal, layout, state, column, row) { + state.active_tab = tab; + state.text_selection = None; + return; + } + + if state.tool_detail_modal.is_none() + && let Some(area) = thread_list_sidebar_area(terminal, layout, state) + && let Some(index) = engine_thread_index_at(area, state, column, row) + && let Some(thread) = state.engine_threads.get(index) + { + let _ = msg_tx + .send(TuiUserMessage::open_engine_thread_detail(thread.id.clone())) + .await; + state.text_selection = None; + return; + } + + if let Some(bounds) = selectable_area_at(terminal, layout, state, column, row) { + state.text_selection = Some(TextSelection { + anchor: SelectionPoint { column, row }, + focus: SelectionPoint { column, row }, + bounds, + }); + return; + } + + state.text_selection = None; + if state.tool_detail_modal.is_some() + && !rect_contains(tool_detail_modal_area(terminal), column, row) + { + state.tool_detail_modal = None; + } +} + +fn handle_mouse_drag(column: u16, row: u16, state: &mut AppState) { + if let Some(ref mut selection) = state.text_selection { + selection.focus = clamp_point_to_rect(SelectionPoint { column, row }, selection.bounds); + } +} + +fn handle_mouse_release(column: u16, row: u16, state: &mut AppState) { + let Some(ref mut selection) = state.text_selection else { + return; + }; + + selection.focus = clamp_point_to_rect(SelectionPoint { column, row }, selection.bounds); + + if selection.anchor == selection.focus { + state.text_selection = None; + return; + } + + let text = extract_selected_text(&state.screen_snapshot, selection); + if text.is_empty() { + state.text_selection = None; + return; + } + + let copied = copy_text_to_clipboard(&text); + state.toasts.push(Toast { + message: if copied { + format!("Copied {} chars", text.chars().count()) + } else { + "Copy failed".to_string() + }, + kind: if copied { + ToastKind::Success + } else { + ToastKind::Error + }, + created_at: chrono::Utc::now(), + }); +} + +fn frame_sections(size: Rect, layout: &TuiLayout, state: &AppState) -> [Rect; 5] { + let header_height = if layout.header.visible { 1 } else { 0 }; + let status_height = if layout.status_bar.visible { 1 } else { 0 }; + let tab_bar_height = 1u16; + let input_height = if state.pending_attachments.is_empty() { + 3u16 + } else { + 4u16 + }; + + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(header_height), + Constraint::Length(tab_bar_height), + Constraint::Min(4), + Constraint::Length(input_height), + Constraint::Length(status_height), + ]) + .split(size); + + [ + vertical[0], + vertical[1], + vertical[2], + vertical[3], + vertical[4], + ] +} + +fn tab_at( + size: Rect, + layout: &TuiLayout, + state: &AppState, + column: u16, + row: u16, +) -> Option { + let tab_bar_area = frame_sections(size, layout, state)[1]; + if !rect_contains(tab_bar_area, column, row) { + return None; + } + + let relative_x = column.saturating_sub(tab_bar_area.x); + if (2..6).contains(&relative_x) { + Some(ActiveTab::Conversation) + } else if (8..12).contains(&relative_x) { + Some(ActiveTab::Logs) + } else { + None + } +} + +fn selectable_area_at( + size: Rect, + layout: &TuiLayout, + state: &AppState, + column: u16, + row: u16, +) -> Option { + if state.tool_detail_modal.is_some() { + let inner = tool_detail_inner_area(tool_detail_modal_area(size)); + if rect_contains(inner, column, row) { + return Some(inner); + } + return None; + } + + let main_area = frame_sections(size, layout, state)[2]; + let selectable = match state.active_tab { + ActiveTab::Logs => main_area, + ActiveTab::Conversation => { + if state.sidebar_visible && main_area.width > 40 { + let sidebar_width = + (main_area.width as u32 * layout.sidebar.effective_width() as u32 / 100) as u16; + let conversation_width = main_area.width.saturating_sub(sidebar_width + 1); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(conversation_width), + Constraint::Length(1), + Constraint::Length(sidebar_width), + ]) + .split(main_area)[0] + } else { + main_area + } + } + }; + + rect_contains(selectable, column, row).then_some(selectable) +} + +fn thread_list_sidebar_area(size: Rect, layout: &TuiLayout, state: &AppState) -> Option { + if state.active_tab != ActiveTab::Conversation || !state.sidebar_visible { + return None; + } + + let main_area = frame_sections(size, layout, state)[2]; + if main_area.width <= 40 { + return None; + } + + let sidebar_width = + (main_area.width as u32 * layout.sidebar.effective_width() as u32 / 100) as u16; + let conversation_width = main_area.width.saturating_sub(sidebar_width + 1); + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(conversation_width), + Constraint::Length(1), + Constraint::Length(sidebar_width), + ]) + .split(main_area); + let sidebar_area = horizontal[2]; + let sidebar_split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(sidebar_area); + Some(sidebar_split[1]) +} + +fn approval_action_at( + size: Rect, + approval: &ApprovalRequest, + column: u16, + row: u16, +) -> Option { + let area = ApprovalWidget::modal_area(size); + if !rect_contains(area, column, row) { + return None; + } + + let params_count = approval + .parameters + .as_object() + .map(|obj: &serde_json::Map| obj.len().min(4) as u16) + .unwrap_or(0); + let options_start_y = area.y + 1 + 3 + params_count; + let options = ApprovalWidget::options(approval.allow_always); + let index = row.checked_sub(options_start_y)? as usize; + options.get(index).copied() +} + +fn thread_picker_index_at( + size: Rect, + picker: &crate::widgets::ThreadPickerState, + column: u16, + row: u16, +) -> Option { + let area = ThreadPickerWidget::modal_area(size, picker.threads.len()); + if !rect_contains(area, column, row) { + return None; + } + + let inner = Rect::new( + area.x.saturating_add(1), + area.y.saturating_add(1), + area.width.saturating_sub(2), + area.height.saturating_sub(2), + ); + if inner.height < 2 || row >= inner.y + inner.height.saturating_sub(1) { + return None; + } + + let list_height = inner.height.saturating_sub(1) as usize; + let scroll_offset = if picker.selected >= list_height { + picker.selected - list_height + 1 + } else { + 0 + }; + + let row_index = row.checked_sub(inner.y)? as usize; + let thread_index = scroll_offset + row_index; + picker.threads.get(thread_index)?; + Some(thread_index) +} + +fn tool_detail_modal_area(size: Rect) -> Rect { + let width = (size.width * 3 / 4) + .max(40) + .min(size.width.saturating_sub(4)); + let height = (size.height * 3 / 4) + .max(10) + .min(size.height.saturating_sub(4)); + let x = (size.width.saturating_sub(width)) / 2; + let y = (size.height.saturating_sub(height)) / 2; + Rect::new(x, y, width, height) +} + +fn tool_detail_inner_area(size: Rect) -> Rect { + Rect::new( + size.x.saturating_add(1), + size.y.saturating_add(1), + size.width.saturating_sub(2), + size.height.saturating_sub(2), + ) +} + +fn rect_contains(rect: Rect, column: u16, row: u16) -> bool { + column >= rect.x && column < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height +} + +fn clamp_point_to_rect(point: SelectionPoint, bounds: Rect) -> SelectionPoint { + let max_column = bounds.x + bounds.width.saturating_sub(1); + let max_row = bounds.y + bounds.height.saturating_sub(1); + SelectionPoint { + column: point.column.clamp(bounds.x, max_column), + row: point.row.clamp(bounds.y, max_row), + } +} + +fn normalize_selection(selection: &TextSelection) -> (SelectionPoint, SelectionPoint) { + if selection.anchor.row < selection.focus.row + || (selection.anchor.row == selection.focus.row + && selection.anchor.column <= selection.focus.column) + { + (selection.anchor, selection.focus) + } else { + (selection.focus, selection.anchor) + } +} + +fn extract_selected_text(snapshot: &ScreenSnapshot, selection: &TextSelection) -> String { + let (start, end) = normalize_selection(selection); + let mut lines = Vec::new(); + + for row in start.row..=end.row { + let start_col = if row == start.row { + start.column + } else { + selection.bounds.x + }; + let end_col = if row == end.row { + end.column + } else { + selection.bounds.x + selection.bounds.width.saturating_sub(1) + }; + + let mut line = String::new(); + for column in start_col..=end_col { + if let Some(symbol) = snapshot_symbol(snapshot, column, row) { + line.push_str(symbol); + } + } + lines.push(line.trim_end().to_string()); + } + + lines.join("\n").trim_end_matches('\n').to_string() +} + +fn snapshot_symbol(snapshot: &ScreenSnapshot, column: u16, row: u16) -> Option<&str> { + if !rect_contains(snapshot.area, column, row) { + return None; + } + + Some(snapshot.buffer[(column, row)].symbol()) +} + +/// Render a single frame. +fn render_frame( + frame: &mut ratatui::Frame<'_>, + state: &mut AppState, + widgets: &BuiltinWidgets, + layout: &TuiLayout, +) { + let size = frame.area(); + let [ + header_area, + tab_bar_area, + main_area, + input_area, + status_area, + ] = frame_sections(size, layout, state); + + // Header + if layout.header.visible { + widgets + .header + .render(header_area, frame.buffer_mut(), state); + } + + // Tab bar + widgets + .tab_bar + .render(tab_bar_area, frame.buffer_mut(), state); + + // Track conversation area height for page-scroll calculations + state.conversation_height = main_area.height; + + // Main area: conversation/logs | sidebar + match state.active_tab { + ActiveTab::Logs => { + // Logs tab takes the full main area (no sidebar) + widgets.logs.render(main_area, frame.buffer_mut(), state); + } + ActiveTab::Conversation => { + if state.sidebar_visible && main_area.width > 40 { + let sidebar_width = + (main_area.width as u32 * layout.sidebar.effective_width() as u32 / 100) as u16; + let conversation_width = main_area.width.saturating_sub(sidebar_width + 1); + + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(conversation_width), + Constraint::Length(1), // border + Constraint::Length(sidebar_width), + ]) + .split(main_area); + + let conv_area = horizontal[0]; + let border_area = horizontal[1]; + let sidebar_area = horizontal[2]; + + widgets + .conversation + .render(conv_area, frame.buffer_mut(), state); + + // Vertical border + render_vertical_border(frame, border_area, layout); + + // Split sidebar into tool panel and thread list + let sidebar_split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(sidebar_area); + + widgets + .tool_panel + .render(sidebar_split[0], frame.buffer_mut(), state); + widgets + .thread_list + .render(sidebar_split[1], frame.buffer_mut(), state); + } else { + widgets + .conversation + .render(main_area, frame.buffer_mut(), state); + } + } + } + + // Input area with top border + let input_split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(input_area); + + render_horizontal_border(frame, input_split[0], layout); + widgets + .input_box + .render(input_split[1], frame.buffer_mut(), state); + + // Status bar + if layout.status_bar.visible { + render_horizontal_border(frame, status_area, layout); + // Status bar renders on same line as border (overwriting) + widgets + .status_bar + .render(status_area, frame.buffer_mut(), state); + } + + // Command palette overlay (above input area) + if state.command_palette.visible && !state.command_palette.filtered.is_empty() { + let palette_area = CommandPaletteWidget::palette_area( + size, + input_area, + state.command_palette.filtered.len(), + ); + if palette_area.height > 0 { + widgets.command_palette.render_palette( + palette_area, + frame.buffer_mut(), + &state.command_palette, + ); + } + } + + if state.model_picker.visible { + let modal_area = ModelPickerWidget::modal_area(size, state.model_picker.filtered.len()); + widgets + .model_picker + .render_picker(modal_area, frame.buffer_mut(), state); + } + + // Approval modal (rendered on top of everything) + if state.pending_approval.is_some() { + let modal_area = ApprovalWidget::modal_area(size); + widgets + .approval + .render(modal_area, frame.buffer_mut(), state); + } + + // Thread picker modal (/resume) + if let Some(ref picker) = state.pending_thread_picker { + let modal_area = crate::widgets::thread_picker::ThreadPickerWidget::modal_area( + size, + picker.threads.len(), + ); + widgets + .thread_picker + .render_picker(modal_area, frame.buffer_mut(), state); + } + + // Tool detail modal (Ctrl+E) + if state.tool_detail_modal.is_some() { + render_tool_detail_modal(frame, size, state, layout); + } + + // Help overlay (F1) + if state.help_visible { + let help_area = HelpOverlayWidget::modal_area(size); + widgets.help.render(help_area, frame.buffer_mut(), state); + } + + render_text_selection(frame, state, layout); + + // Notification toasts (bottom-right, above status bar) + render_toasts(frame, size, state, layout); + + capture_screen_snapshot(frame, state); +} + +/// Check input text and update slash-command overlays. +fn update_input_overlays_from_input( + input_box: &crate::widgets::input_box::InputBoxWidget, + state: &mut AppState, +) { + let text = input_box.current_text(); + let trimmed = text.trim(); + + if state.model_picker.has_models() && (trimmed == "/model" || trimmed.starts_with("/model ")) { + let filter = trimmed + .split_once(' ') + .map(|(_, rest)| rest.trim()) + .unwrap_or(""); + state.command_palette.close(); + state.model_picker.open(filter); + return; + } + + state.model_picker.close(); + + if trimmed.starts_with('/') && !trimmed.contains(' ') { + // Text after the leading '/' + let filter = &trimmed[1..]; + state.command_palette.open(filter); + } else { + state.command_palette.close(); + } +} + +/// Render a vertical border line. +fn render_vertical_border(frame: &mut ratatui::Frame<'_>, area: Rect, layout: &TuiLayout) { + let theme = layout.resolve_theme(); + let border_style = theme.border_style(); + + for y in area.y..area.y + area.height { + if let Some(cell) = frame.buffer_mut().cell_mut((area.x, y)) { + cell.set_symbol("\u{2502}"); + cell.set_style(border_style); + } + } +} + +/// Render a horizontal border line. +fn render_horizontal_border(frame: &mut ratatui::Frame<'_>, area: Rect, layout: &TuiLayout) { + let theme = layout.resolve_theme(); + let border_style = theme.border_style(); + + for x in area.x..area.x + area.width { + if let Some(cell) = frame.buffer_mut().cell_mut((x, area.y)) { + cell.set_symbol("\u{2500}"); + cell.set_style(border_style); + } + } +} + +/// Render the tool detail modal (Ctrl+E). +#[allow(clippy::cast_possible_truncation)] +fn render_tool_detail_modal( + frame: &mut ratatui::Frame<'_>, + size: Rect, + state: &AppState, + layout: &TuiLayout, +) { + use ratatui::style::Modifier; + use ratatui::text::Span; + use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget}; + + let Some(ref modal) = state.tool_detail_modal else { + return; + }; + let theme = layout.resolve_theme(); + + let width = (size.width * 3 / 4) + .max(40) + .min(size.width.saturating_sub(4)); + let height = (size.height * 3 / 4) + .max(10) + .min(size.height.saturating_sub(4)); + let x = (size.width.saturating_sub(width)) / 2; + let y = (size.height.saturating_sub(height)) / 2; + let area = Rect::new(x, y, width, height); + + Clear.render(area, frame.buffer_mut()); + + let title = format!(" {} ", modal.tool_name); + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.accent_style()) + .title(Span::styled( + title, + theme.accent_style().add_modifier(Modifier::BOLD), + )); + let inner = block.inner(area); + block.render(area, frame.buffer_mut()); + + let lines = crate::render::render_markdown(&modal.content, inner.width as usize, &theme); + + let paragraph = Paragraph::new(lines).scroll((modal.scroll, 0)); + paragraph.render(inner, frame.buffer_mut()); +} + +/// Render notification toasts in the bottom-right corner. +fn render_toasts( + frame: &mut ratatui::Frame<'_>, + size: Rect, + state: &mut AppState, + layout: &TuiLayout, +) { + use ratatui::style::Modifier; + use ratatui::text::{Line, Span}; + use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget}; + + // Prune expired toasts (older than 5 seconds) + let now = chrono::Utc::now(); + state + .toasts + .retain(|t| now.signed_duration_since(t.created_at).num_seconds() < 5); + + if state.toasts.is_empty() { + return; + } + + let theme = layout.resolve_theme(); + let max_toasts = 3usize; + let toast_width = 40u16.min(size.width.saturating_sub(2)); + + // Stack toasts from bottom up, above status bar + let start_y = size.height.saturating_sub(3); // above status bar + input + let visible_toasts = state.toasts.iter().rev().take(max_toasts); + + for (i, toast) in visible_toasts.enumerate() { + let y = start_y.saturating_sub((i as u16) * 3); + let x = size.width.saturating_sub(toast_width + 1); + let area = Rect::new(x, y, toast_width, 3); + + if area.y == 0 { + continue; + } + + Clear.render(area, frame.buffer_mut()); + + let (icon, border_style) = match toast.kind { + ToastKind::Info => ("\u{2139}", theme.accent_style()), + ToastKind::Success => ("\u{2713}", theme.success_style()), + ToastKind::Warning => ("\u{26A0}", theme.warning_style()), + ToastKind::Error => ("\u{2717}", theme.error_style()), + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style); + let inner = block.inner(area); + block.render(area, frame.buffer_mut()); + + let msg_width = inner.width as usize; + let display_msg = if toast.message.len() > msg_width.saturating_sub(3) { + format!( + "{}...", + &toast.message[..msg_width.saturating_sub(6).min(toast.message.len())] + ) + } else { + toast.message.clone() + }; + + let line = Line::from(vec![ + Span::styled( + format!(" {icon} "), + border_style.add_modifier(Modifier::BOLD), + ), + Span::styled( + display_msg, + ratatui::style::Style::default().fg(theme.fg.to_color()), + ), + ]); + let paragraph = Paragraph::new(line); + paragraph.render(inner, frame.buffer_mut()); + } +} + +fn render_text_selection(frame: &mut ratatui::Frame<'_>, state: &AppState, layout: &TuiLayout) { + let Some(ref selection) = state.text_selection else { + return; + }; + + let (start, end) = normalize_selection(selection); + let theme = layout.resolve_theme(); + let selection_style = ratatui::style::Style::default() + .bg(theme.accent.to_color()) + .fg(ratatui::style::Color::Black); + + for row in start.row..=end.row { + let start_col = if row == start.row { + start.column + } else { + selection.bounds.x + }; + let end_col = if row == end.row { + end.column + } else { + selection.bounds.x + selection.bounds.width.saturating_sub(1) + }; + + for column in start_col..=end_col { + if let Some(cell) = frame.buffer_mut().cell_mut((column, row)) { + cell.set_style(selection_style); + } + } + } +} + +fn capture_screen_snapshot(frame: &mut ratatui::Frame<'_>, state: &mut AppState) { + state.screen_snapshot = ScreenSnapshot { + area: frame.area(), + buffer: frame.buffer_mut().clone(), + }; +} + +/// Try to read an image from the system clipboard and return it as a PNG-encoded +/// [`TuiAttachment`]. Returns `None` if the clipboard has no image data or if +/// encoding fails. +fn try_paste_clipboard_image(state: &AppState) -> Option { + let mut clipboard = arboard::Clipboard::new().ok()?; + let img_data = clipboard.get_image().ok()?; + + let png_bytes = encode_rgba_to_png( + &img_data.bytes, + img_data.width as u32, + img_data.height as u32, + )?; + + let n = state.pending_attachments.len() + 1; + Some(TuiAttachment { + data: png_bytes, + mime_type: "image/png".to_string(), + label: format!("Image {n}"), + }) +} + +/// Encode raw RGBA pixel data to PNG. Returns `None` on invalid dimensions or +/// encoding failure. +fn encode_rgba_to_png(rgba: &[u8], width: u32, height: u32) -> Option> { + let expected_len = (width as usize) + .checked_mul(height as usize)? + .checked_mul(4)?; + if rgba.len() != expected_len { + return None; + } + + let buf: image::ImageBuffer, &[u8]> = + image::ImageBuffer::from_raw(width, height, rgba)?; + let mut png_bytes: Vec = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut png_bytes); + buf.write_to(&mut cursor, image::ImageFormat::Png).ok()?; + Some(png_bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::{HistoryMessage, ThreadEntry}; + use crate::widgets::approval::ApprovalWidget; + use crate::widgets::registry::create_default_widgets; + use crate::widgets::thread_picker::ThreadPickerWidget; + use crate::widgets::{ActiveTab, ApprovalRequest, MessageRole, ThreadStatus}; + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::layout::Rect; + + async fn apply_event(state: &mut AppState, event: TuiEvent) { + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + let (msg_tx, _msg_rx) = mpsc::channel(4); + handle_event(event, state, &mut widgets, &msg_tx, &layout).await; + } + + async fn apply_event_and_take_messages( + state: &mut AppState, + event: TuiEvent, + ) -> Vec { + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + let (msg_tx, mut msg_rx) = mpsc::channel(4); + handle_event(event, state, &mut widgets, &msg_tx, &layout).await; + + let mut messages = Vec::new(); + while let Ok(message) = msg_rx.try_recv() { + messages.push(message); + } + messages + } + + fn make_snapshot(width: u16, height: u16) -> ScreenSnapshot { + let area = Rect::new(0, 0, width, height); + ScreenSnapshot { + area, + buffer: ratatui::buffer::Buffer::empty(area), + } + } + + fn write_snapshot_text(snapshot: &mut ScreenSnapshot, column: u16, row: u16, text: &str) { + for (offset, ch) in text.chars().enumerate() { + snapshot.buffer[(column + offset as u16, row)].set_symbol(&ch.to_string()); + } + } + + #[test] + fn encode_rgba_to_png_valid() { + // 2x2 red image + let rgba = vec![ + 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, + ]; + let png = encode_rgba_to_png(&rgba, 2, 2); + assert!(png.is_some()); + let bytes = png.unwrap(); + // PNG signature starts with 0x89 'P' 'N' 'G' + assert!(bytes.len() > 8); + assert_eq!(&bytes[..4], &[0x89, b'P', b'N', b'G']); + } + + #[test] + fn encode_rgba_to_png_bad_dimensions() { + let rgba = vec![0u8; 16]; // 4 pixels + // Claim 3x2 = 6 pixels, but only 4 are provided + let png = encode_rgba_to_png(&rgba, 3, 2); + assert!(png.is_none()); + } + + #[test] + fn encode_rgba_to_png_zero_size() { + // 0x0 image: the image crate rejects zero-dimension buffers + let png = encode_rgba_to_png(&[], 0, 0); + assert!(png.is_none()); + } + + #[tokio::test] + async fn response_appends_after_existing_assistant_message_when_not_streaming() { + let mut state = AppState::default(); + state.messages.push(ChatMessage { + role: MessageRole::Assistant, + content: "first reply".to_string(), + timestamp: chrono::Utc::now(), + cost_summary: None, + }); + + apply_event( + &mut state, + TuiEvent::Response { + content: "background notification".to_string(), + thread_id: None, + }, + ) + .await; + + assert_eq!(state.messages.len(), 2); + assert_eq!(state.messages[1].content, "background notification"); + } + + #[tokio::test] + async fn response_tracks_active_thread_id() { + let mut state = AppState::default(); + + apply_event( + &mut state, + TuiEvent::Response { + content: "ok".to_string(), + thread_id: Some("thread-42".to_string()), + }, + ) + .await; + + assert_eq!(state.current_thread_id.as_deref(), Some("thread-42")); + } + + #[tokio::test] + async fn thread_list_only_populates_picker() { + let mut state = AppState::default(); + + apply_event( + &mut state, + TuiEvent::ThreadList { + threads: vec![ThreadEntry { + id: "thread-1".to_string(), + title: Some("Bug bash".to_string()), + message_count: 3, + last_activity: "2026-04-03 12:00".to_string(), + channel: "repl".to_string(), + }], + }, + ) + .await; + + // ThreadList no longer populates the sidebar — only the picker. + assert!(state.engine_threads.is_empty()); + assert!(state.pending_thread_picker.is_some()); + assert_eq!( + state.pending_thread_picker.as_ref().unwrap().threads.len(), + 1 + ); + } + + #[tokio::test] + async fn engine_thread_list_updates_sidebar() { + let mut state = AppState::default(); + + apply_event( + &mut state, + TuiEvent::EngineThreadList { + threads: vec![crate::event::EngineThreadEntry { + id: "eng-1".to_string(), + goal: "fix login".to_string(), + thread_type: "Foreground".to_string(), + state: "Running".to_string(), + step_count: 3, + total_tokens: 800, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }], + }, + ) + .await; + + assert_eq!(state.engine_threads.len(), 1); + assert_eq!(state.engine_threads[0].goal, "fix login"); + assert_eq!(state.engine_threads[0].thread_type, "Foreground"); + assert_eq!(state.engine_threads[0].status, ThreadStatus::Active); + } + + #[tokio::test] + async fn engine_thread_detail_opens_modal() { + let mut state = AppState::default(); + + apply_event( + &mut state, + TuiEvent::EngineThreadDetail { + detail: crate::event::EngineThreadDetailEntry { + id: "eng-1".to_string(), + goal: "Send the top three Hacker News stories".to_string(), + thread_type: "Mission".to_string(), + state: "Running".to_string(), + project_id: "proj-1".to_string(), + parent_id: None, + step_count: 7, + total_tokens: 2_048, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + max_iterations: 24, + completed_at: None, + total_cost_usd: 0.1234, + messages: vec![crate::event::EngineThreadMessageEntry { + role: "Assistant".to_string(), + content: "Fetching the latest stories.".to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + }], + }, + }, + ) + .await; + + let modal = state + .tool_detail_modal + .as_ref() + .expect("thread detail modal should open"); + assert_eq!(modal.tool_name, "Thread Mission"); + assert!(modal.content.contains("Goal")); + assert!( + modal + .content + .contains("Send the top three Hacker News stories") + ); + assert!(modal.content.contains("Messages (1)")); + assert!(modal.content.contains("Fetching the latest stories.")); + } + + #[tokio::test] + async fn empty_thread_list_clears_picker() { + let mut state = AppState::default(); + + apply_event( + &mut state, + TuiEvent::ThreadList { + threads: vec![ThreadEntry { + id: "thread-1".to_string(), + title: Some("Bug bash".to_string()), + message_count: 3, + last_activity: "2026-04-03 12:00".to_string(), + channel: "repl".to_string(), + }], + }, + ) + .await; + assert!(state.pending_thread_picker.is_some()); + + apply_event(&mut state, TuiEvent::ThreadList { threads: vec![] }).await; + + assert!(state.pending_thread_picker.is_none()); + } + + #[tokio::test] + async fn job_events_do_not_populate_thread_sidebar() { + let mut state = AppState::default(); + + apply_event( + &mut state, + TuiEvent::JobStarted { + job_id: "job-1".to_string(), + title: "Backfill".to_string(), + }, + ) + .await; + + assert_eq!(state.jobs.len(), 1); + assert!(state.threads.is_empty()); + } + + #[tokio::test] + async fn mouse_scroll_moves_thread_picker_selection() { + let mut state = AppState { + pending_thread_picker: Some(crate::widgets::ThreadPickerState { + threads: vec![ + ThreadEntry { + id: "thread-1".to_string(), + title: Some("Bug bash".to_string()), + message_count: 3, + last_activity: "2026-04-03 12:00".to_string(), + channel: "repl".to_string(), + }, + ThreadEntry { + id: "thread-2".to_string(), + title: Some("Release prep".to_string()), + message_count: 8, + last_activity: "2026-04-03 13:00".to_string(), + channel: "repl".to_string(), + }, + ], + selected: 0, + }), + ..Default::default() + }; + + apply_event(&mut state, TuiEvent::MouseScroll(3)).await; + + assert_eq!( + state + .pending_thread_picker + .as_ref() + .map(|picker| picker.selected), + Some(1) + ); + } + + #[tokio::test] + async fn mouse_click_switches_active_tab() { + let mut state = AppState::default(); + + apply_event(&mut state, TuiEvent::MouseClick { column: 9, row: 0 }).await; + + assert_eq!(state.active_tab, ActiveTab::Logs); + } + + #[tokio::test] + async fn mouse_click_engine_thread_row_requests_detail_modal_data() { + let now = chrono::Utc::now(); + let mut state = AppState { + engine_threads: vec![EngineThreadInfo { + id: "eng-1".to_string(), + goal: "Check Hacker News hourly".to_string(), + thread_type: "Mission".to_string(), + status: ThreadStatus::Active, + step_count: 5, + total_tokens: 4_096, + started_at: Some(now - chrono::Duration::minutes(9)), + updated_at: Some(now), + }], + ..Default::default() + }; + + let layout = TuiLayout::default(); + let area = thread_list_sidebar_area(Rect::new(0, 0, 80, 24), &layout, &state) + .expect("thread list area should exist"); + let click = (area.y..area.y + area.height) + .find_map(|row| { + (area.x..area.x + area.width).find_map(|column| { + (engine_thread_index_at(area, &state, column, row) == Some(0)) + .then_some((column, row)) + }) + }) + .expect("expected a clickable engine thread row"); + + let messages = apply_event_and_take_messages( + &mut state, + TuiEvent::MouseClick { + column: click.0, + row: click.1, + }, + ) + .await; + + assert_eq!(messages.len(), 1); + assert!(messages[0].text.is_empty()); + assert!(messages[0].thread_id.is_none()); + match &messages[0].ui_action { + Some(crate::event::TuiUiAction::OpenEngineThreadDetail { thread_id }) => { + assert_eq!(thread_id, "eng-1"); + } + other => panic!("expected engine thread detail action, got {other:?}"), + } + } + + #[tokio::test] + async fn mouse_click_approval_option_submits_response() { + let mut state = AppState { + pending_approval: Some(ApprovalRequest { + request_id: "req-1".to_string(), + tool_name: "shell".to_string(), + description: "Run a command".to_string(), + parameters: serde_json::json!({}), + allow_always: false, + selected: 0, + }), + ..Default::default() + }; + + let area = ApprovalWidget::modal_area(Rect::new(0, 0, 80, 24)); + let messages = apply_event_and_take_messages( + &mut state, + TuiEvent::MouseClick { + column: area.x + 3, + row: area.y + 5, + }, + ) + .await; + + assert!(state.pending_approval.is_none()); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].text, "n"); + } + + #[tokio::test] + async fn conversation_history_restores_pending_approval() { + let mut state = AppState { + pending_approval: Some(ApprovalRequest { + request_id: "stale".to_string(), + tool_name: "old-tool".to_string(), + description: "stale approval".to_string(), + parameters: serde_json::json!({"old": true}), + allow_always: false, + selected: 2, + }), + ..Default::default() + }; + + apply_event( + &mut state, + TuiEvent::ConversationHistory { + thread_id: "thread-1".to_string(), + messages: vec![HistoryMessage { + role: "assistant".to_string(), + content: "Waiting on approval".to_string(), + timestamp: chrono::Utc::now(), + }], + pending_approval: Some(crate::event::HistoryApprovalRequest { + request_id: "req-1".to_string(), + tool_name: "shell".to_string(), + description: "Run a command".to_string(), + parameters: serde_json::json!({"command": "[REDACTED]"}), + allow_always: true, + }), + }, + ) + .await; + + let approval = state + .pending_approval + .as_ref() + .expect("pending approval should be restored"); + assert_eq!(approval.request_id, "req-1"); + assert_eq!(approval.tool_name, "shell"); + assert_eq!(approval.description, "Run a command"); + assert_eq!( + approval.parameters, + serde_json::json!({"command": "[REDACTED]"}) + ); + assert!(approval.allow_always); + assert_eq!(approval.selected, 0); + } + + #[tokio::test] + async fn mouse_click_thread_picker_row_resumes_thread() { + let mut state = AppState { + pending_thread_picker: Some(crate::widgets::ThreadPickerState { + threads: vec![ + ThreadEntry { + id: "thread-1".to_string(), + title: Some("Bug bash".to_string()), + message_count: 3, + last_activity: "2026-04-03 12:00".to_string(), + channel: "repl".to_string(), + }, + ThreadEntry { + id: "thread-2".to_string(), + title: Some("Release prep".to_string()), + message_count: 8, + last_activity: "2026-04-03 13:00".to_string(), + channel: "repl".to_string(), + }, + ], + selected: 0, + }), + ..Default::default() + }; + + let area = ThreadPickerWidget::modal_area(Rect::new(0, 0, 80, 24), 2); + let messages = apply_event_and_take_messages( + &mut state, + TuiEvent::MouseClick { + column: area.x + 3, + row: area.y + 2, + }, + ) + .await; + + assert!(state.pending_thread_picker.is_none()); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].text, "/thread thread-2"); + assert!(messages[0].thread_id.is_none()); + assert_eq!(state.current_thread_id.as_deref(), Some("thread-2")); + } + + #[tokio::test] + async fn mouse_drag_and_release_copies_selected_text() { + let mut state = AppState { + active_tab: ActiveTab::Logs, + screen_snapshot: make_snapshot(80, 24), + ..Default::default() + }; + write_snapshot_text(&mut state.screen_snapshot, 1, 2, "hello world"); + take_last_copied_text_for_test(); + + apply_event(&mut state, TuiEvent::MouseClick { column: 1, row: 2 }).await; + apply_event(&mut state, TuiEvent::MouseDrag { column: 5, row: 2 }).await; + apply_event(&mut state, TuiEvent::MouseRelease { column: 5, row: 2 }).await; + + assert_eq!(take_last_copied_text_for_test().as_deref(), Some("hello")); + assert!(state.text_selection.is_some()); + } + + #[test] + fn extract_selected_text_preserves_multiline_range() { + let mut snapshot = make_snapshot(20, 4); + write_snapshot_text(&mut snapshot, 0, 1, "first line"); + write_snapshot_text(&mut snapshot, 0, 2, "second line"); + + let selection = TextSelection { + anchor: SelectionPoint { column: 2, row: 1 }, + focus: SelectionPoint { column: 5, row: 2 }, + bounds: Rect::new(0, 1, 20, 2), + }; + + assert_eq!( + extract_selected_text(&snapshot, &selection), + "rst line\nsecond" + ); + } + + #[test] + fn parse_engine_thread_timestamp_accepts_rfc3339_and_legacy_format() { + let rfc3339 = + parse_engine_thread_timestamp("2026-04-06T05:56:16Z", "created_at", "thread-1"); + let legacy = parse_engine_thread_timestamp("2026-04-06 05:56", "created_at", "thread-1"); + + assert_eq!( + rfc3339, + Some( + chrono::DateTime::parse_from_rfc3339("2026-04-06T05:56:16Z") + .expect("valid rfc3339") + .with_timezone(&chrono::Utc) + ) + ); + assert_eq!( + legacy, + Some( + chrono::NaiveDateTime::parse_from_str("2026-04-06 05:56", "%Y-%m-%d %H:%M") + .expect("valid legacy timestamp") + .and_utc() + ) + ); + } + + #[test] + fn parse_engine_thread_timestamp_returns_none_for_invalid_input() { + assert_eq!( + parse_engine_thread_timestamp("not-a-timestamp", "created_at", "thread-1"), + None + ); + } + + async fn apply_event_with_widgets( + state: &mut AppState, + widgets: &mut BuiltinWidgets, + event: TuiEvent, + ) { + let layout = TuiLayout::default(); + let (msg_tx, _msg_rx) = mpsc::channel(4); + handle_event(event, state, widgets, &msg_tx, &layout).await; + } + + #[tokio::test] + async fn up_arrow_recalls_latest_history_from_input_bar() { + let mut state = AppState { + input_history: vec!["first prompt".to_string(), "latest prompt".to_string()], + ..Default::default() + }; + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + + apply_event_with_widgets( + &mut state, + &mut widgets, + TuiEvent::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)), + ) + .await; + + assert_eq!(widgets.input_box.current_text(), "latest prompt"); + assert_eq!(state.history_index, Some(1)); + } + + #[tokio::test] + async fn up_arrow_inside_multiline_draft_keeps_editing_instead_of_history() { + let mut state = AppState { + input_history: vec!["latest prompt".to_string()], + ..Default::default() + }; + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + widgets.input_box.set_text("first line\nsecond line"); + widgets + .input_box + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &mut state); + + apply_event_with_widgets( + &mut state, + &mut widgets, + TuiEvent::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)), + ) + .await; + + assert_eq!(widgets.input_box.current_text(), "first line\nsecond line"); + assert_eq!(state.history_index, None); + } + + #[tokio::test] + async fn down_arrow_restores_draft_after_history_recall() { + let mut state = AppState { + input_history: vec!["older prompt".to_string()], + ..Default::default() + }; + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + widgets.input_box.set_text("draft prompt"); + + apply_event_with_widgets( + &mut state, + &mut widgets, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)), + ) + .await; + apply_event_with_widgets( + &mut state, + &mut widgets, + TuiEvent::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + ) + .await; + + assert_eq!(widgets.input_box.current_text(), "draft prompt"); + assert_eq!(state.history_index, None); + } + + #[test] + fn slash_model_opens_model_picker_instead_of_command_palette() { + let mut state = AppState::default(); + state.model_picker.set_models(vec![ + "gpt-4o".to_string(), + "gpt-5".to_string(), + "claude-sonnet-4-6".to_string(), + ]); + + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + widgets.input_box.set_text("/model gpt"); + + update_input_overlays_from_input(&widgets.input_box, &mut state); + + assert!(state.model_picker.visible); + assert_eq!(state.model_picker.filter, "gpt"); + assert_eq!(state.model_picker.filtered.len(), 2); + assert!(!state.command_palette.visible); + } + + #[tokio::test] + async fn enter_on_model_picker_submits_selected_model_command() { + let mut state = AppState { + model: "gpt-4o".to_string(), + ..Default::default() + }; + state + .model_picker + .set_models(vec!["gpt-4o".to_string(), "gpt-5".to_string()]); + + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + let (msg_tx, mut msg_rx) = mpsc::channel(4); + + widgets.input_box.set_text("/model"); + update_input_overlays_from_input(&widgets.input_box, &mut state); + + handle_event( + TuiEvent::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + &mut state, + &mut widgets, + &msg_tx, + &layout, + ) + .await; + handle_event( + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &mut state, + &mut widgets, + &msg_tx, + &layout, + ) + .await; + + let message = msg_rx.try_recv().expect("model command sent"); + assert_eq!(message.text, "/model gpt-5"); + assert!(message.thread_id.is_none()); + assert_eq!(state.model, "gpt-5"); + assert!(!state.model_picker.visible); + } + + #[tokio::test] + async fn submit_uses_current_thread_scope() { + let mut state = AppState { + current_thread_id: Some("thread-123".to_string()), + ..Default::default() + }; + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + let (msg_tx, mut msg_rx) = mpsc::channel(4); + + widgets.input_box.set_text("run it"); + + handle_event( + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &mut state, + &mut widgets, + &msg_tx, + &layout, + ) + .await; + + let message = msg_rx.try_recv().expect("message sent"); + assert_eq!(message.text, "run it"); + assert_eq!(message.thread_id.as_deref(), Some("thread-123")); + } + + #[tokio::test] + async fn slash_model_without_available_models_submits_on_enter() { + let mut state = AppState::default(); + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + let (msg_tx, mut msg_rx) = mpsc::channel(4); + + widgets.input_box.set_text("/model"); + update_input_overlays_from_input(&widgets.input_box, &mut state); + assert!(state.command_palette.visible); + assert!(!state.model_picker.visible); + + handle_event( + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &mut state, + &mut widgets, + &msg_tx, + &layout, + ) + .await; + + let message = msg_rx.try_recv().expect("/model command sent"); + assert_eq!(message.text, "/model"); + assert!(state.awaiting_model_list); + assert!(!state.command_palette.visible); + assert!(widgets.input_box.is_empty()); + } + + #[tokio::test] + async fn slash_palette_model_selection_opens_model_picker_when_models_exist() { + let mut state = AppState::default(); + state + .model_picker + .set_models(vec!["gpt-4o".to_string(), "gpt-5".to_string()]); + + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + let (msg_tx, _msg_rx) = mpsc::channel(4); + + widgets.input_box.set_text("/mo"); + update_input_overlays_from_input(&widgets.input_box, &mut state); + assert!(state.command_palette.visible); + + handle_event( + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &mut state, + &mut widgets, + &msg_tx, + &layout, + ) + .await; + + assert_eq!(widgets.input_box.current_text(), "/model "); + assert!(state.model_picker.visible); + assert!(!state.command_palette.visible); + } + + #[test] + fn parse_model_response_extracts_active_and_available_models() { + let parsed = parse_model_list_response( + "Active model: gpt-5\n\nAvailable models:\n gpt-5 (active)\n gpt-4o\n\nUse /model to switch.", + ) + .expect("parsed model response"); + + assert_eq!(parsed.0, "gpt-5"); + assert_eq!(parsed.1, vec!["gpt-5".to_string(), "gpt-4o".to_string()]); + } + + #[tokio::test] + async fn model_response_hydrates_picker_after_first_fetch() { + let mut state = AppState { + awaiting_model_list: true, + ..Default::default() + }; + let layout = TuiLayout::default(); + let mut widgets = create_default_widgets(&layout); + + handle_event( + TuiEvent::Response { + content: "Active model: gpt-5\n\nAvailable models:\n gpt-5 (active)\n gpt-4o\n\nUse /model to switch.".to_string(), + thread_id: None, + }, + &mut state, + &mut widgets, + &mpsc::channel(1).0, + &layout, + ) + .await; + + assert_eq!(state.model, "gpt-5"); + assert_eq!(widgets.input_box.current_text(), "/model "); + assert!(state.model_picker.visible); + assert_eq!(state.model_picker.filtered.len(), 2); + assert!(!state.awaiting_model_list); + } + + #[tokio::test] + async fn tool_updates_use_call_id_to_disambiguate_duplicate_names() { + let mut state = AppState::default(); + + apply_event( + &mut state, + TuiEvent::ToolStarted { + name: "http".to_string(), + detail: Some("first".to_string()), + call_id: Some("call-1".to_string()), + }, + ) + .await; + apply_event( + &mut state, + TuiEvent::ToolStarted { + name: "http".to_string(), + detail: Some("second".to_string()), + call_id: Some("call-2".to_string()), + }, + ) + .await; + apply_event( + &mut state, + TuiEvent::ToolResult { + name: "http".to_string(), + preview: "preview-2".to_string(), + call_id: Some("call-2".to_string()), + }, + ) + .await; + apply_event( + &mut state, + TuiEvent::ToolCompleted { + name: "http".to_string(), + success: true, + error: None, + call_id: Some("call-2".to_string()), + }, + ) + .await; + + assert_eq!(state.active_tools.len(), 1); + assert_eq!(state.active_tools[0].call_id.as_deref(), Some("call-1")); + assert_eq!(state.recent_tools.len(), 1); + assert_eq!(state.recent_tools[0].call_id.as_deref(), Some("call-2")); + assert_eq!(state.recent_tools[0].detail.as_deref(), Some("second")); + assert_eq!( + state.recent_tools[0].result_preview.as_deref(), + Some("preview-2") + ); + } +} diff --git a/crates/ironclaw_tui/src/event.rs b/crates/ironclaw_tui/src/event.rs new file mode 100644 index 00000000000..f6c28b59a4a --- /dev/null +++ b/crates/ironclaw_tui/src/event.rs @@ -0,0 +1,356 @@ +//! Unified event type for the TUI event loop. +//! +//! All external inputs (keyboard, terminal resize, engine status updates, +//! agent responses) are funnelled into a single `TuiEvent` enum so the +//! main loop can `select!` on one receiver. + +use std::collections::VecDeque; + +use ratatui::crossterm::event::KeyEvent; + +/// A single log entry displayed in the TUI Logs tab. +/// +/// This mirrors `LogEntry` from the main crate but is self-contained +/// so `ironclaw_tui` has no dependency on the main crate. +#[derive(Debug, Clone)] +pub struct TuiLogEntry { + pub level: String, + pub target: String, + pub message: String, + pub timestamp: String, +} + +/// Ring buffer of log entries with a fixed capacity. +#[derive(Debug, Clone)] +pub struct LogRingBuffer { + entries: VecDeque, + capacity: usize, +} + +impl LogRingBuffer { + pub fn new(capacity: usize) -> Self { + Self { + entries: VecDeque::with_capacity(capacity), + capacity, + } + } + + pub fn push(&mut self, entry: TuiLogEntry) { + if self.entries.len() >= self.capacity { + self.entries.pop_front(); + } + self.entries.push_back(entry); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } +} + +/// A single image or file attachment pasted into the TUI. +#[derive(Debug, Clone)] +pub struct TuiAttachment { + /// Raw file bytes (e.g. PNG-encoded image). + pub data: Vec, + /// MIME type (e.g. "image/png"). + pub mime_type: String, + /// Display label shown in the input area (e.g. "Image 1"). + pub label: String, +} + +/// A user message with optional attachments, sent from the TUI to the channel bridge. +#[derive(Debug, Clone)] +pub struct TuiUserMessage { + /// The text content of the message. + pub text: String, + /// Pasted image attachments. + pub attachments: Vec, + /// Active thread scope for this message, if the TUI has one selected. + pub thread_id: Option, + /// Non-chat UI action to run through the bridge. + pub ui_action: Option, +} + +/// Out-of-band UI actions emitted by the TUI. +#[derive(Debug, Clone)] +pub enum TuiUiAction { + /// Load and show engine thread detail without sending chat text. + OpenEngineThreadDetail { thread_id: String }, +} + +impl TuiUserMessage { + /// Create a text-only message with no attachments. + pub fn text_only(text: impl Into) -> Self { + Self { + text: text.into(), + attachments: Vec::new(), + thread_id: None, + ui_action: None, + } + } + + /// Attach a thread scope to this message. + pub fn with_thread_id(mut self, thread_id: Option) -> Self { + self.thread_id = thread_id; + self + } + + /// Request detail for an engine thread from the TUI bridge. + pub fn open_engine_thread_detail(thread_id: impl Into) -> Self { + Self { + text: String::new(), + attachments: Vec::new(), + thread_id: None, + ui_action: Some(TuiUiAction::OpenEngineThreadDetail { + thread_id: thread_id.into(), + }), + } + } +} + +/// A past conversation entry for the resume/thread picker. +#[derive(Debug, Clone)] +pub struct ThreadEntry { + pub id: String, + pub title: Option, + pub message_count: i64, + pub last_activity: String, + pub channel: String, +} + +/// A single message from conversation history, for hydrating the TUI on thread resume. +#[derive(Debug, Clone)] +pub struct HistoryMessage { + pub role: String, + pub content: String, + pub timestamp: chrono::DateTime, +} + +/// A pending approval restored alongside conversation history. +#[derive(Debug, Clone)] +pub struct HistoryApprovalRequest { + pub request_id: String, + pub tool_name: String, + pub description: String, + pub parameters: serde_json::Value, + pub allow_always: bool, +} + +/// An engine v2 thread entry for the activity sidebar. +#[derive(Debug, Clone)] +pub struct EngineThreadEntry { + pub id: String, + pub goal: String, + /// "Foreground", "Research", or "Mission". + pub thread_type: String, + /// Engine ThreadState as a string (e.g. "Running", "Waiting"). + pub state: String, + pub step_count: usize, + pub total_tokens: u64, + pub created_at: String, + pub updated_at: String, +} + +/// A single message in engine thread detail. +#[derive(Debug, Clone)] +pub struct EngineThreadMessageEntry { + pub role: String, + pub content: String, + pub timestamp: String, +} + +/// Full engine thread detail for the sidebar modal. +#[derive(Debug, Clone)] +pub struct EngineThreadDetailEntry { + pub id: String, + pub goal: String, + pub thread_type: String, + pub state: String, + pub project_id: String, + pub parent_id: Option, + pub step_count: usize, + pub total_tokens: u64, + pub created_at: String, + pub updated_at: String, + pub max_iterations: usize, + pub completed_at: Option, + pub total_cost_usd: f64, + pub messages: Vec, +} + +/// Events consumed by the TUI run loop. +#[derive(Debug, Clone)] +pub enum TuiEvent { + /// A keyboard event from crossterm. + Key(KeyEvent), + + /// Bracketed paste text from the terminal. + Paste(String), + + /// Terminal was resized to (cols, rows). + Resize(u16, u16), + + /// Mouse scroll (delta: negative = up, positive = down). + MouseScroll(i16), + + /// Left mouse click at a terminal cell coordinate. + MouseClick { column: u16, row: u16 }, + + /// Mouse drag with the left button held. + MouseDrag { column: u16, row: u16 }, + + /// Left mouse button release. + MouseRelease { column: u16, row: u16 }, + + /// Periodic render tick (~30 fps). + Tick, + + /// Agent is thinking / processing. + Thinking(String), + + /// Tool execution started. + ToolStarted { + name: String, + detail: Option, + call_id: Option, + }, + + /// Tool execution completed. + ToolCompleted { + name: String, + success: bool, + error: Option, + call_id: Option, + }, + + /// Brief preview of tool output. + ToolResult { + name: String, + preview: String, + call_id: Option, + }, + + /// Streaming text chunk from the LLM. + StreamChunk(String), + + /// General status message. + Status(String), + + /// Full agent response ready to display. + Response { + content: String, + thread_id: Option, + }, + + /// A sandbox job started. + JobStarted { job_id: String, title: String }, + + /// A sandbox job's status changed. + JobStatus { job_id: String, status: String }, + + /// A sandbox job completed with final result. + JobResult { job_id: String, status: String }, + + /// A routine was created, updated, or deleted. + RoutineUpdate { + id: String, + name: String, + trigger_type: String, + enabled: bool, + last_run: Option, + next_fire: Option, + }, + + /// Tool requires user approval. + ApprovalNeeded { + request_id: String, + tool_name: String, + description: String, + parameters: serde_json::Value, + allow_always: bool, + }, + + /// Extension needs user authentication. + AuthRequired { + extension_name: String, + instructions: Option, + }, + + /// Extension auth completed. + AuthCompleted { + extension_name: String, + success: bool, + message: String, + }, + + /// Agent reasoning update. + ReasoningUpdate { narrative: String }, + + /// Per-turn token/cost summary. + TurnCost { + input_tokens: u64, + output_tokens: u64, + cost_usd: String, + }, + + /// Suggestions for follow-up messages. + Suggestions { suggestions: Vec }, + + /// Context pressure update (token usage warning). + ContextPressure { + used_tokens: u64, + max_tokens: u64, + percentage: u8, + warning: Option, + }, + + /// Sandbox / Docker status update. + SandboxStatus { + docker_available: bool, + running_containers: u32, + status: String, + }, + + /// Secrets vault status update. + SecretsStatus { count: u32, vault_unlocked: bool }, + + /// Cost guard / budget status update. + CostGuard { + session_budget_usd: Option, + spent_usd: String, + remaining_usd: Option, + limit_reached: bool, + }, + + /// A log entry captured from the tracing subscriber. + Log { + level: String, + target: String, + message: String, + timestamp: String, + }, + + /// Thread list for the interactive resume picker. + ThreadList { threads: Vec }, + + /// Engine v2 thread list update for the activity sidebar. + EngineThreadList { threads: Vec }, + + /// Full engine v2 thread detail for the sidebar modal. + EngineThreadDetail { detail: EngineThreadDetailEntry }, + + /// Full conversation history for a resumed thread. + ConversationHistory { + thread_id: String, + messages: Vec, + pending_approval: Option, + }, +} diff --git a/crates/ironclaw_tui/src/input.rs b/crates/ironclaw_tui/src/input.rs new file mode 100644 index 00000000000..f241f2318c7 --- /dev/null +++ b/crates/ironclaw_tui/src/input.rs @@ -0,0 +1,514 @@ +//! Key handling and command parsing for the TUI. + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::widgets::LogLevelFilter; + +/// Parsed user command from keyboard input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputAction { + /// Submit the current input text to the agent. + Submit, + /// Quit the TUI. + Quit, + /// Toggle sidebar visibility. + ToggleSidebar, + /// Toggle between Conversation and Logs tabs. + ToggleLogs, + /// Scroll conversation up. + ScrollUp, + /// Scroll conversation down. + ScrollDown, + /// Cancel / interrupt current operation. + Interrupt, + /// Navigate approval dialog up. + ApprovalUp, + /// Navigate approval dialog down. + ApprovalDown, + /// Confirm approval selection. + ApprovalConfirm, + /// Cancel approval (deny). + ApprovalCancel, + /// Quick approve. + QuickApprove, + /// Quick always-approve. + QuickAlways, + /// Quick deny. + QuickDeny, + /// Navigate command palette up. + PaletteUp, + /// Navigate command palette down. + PaletteDown, + /// Select the highlighted command palette item. + PaletteSelect, + /// Close the command palette. + PaletteClose, + /// Navigate input history backward (older). + HistoryUp, + /// Navigate input history forward (newer). + HistoryDown, + /// Toggle search mode on/off. + SearchToggle, + /// Jump to next search match. + SearchNext, + /// Jump to previous search match. + SearchPrev, + /// Toggle help overlay (F1). + ToggleHelp, + /// Expand most recent tool output (Ctrl+E). + ExpandTool, + /// Set log level filter (1-5 in Logs tab). + LogFilter(LogLevelFilter), + /// Scroll tool detail modal up. + ToolDetailScrollUp, + /// Scroll tool detail modal down. + ToolDetailScrollDown, + /// Close the tool detail modal. + ToolDetailClose, + /// Paste image from system clipboard (Ctrl+V). + ClipboardPaste, + /// Navigate thread picker up. + ThreadPickerUp, + /// Navigate thread picker down. + ThreadPickerDown, + /// Select the highlighted thread. + ThreadPickerSelect, + /// Close the thread picker. + ThreadPickerClose, + /// Jump to the bottom of the conversation. + ScrollToBottom, + /// No recognized action — pass to input box. + Forward, +} + +/// Map a key event to an action, considering active modal/context state. +#[allow(clippy::too_many_arguments)] +pub fn map_key( + key: KeyEvent, + approval_active: bool, + palette_active: bool, + search_active: bool, + help_active: bool, + tool_detail_active: bool, + logs_active: bool, + thread_picker_active: bool, +) -> InputAction { + if thread_picker_active { + return map_thread_picker_key(key); + } + + if approval_active { + return map_approval_key(key); + } + + if help_active { + return map_help_key(key); + } + + if tool_detail_active { + return map_tool_detail_key(key); + } + + if search_active { + return map_search_key(key); + } + + if palette_active { + return map_palette_key(key); + } + + // Log level filter keys only in logs tab + if logs_active && let Some(action) = map_log_filter_key(key) { + return action; + } + + match (key.code, key.modifiers) { + (KeyCode::Enter, KeyModifiers::NONE) => InputAction::Submit, + (KeyCode::Char('c'), KeyModifiers::CONTROL) => InputAction::Quit, + (KeyCode::Char('b'), KeyModifiers::CONTROL) => InputAction::ToggleSidebar, + (KeyCode::Char('l'), KeyModifiers::CONTROL) => InputAction::ToggleLogs, + (KeyCode::Char('f'), KeyModifiers::CONTROL) => InputAction::SearchToggle, + (KeyCode::Char('e'), KeyModifiers::CONTROL) => InputAction::ExpandTool, + (KeyCode::Char('v'), KeyModifiers::CONTROL) => InputAction::ClipboardPaste, + (KeyCode::F(1), _) => InputAction::ToggleHelp, + (KeyCode::Esc, _) => InputAction::Interrupt, + (KeyCode::PageUp, _) => InputAction::ScrollUp, + (KeyCode::PageDown, _) => InputAction::ScrollDown, + // Ctrl+Up / Ctrl+Down for scroll + (KeyCode::Up, KeyModifiers::CONTROL) => InputAction::ScrollUp, + (KeyCode::Down, KeyModifiers::CONTROL) => InputAction::ScrollDown, + // End key jumps to bottom + (KeyCode::End, _) => InputAction::ScrollToBottom, + // Ctrl+P / Ctrl+N for input history navigation + (KeyCode::Char('p'), KeyModifiers::CONTROL) => InputAction::HistoryUp, + (KeyCode::Char('n'), KeyModifiers::CONTROL) => InputAction::HistoryDown, + _ => InputAction::Forward, + } +} + +/// Map key events when the help overlay is active. +fn map_help_key(key: KeyEvent) -> InputAction { + match (key.code, key.modifiers) { + (KeyCode::Char('c'), KeyModifiers::CONTROL) => InputAction::Quit, + (KeyCode::Esc, _) | (KeyCode::F(1), _) => InputAction::ToggleHelp, + _ => InputAction::Forward, + } +} + +/// Map key events when the tool detail modal is active. +fn map_tool_detail_key(key: KeyEvent) -> InputAction { + match (key.code, key.modifiers) { + (KeyCode::Char('c'), KeyModifiers::CONTROL) => InputAction::Quit, + (KeyCode::Esc, _) => InputAction::ToolDetailClose, + (KeyCode::PageUp, _) | (KeyCode::Up, _) => InputAction::ToolDetailScrollUp, + (KeyCode::PageDown, _) | (KeyCode::Down, _) => InputAction::ToolDetailScrollDown, + _ => InputAction::Forward, + } +} + +/// Map number keys to log level filters (only when logs tab is active). +fn map_log_filter_key(key: KeyEvent) -> Option { + if key.modifiers != KeyModifiers::NONE { + return None; + } + match key.code { + KeyCode::Char('1') => Some(InputAction::LogFilter(LogLevelFilter::Error)), + KeyCode::Char('2') => Some(InputAction::LogFilter(LogLevelFilter::Warn)), + KeyCode::Char('3') => Some(InputAction::LogFilter(LogLevelFilter::Info)), + KeyCode::Char('4') => Some(InputAction::LogFilter(LogLevelFilter::Debug)), + KeyCode::Char('5') => Some(InputAction::LogFilter(LogLevelFilter::All)), + _ => None, + } +} + +/// Map key events when the search bar is active. +fn map_search_key(key: KeyEvent) -> InputAction { + match (key.code, key.modifiers) { + (KeyCode::Char('c'), KeyModifiers::CONTROL) => InputAction::Quit, + (KeyCode::Esc, _) => InputAction::SearchToggle, + (KeyCode::Enter, KeyModifiers::NONE) => InputAction::SearchNext, + (KeyCode::Enter, KeyModifiers::SHIFT) => InputAction::SearchPrev, + _ => InputAction::Forward, + } +} + +/// Map key events when the command palette is active. +fn map_palette_key(key: KeyEvent) -> InputAction { + match key.code { + KeyCode::Up => InputAction::PaletteUp, + KeyCode::Down => InputAction::PaletteDown, + KeyCode::Enter | KeyCode::Tab => InputAction::PaletteSelect, + KeyCode::Esc => InputAction::PaletteClose, + KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => InputAction::Quit, + _ => InputAction::Forward, + } +} + +/// Map key events when the thread picker modal is active. +fn map_thread_picker_key(key: KeyEvent) -> InputAction { + match key.code { + KeyCode::Up | KeyCode::Char('k') => InputAction::ThreadPickerUp, + KeyCode::Down | KeyCode::Char('j') => InputAction::ThreadPickerDown, + KeyCode::Enter => InputAction::ThreadPickerSelect, + KeyCode::Esc => InputAction::ThreadPickerClose, + KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => InputAction::Quit, + _ => InputAction::Forward, + } +} + +/// Map key events when the approval dialog is active. +fn map_approval_key(key: KeyEvent) -> InputAction { + match key.code { + KeyCode::Up | KeyCode::Char('k') => InputAction::ApprovalUp, + KeyCode::Down | KeyCode::Char('j') => InputAction::ApprovalDown, + KeyCode::Enter => InputAction::ApprovalConfirm, + KeyCode::Esc => InputAction::ApprovalCancel, + KeyCode::Char('y') | KeyCode::Char('Y') => InputAction::QuickApprove, + KeyCode::Char('a') | KeyCode::Char('A') => InputAction::QuickAlways, + KeyCode::Char('n') | KeyCode::Char('N') => InputAction::QuickDeny, + _ => InputAction::Forward, + } +} + +/// Parse a slash command from user input text. +pub fn parse_slash_command(text: &str) -> Option<&str> { + let trimmed = text.trim(); + if trimmed.starts_with('/') { + Some(trimmed) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn map_default(key: KeyEvent) -> InputAction { + map_key(key, false, false, false, false, false, false, false) + } + + fn map_approval(key: KeyEvent) -> InputAction { + map_key(key, true, false, false, false, false, false, false) + } + + fn map_palette(key: KeyEvent) -> InputAction { + map_key(key, false, true, false, false, false, false, false) + } + + fn map_search(key: KeyEvent) -> InputAction { + map_key(key, false, false, true, false, false, false, false) + } + + fn map_logs(key: KeyEvent) -> InputAction { + map_key(key, false, false, false, false, false, true, false) + } + + fn map_help(key: KeyEvent) -> InputAction { + map_key(key, false, false, false, true, false, false, false) + } + + fn map_tool_detail(key: KeyEvent) -> InputAction { + map_key(key, false, false, false, false, true, false, false) + } + + fn map_thread_picker(key: KeyEvent) -> InputAction { + map_key(key, false, false, false, false, false, false, true) + } + + #[test] + fn enter_submits() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(map_default(key), InputAction::Submit); + } + + #[test] + fn ctrl_c_quits() { + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(map_default(key), InputAction::Quit); + } + + #[test] + fn ctrl_b_toggles_sidebar() { + let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL); + assert_eq!(map_default(key), InputAction::ToggleSidebar); + } + + #[test] + fn ctrl_l_toggles_logs() { + let key = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL); + assert_eq!(map_default(key), InputAction::ToggleLogs); + } + + #[test] + fn esc_interrupts() { + let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(map_default(key), InputAction::Interrupt); + } + + #[test] + fn f1_toggles_help() { + let key = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE); + assert_eq!(map_default(key), InputAction::ToggleHelp); + } + + #[test] + fn ctrl_e_expands_tool() { + let key = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL); + assert_eq!(map_default(key), InputAction::ExpandTool); + } + + #[test] + fn approval_mode_y_approves() { + let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE); + assert_eq!(map_approval(key), InputAction::QuickApprove); + } + + #[test] + fn approval_mode_n_denies() { + let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE); + assert_eq!(map_approval(key), InputAction::QuickDeny); + } + + #[test] + fn palette_up_down() { + let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE); + assert_eq!(map_palette(up), InputAction::PaletteUp); + let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + assert_eq!(map_palette(down), InputAction::PaletteDown); + } + + #[test] + fn palette_enter_selects() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(map_palette(key), InputAction::PaletteSelect); + } + + #[test] + fn palette_tab_selects() { + let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + assert_eq!(map_palette(key), InputAction::PaletteSelect); + } + + #[test] + fn palette_esc_closes() { + let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(map_palette(key), InputAction::PaletteClose); + } + + #[test] + fn palette_typing_forwards() { + let key = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE); + assert_eq!(map_palette(key), InputAction::Forward); + } + + #[test] + fn ctrl_p_history_up() { + let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL); + assert_eq!(map_default(key), InputAction::HistoryUp); + } + + #[test] + fn ctrl_n_history_down() { + let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL); + assert_eq!(map_default(key), InputAction::HistoryDown); + } + + #[test] + fn history_keys_ignored_in_approval_mode() { + let key_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL); + assert_eq!(map_approval(key_p), InputAction::Forward); + } + + #[test] + fn history_keys_ignored_in_palette_mode() { + let key_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL); + assert_eq!(map_palette(key_p), InputAction::Forward); + } + + #[test] + fn ctrl_f_toggles_search() { + let key = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL); + assert_eq!(map_default(key), InputAction::SearchToggle); + } + + #[test] + fn search_esc_closes() { + let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(map_search(key), InputAction::SearchToggle); + } + + #[test] + fn search_enter_next() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(map_search(key), InputAction::SearchNext); + } + + #[test] + fn search_shift_enter_prev() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + assert_eq!(map_search(key), InputAction::SearchPrev); + } + + #[test] + fn search_typing_forwards() { + let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); + assert_eq!(map_search(key), InputAction::Forward); + } + + #[test] + fn search_ctrl_c_quits() { + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(map_search(key), InputAction::Quit); + } + + #[test] + fn log_filter_keys_in_logs_tab() { + let key1 = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE); + assert_eq!( + map_logs(key1), + InputAction::LogFilter(LogLevelFilter::Error) + ); + let key5 = KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE); + assert_eq!(map_logs(key5), InputAction::LogFilter(LogLevelFilter::All)); + } + + #[test] + fn log_filter_keys_not_in_chat_tab() { + let key1 = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE); + assert_eq!(map_default(key1), InputAction::Forward); + } + + #[test] + fn help_esc_closes() { + let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(map_help(key), InputAction::ToggleHelp); + } + + #[test] + fn help_f1_closes() { + let key = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE); + assert_eq!(map_help(key), InputAction::ToggleHelp); + } + + #[test] + fn tool_detail_esc_closes() { + let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(map_tool_detail(key), InputAction::ToolDetailClose); + } + + #[test] + fn tool_detail_scroll() { + let up = KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE); + assert_eq!(map_tool_detail(up), InputAction::ToolDetailScrollUp); + let down = KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE); + assert_eq!(map_tool_detail(down), InputAction::ToolDetailScrollDown); + } + + #[test] + fn ctrl_v_clipboard_paste() { + let key = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + assert_eq!(map_default(key), InputAction::ClipboardPaste); + } + + #[test] + fn thread_picker_up_down() { + let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE); + assert_eq!(map_thread_picker(up), InputAction::ThreadPickerUp); + let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + assert_eq!(map_thread_picker(down), InputAction::ThreadPickerDown); + } + + #[test] + fn thread_picker_jk_navigation() { + let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); + assert_eq!(map_thread_picker(j), InputAction::ThreadPickerDown); + let k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE); + assert_eq!(map_thread_picker(k), InputAction::ThreadPickerUp); + } + + #[test] + fn thread_picker_enter_selects() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(map_thread_picker(key), InputAction::ThreadPickerSelect); + } + + #[test] + fn thread_picker_esc_closes() { + let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(map_thread_picker(key), InputAction::ThreadPickerClose); + } + + #[test] + fn thread_picker_ctrl_c_quits() { + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(map_thread_picker(key), InputAction::Quit); + } + + #[test] + fn slash_command_detected() { + assert_eq!(parse_slash_command("/help"), Some("/help")); + assert_eq!(parse_slash_command(" /quit "), Some("/quit")); + assert_eq!(parse_slash_command("hello"), None); + } +} diff --git a/crates/ironclaw_tui/src/layout.rs b/crates/ironclaw_tui/src/layout.rs new file mode 100644 index 00000000000..11a956d3bb2 --- /dev/null +++ b/crates/ironclaw_tui/src/layout.rs @@ -0,0 +1,256 @@ +//! User-configurable TUI layout. +//! +//! Layout is loaded from `tui/layout.json` in the workspace directory. +//! If the file doesn't exist, sensible defaults are used. + +use std::collections::HashMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::theme::Theme; + +/// Top-level layout configuration for the TUI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TuiLayout { + /// Theme name or inline theme definition. + #[serde(default = "default_theme_name")] + pub theme: String, + + /// Sidebar configuration. + #[serde(default)] + pub sidebar: SidebarConfig, + + /// Header bar configuration. + #[serde(default)] + pub header: HeaderConfig, + + /// Status bar configuration. + #[serde(default)] + pub status_bar: StatusBarConfig, + + /// Conversation area configuration. + #[serde(default)] + pub conversation: ConversationConfig, + + /// Key binding overrides: action name -> key combo string. + #[serde(default)] + pub keybindings: HashMap, + + /// Per-widget configuration overrides. + #[serde(default)] + pub widgets: HashMap, +} + +fn default_theme_name() -> String { + "dark".to_string() +} + +impl Default for TuiLayout { + fn default() -> Self { + Self { + theme: default_theme_name(), + sidebar: SidebarConfig::default(), + header: HeaderConfig::default(), + status_bar: StatusBarConfig::default(), + conversation: ConversationConfig::default(), + keybindings: HashMap::new(), + widgets: HashMap::new(), + } + } +} + +impl TuiLayout { + /// Load layout from a JSON file, falling back to defaults on any error. + pub fn load_from_file(path: &Path) -> Self { + match std::fs::read_to_string(path) { + Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + /// Resolve the theme from the layout's theme name. + pub fn resolve_theme(&self) -> Theme { + match self.theme.as_str() { + "light" => Theme::light(), + _ => Theme::dark(), + } + } +} + +/// Sidebar panel configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SidebarConfig { + /// Whether the sidebar is visible. + #[serde(default = "default_true")] + pub visible: bool, + + /// Sidebar width as percentage of terminal width (10-50). + #[serde(default = "default_sidebar_width")] + pub width_percent: u16, +} + +fn default_true() -> bool { + true +} + +fn default_sidebar_width() -> u16 { + 25 +} + +impl Default for SidebarConfig { + fn default() -> Self { + Self { + visible: true, + width_percent: default_sidebar_width(), + } + } +} + +impl SidebarConfig { + /// Clamp width to valid range. + pub fn effective_width(&self) -> u16 { + self.width_percent.clamp(10, 50) + } +} + +/// Header bar configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeaderConfig { + #[serde(default = "default_true")] + pub visible: bool, + + #[serde(default = "default_true")] + pub show_model: bool, + + #[serde(default = "default_true")] + pub show_tokens: bool, + + #[serde(default = "default_true")] + pub show_session_duration: bool, +} + +impl Default for HeaderConfig { + fn default() -> Self { + Self { + visible: false, + show_model: true, + show_tokens: true, + show_session_duration: true, + } + } +} + +/// Status bar configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusBarConfig { + #[serde(default = "default_true")] + pub visible: bool, + + #[serde(default = "default_true")] + pub show_cost: bool, + + #[serde(default = "default_true")] + pub show_keybinds: bool, +} + +impl Default for StatusBarConfig { + fn default() -> Self { + Self { + visible: true, + show_cost: true, + show_keybinds: true, + } + } +} + +/// Conversation area configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationConfig { + /// Show tool call details inline in conversation. + #[serde(default = "default_true")] + pub show_tool_details: bool, + + /// Maximum number of messages to keep in the visible buffer. + #[serde(default = "default_max_messages")] + pub max_visible_messages: usize, +} + +fn default_max_messages() -> usize { + 200 +} + +impl Default for ConversationConfig { + fn default() -> Self { + Self { + show_tool_details: true, + max_visible_messages: default_max_messages(), + } + } +} + +/// Where widgets can be placed in the TUI layout. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TuiSlot { + Header, + StatusBarLeft, + StatusBarCenter, + StatusBarRight, + Sidebar, + SidebarSection, + ConversationBanner, + InputPrefix, + Tab, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_layout_is_valid() { + let layout = TuiLayout::default(); + assert_eq!(layout.theme, "dark"); + assert!(layout.sidebar.visible); + assert_eq!(layout.sidebar.effective_width(), 25); + assert!(!layout.header.visible); + assert!(layout.status_bar.visible); + } + + #[test] + fn sidebar_width_clamped() { + let mut sb = SidebarConfig { + width_percent: 5, + ..Default::default() + }; + assert_eq!(sb.effective_width(), 10); + sb.width_percent = 80; + assert_eq!(sb.effective_width(), 50); + } + + #[test] + fn layout_serialization_round_trip() { + let layout = TuiLayout::default(); + let json = serde_json::to_string(&layout).expect("serialize"); + let back: TuiLayout = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.theme, "dark"); + assert_eq!(back.sidebar.width_percent, 25); + } + + #[test] + fn resolve_theme_dark() { + let layout = TuiLayout::default(); + let theme = layout.resolve_theme(); + assert_eq!(theme.name, "dark"); + } + + #[test] + fn resolve_theme_light() { + let layout = TuiLayout { + theme: "light".to_string(), + ..Default::default() + }; + let theme = layout.resolve_theme(); + assert_eq!(theme.name, "light"); + } +} diff --git a/crates/ironclaw_tui/src/lib.rs b/crates/ironclaw_tui/src/lib.rs new file mode 100644 index 00000000000..3d7b4f381dc --- /dev/null +++ b/crates/ironclaw_tui/src/lib.rs @@ -0,0 +1,50 @@ +//! `ironclaw_tui` — Modular Ratatui-based TUI for IronClaw. +//! +//! This crate provides the rendering engine, widget system, and event loop +//! for IronClaw's terminal user interface. It is intentionally decoupled +//! from the main `ironclaw` crate: the Channel trait bridge lives in +//! `src/channels/tui.rs` in the main crate. +//! +//! # Architecture +//! +//! ```text +//! ┌─ TuiApp (app.rs) ────────────────────────────────────────────┐ +//! │ Event loop: poll crossterm → merge with TuiEvent rx │ +//! │ Render: Layout → Widget::render() → Terminal::draw() │ +//! │ │ +//! │ ┌─ Header ─────────────────────────────────────────────┐ │ +//! │ │ version · model · duration · tokens │ │ +//! │ ├─ Conversation ──────────┬─ Sidebar ──────────────────┤ │ +//! │ │ Messages + markdown │ Tools: live activity │ │ +//! │ │ │ Threads: active/recent │ │ +//! │ ├─ Input ─────────────────┴────────────────────────────┤ │ +//! │ │ › user input (tui-textarea) │ │ +//! │ ├─ Status Bar ─────────────────────────────────────────┤ │ +//! │ │ model │ tokens │ cost │ keybinds │ │ +//! │ └──────────────────────────────────────────────────────┘ │ +//! └──────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Communication +//! +//! The main crate sends [`TuiEvent`]s via the handle's `event_tx`, and +//! receives user messages via `msg_rx`. The TUI never calls into the +//! main crate directly. + +pub mod app; +pub mod event; +pub mod input; +pub mod layout; +pub mod render; +pub mod spinner; +pub mod theme; +pub mod widgets; + +pub use app::{TuiAppConfig, TuiAppHandle, start_tui}; +pub use event::{ + EngineThreadDetailEntry, EngineThreadEntry, EngineThreadMessageEntry, HistoryApprovalRequest, + HistoryMessage, ThreadEntry, TuiAttachment, TuiEvent, TuiLogEntry, TuiUiAction, TuiUserMessage, +}; +pub use layout::TuiLayout; +pub use theme::Theme; +pub use widgets::{AppState, SkillCategory, ToolCategory}; diff --git a/crates/ironclaw_tui/src/render.rs b/crates/ironclaw_tui/src/render.rs new file mode 100644 index 00000000000..44fd1cdbfb3 --- /dev/null +++ b/crates/ironclaw_tui/src/render.rs @@ -0,0 +1,1135 @@ +//! Rendering utilities for converting text to styled Ratatui spans. + +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use crate::theme::Theme; + +/// Convert a plain text string into wrapped `Line`s that fit within `max_width`. +pub fn wrap_text(text: &str, max_width: usize, style: Style) -> Vec> { + if max_width == 0 { + return vec![]; + } + + let mut lines = Vec::new(); + for raw_line in text.lines() { + if raw_line.is_empty() { + lines.push(Line::from("")); + continue; + } + lines.extend(wrap_plain_line(raw_line, max_width, style)); + } + + if lines.is_empty() { + lines.push(Line::from("")); + } + + lines +} + +fn wrap_plain_line(raw_line: &str, max_width: usize, style: Style) -> Vec> { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut current_is_whitespace = None; + + for ch in raw_line.chars() { + let is_whitespace = ch.is_whitespace(); + if current_is_whitespace == Some(is_whitespace) || current.is_empty() { + push_wrapped_char(&mut current, ch); + current_is_whitespace = Some(is_whitespace); + continue; + } + + tokens.push(std::mem::take(&mut current)); + push_wrapped_char(&mut current, ch); + current_is_whitespace = Some(is_whitespace); + } + + if !current.is_empty() { + tokens.push(current); + } + + let mut lines = Vec::new(); + let mut current_line = String::new(); + let mut current_width = 0usize; + + for token in tokens { + let token_width = UnicodeWidthStr::width(token.as_str()); + if current_width + token_width <= max_width { + current_line.push_str(&token); + current_width += token_width; + continue; + } + + if !current_line.is_empty() { + lines.push(Line::from(Span::styled( + std::mem::take(&mut current_line), + style, + ))); + current_width = 0; + } + + if token_width <= max_width { + current_width = token_width; + current_line = token; + continue; + } + + for ch in token.chars() { + let rendered = render_wrapped_char(ch); + let rendered_width = wrapped_char_width(ch); + if current_width + rendered_width > max_width && !current_line.is_empty() { + lines.push(Line::from(Span::styled( + std::mem::take(&mut current_line), + style, + ))); + current_width = 0; + } + current_line.push_str(&rendered); + current_width += rendered_width; + } + } + + if !current_line.is_empty() { + lines.push(Line::from(Span::styled(current_line, style))); + } + + if lines.is_empty() { + lines.push(Line::from("")); + } + + lines +} + +fn push_wrapped_char(target: &mut String, ch: char) { + target.push_str(&render_wrapped_char(ch)); +} + +fn render_wrapped_char(ch: char) -> String { + match ch { + '\t' => " ".to_string(), + _ => ch.to_string(), + } +} + +fn wrapped_char_width(ch: char) -> usize { + match ch { + '\t' => 4, + _ => UnicodeWidthChar::width(ch).unwrap_or(0), + } +} + +// ── Markdown rendering ──────────────────────────────────────────────── + +/// Which kind of list we're inside. +#[derive(Clone)] +enum ListKind { + Unordered, + Ordered(u64), +} + +/// Render CommonMark `text` into styled, word-wrapped `Line`s. +/// +/// Headings, bold, italic, inline code, fenced code blocks, lists, +/// blockquotes, horizontal rules, and links are all rendered with +/// appropriate terminal styles via `theme`. +pub fn render_markdown(text: &str, max_width: usize, theme: &Theme) -> Vec> { + if max_width == 0 { + return vec![]; + } + + let opts = Options::ENABLE_STRIKETHROUGH; + let parser = Parser::new_ext(text, opts); + + let mut ctx = MdContext::new(theme); + + for event in parser { + match event { + // ── Block-level start ──────────────────────────────── + Event::Start(Tag::Heading { level, .. }) => { + if !ctx.first_block { + ctx.need_blank_line = true; + } + if ctx.need_blank_line { + ctx.lines.push(Line::from("")); + ctx.need_blank_line = false; + } + let heading_style = match level { + HeadingLevel::H1 | HeadingLevel::H2 => theme.bold_accent_style(), + _ => theme.bold_style(), + }; + ctx.style_stack.push(heading_style); + } + Event::End(TagEnd::Heading(_)) => { + ctx.flush(max_width, theme); + ctx.style_stack.pop(); + ctx.need_blank_line = true; + ctx.first_block = false; + } + + Event::Start(Tag::Paragraph) => { + if ctx.need_blank_line && !ctx.first_block { + ctx.lines.push(Line::from("")); + ctx.need_blank_line = false; + } + } + Event::End(TagEnd::Paragraph) => { + ctx.flush(max_width, theme); + ctx.need_blank_line = true; + ctx.first_block = false; + } + + Event::Start(Tag::BlockQuote(_)) => { + if ctx.need_blank_line && !ctx.first_block { + ctx.lines.push(Line::from("")); + ctx.need_blank_line = false; + } + ctx.in_blockquote = true; + ctx.style_stack.push(theme.dim_style()); + } + Event::End(TagEnd::BlockQuote(_)) => { + ctx.flush(max_width, theme); + ctx.in_blockquote = false; + ctx.style_stack.pop(); + ctx.need_blank_line = true; + ctx.first_block = false; + } + + Event::Start(Tag::CodeBlock(kind)) => { + if ctx.need_blank_line && !ctx.first_block { + ctx.lines.push(Line::from("")); + ctx.need_blank_line = false; + } + // Language badge for fenced code blocks + if let CodeBlockKind::Fenced(ref lang) = kind { + let lang_str = lang.split(',').next().unwrap_or("").trim(); + if !lang_str.is_empty() { + ctx.lines.push(Line::from(Span::styled( + format!("[{lang_str}]"), + theme.accent_style().add_modifier(Modifier::BOLD), + ))); + } + } + ctx.in_code_block = true; + } + Event::End(TagEnd::CodeBlock) => { + ctx.in_code_block = false; + ctx.need_blank_line = true; + ctx.first_block = false; + } + + Event::Start(Tag::List(start)) => { + if ctx.need_blank_line && !ctx.first_block { + ctx.lines.push(Line::from("")); + ctx.need_blank_line = false; + } + match start { + Some(n) => ctx.list_stack.push(ListKind::Ordered(n)), + None => ctx.list_stack.push(ListKind::Unordered), + } + } + Event::End(TagEnd::List(_)) => { + ctx.list_stack.pop(); + ctx.need_blank_line = true; + ctx.first_block = false; + } + + Event::Start(Tag::Item) => { + let depth = ctx.list_stack.len().saturating_sub(1); + let base_indent = depth * 4; + let prefix = match ctx.list_stack.last() { + Some(ListKind::Unordered) => { + format!("{}\u{2022} ", " ".repeat(base_indent + 2)) + } + Some(ListKind::Ordered(n)) => { + format!("{}{}. ", " ".repeat(base_indent + 1), n) + } + None => String::new(), + }; + let style = ctx.top_style(); + ctx.segments.push((prefix, style)); + } + Event::End(TagEnd::Item) => { + ctx.flush(max_width, theme); + if let Some(ListKind::Ordered(n)) = ctx.list_stack.last_mut() { + *n += 1; + } + ctx.first_block = false; + } + + // ── Inline formatting ──────────────────────────────── + Event::Start(Tag::Strong) => { + let s = ctx.top_style().add_modifier(Modifier::BOLD); + ctx.style_stack.push(s); + } + Event::End(TagEnd::Strong) => { + ctx.style_stack.pop(); + } + + Event::Start(Tag::Emphasis) => { + let s = ctx.top_style().add_modifier(Modifier::ITALIC); + ctx.style_stack.push(s); + } + Event::End(TagEnd::Emphasis) => { + ctx.style_stack.pop(); + } + + Event::Start(Tag::Strikethrough) => { + let s = ctx.top_style().add_modifier(Modifier::CROSSED_OUT); + ctx.style_stack.push(s); + } + Event::End(TagEnd::Strikethrough) => { + ctx.style_stack.pop(); + } + + Event::Start(Tag::Link { .. }) => { + ctx.style_stack.push(theme.accent_style()); + } + Event::End(TagEnd::Link) => { + ctx.style_stack.pop(); + } + + Event::Code(code) => { + ctx.segments.push((code.to_string(), theme.success_style())); + } + + // ── Text content ───────────────────────────────────── + Event::Text(txt) => { + if ctx.in_code_block { + for raw_line in txt.lines() { + ctx.lines.push(highlight_code_line(raw_line, theme)); + } + } else { + let style = ctx.top_style(); + ctx.segments.push((txt.to_string(), style)); + } + } + + Event::SoftBreak => { + if !ctx.in_code_block { + let style = ctx.top_style(); + ctx.segments.push((" ".to_string(), style)); + } + } + Event::HardBreak => { + ctx.flush(max_width, theme); + } + + Event::Rule => { + if ctx.need_blank_line && !ctx.first_block { + ctx.lines.push(Line::from("")); + } + let rule_width = max_width.min(60); + let rule = "\u{2500}".repeat(rule_width); + ctx.lines + .push(Line::from(Span::styled(rule, theme.dim_style()))); + ctx.need_blank_line = true; + ctx.first_block = false; + } + + // Skip events we don't render (tables, footnotes, HTML, etc.) + _ => {} + } + } + + // Flush any remaining segments. + ctx.flush(max_width, theme); + + if ctx.lines.is_empty() { + ctx.lines.push(Line::from("")); + } + + ctx.lines +} + +// ── Code syntax highlighting ────────────────────────────────────────── + +/// Keywords highlighted with bold accent style in code blocks. +const CODE_KEYWORDS: &[&str] = &[ + // Rust + "fn", "let", "mut", "pub", "use", "struct", "enum", "impl", "trait", "for", "while", "if", + "else", "match", "return", "self", "Self", "async", "await", "const", "static", "type", + "where", "mod", "crate", "super", "true", "false", "None", "Some", "Ok", "Err", + // Python + "def", "class", "import", "from", "print", // JS/TS + "var", "function", "export", "default", "require", +]; + +/// Produce a syntax-highlighted `Line` for a single code-block line. +/// +/// Applies basic keyword, string, comment, and number highlighting without +/// any heavy parsing dependency. +fn highlight_code_line(line: &str, theme: &Theme) -> Line<'static> { + let trimmed = line.trim_start(); + + // Full-line comments: `//` or `#` prefix. + if trimmed.starts_with("//") || trimmed.starts_with('#') { + return Line::from(Span::styled(line.to_string(), theme.dim_style())); + } + + let base_style = theme.success_style(); + let keyword_style = Style::default() + .fg(theme.accent.to_color()) + .add_modifier(Modifier::BOLD); + let string_style = theme.warning_style(); + let number_style = theme.accent_style(); + + let mut spans: Vec> = Vec::new(); + let chars: Vec = line.chars().collect(); + let len = chars.len(); + let mut i = 0; + + while i < len { + let ch = chars[i]; + + // ── Whitespace run ─────────────────────────────────── + if ch.is_whitespace() { + let start = i; + while i < len && chars[i].is_whitespace() { + i += 1; + } + let s: String = chars[start..i].iter().collect(); + spans.push(Span::styled(s, base_style)); + continue; + } + + // ── String literals ────────────────────────────────── + if ch == '"' || ch == '\'' { + let quote = ch; + let start = i; + i += 1; + while i < len { + if chars[i] == '\\' { + i += 2; // skip escaped char + } else if chars[i] == quote { + i += 1; + break; + } else { + i += 1; + } + } + let s: String = chars[start..i].iter().collect(); + spans.push(Span::styled(s, string_style)); + continue; + } + + // ── Inline comment (// in the middle of a line) ────── + if ch == '/' && i + 1 < len && chars[i + 1] == '/' { + let s: String = chars[i..].iter().collect(); + spans.push(Span::styled(s, theme.dim_style())); + break; + } + + // ── Word token (identifier / keyword / number) ─────── + if ch.is_alphanumeric() || ch == '_' { + let start = i; + while i < len && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + + if CODE_KEYWORDS.contains(&word.as_str()) { + spans.push(Span::styled(word, keyword_style)); + } else if word.chars().all(|c| c.is_ascii_digit() || c == '_') && !word.is_empty() { + spans.push(Span::styled(word, number_style)); + } else { + spans.push(Span::styled(word, base_style)); + } + continue; + } + + // ── Punctuation / operators ────────────────────────── + let start = i; + while i < len + && !chars[i].is_whitespace() + && !chars[i].is_alphanumeric() + && chars[i] != '_' + && chars[i] != '"' + && chars[i] != '\'' + && !(chars[i] == '/' && i + 1 < len && chars[i + 1] == '/') + { + i += 1; + } + if i == start { + // Safety: advance at least one character to avoid infinite loop. + i += 1; + } + let s: String = chars[start..i].iter().collect(); + spans.push(Span::styled(s, base_style)); + } + + if spans.is_empty() { + Line::from(Span::styled(String::new(), base_style)) + } else { + Line::from(spans) + } +} + +/// Internal state for the markdown event walker. +struct MdContext { + style_stack: Vec