diff --git a/.eslintignore b/.eslintignore index ea62efdf1f..ff10944af7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,2 @@ **/node_modules/** -**/out/** -server/aws-lsp-codewhisperer/src/client/sigv4/codewhisperersigv4client.d.ts -server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts \ No newline at end of file +**/out/** \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..f55b400527 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +app/aws-lsp-codewhisperer-runtimes/_bundle-assets/**/*.zip filter=lfs diff=lfs merge=lfs -text +binaries/*.zip filter=lfs diff=lfs merge=lfs -text diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 085de054d4..4e8d061fc4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,3 +14,20 @@ updates: - '@typescript-eslint/*' commit-message: prefix: 'chore' + - package-ecosystem: 'npm' + directory: '/core/codewhisperer-streaming' + target-branch: 'main' + schedule: + interval: 'weekly' + ignore: + - dependency-name: 'tslib' + - dependency-name: '@aws-crypto/*' + - dependency-name: '@aws-sdk/*' + - dependency-name: '@smithy/*' + - dependency-name: 'uuid' + - dependency-name: '@tsconfig/node16' + - dependency-name: 'concurrently' + - dependency-name: 'downlevel-dts' + - dependency-name: 'rimraf' + - dependency-name: 'typescript' + - dependency-name: '@types/*' diff --git a/.github/workflows/agentic-prerelease-release-notes.md b/.github/workflows/agentic-prerelease-release-notes.md new file mode 100644 index 0000000000..b29f3938ab --- /dev/null +++ b/.github/workflows/agentic-prerelease-release-notes.md @@ -0,0 +1,19 @@ +This is an **unsupported preview build** of agentic chat for the `${BRANCH}` branch. + +Commit ID: `${COMMIT_ID}` +Git Tag: `${TAG_NAME}` +Version: `${SERVER_VERSION}` + +## Installation + +Depending on your IDE plugin, you may have the following options available to you + +### Sideload a build into the plugin +Download the bundle, then configure your plugin to use the downloaded build. +- download clients.zip, and unzip it to a `clients` folder +- download the servers zip for your platform, and unzip it to a `servers` folder +- configure your plugin to use your downloaded client and server + +### Override the artifact manifest +Configure your plugin to download and install the build linked to this release. +- Override your plugin's manifest url to use ${MANIFEST_URL} diff --git a/.github/workflows/create-agent-standalone.yml b/.github/workflows/create-agent-standalone.yml new file mode 100644 index 0000000000..12a8fdfb12 --- /dev/null +++ b/.github/workflows/create-agent-standalone.yml @@ -0,0 +1,102 @@ +name: Create agent-standalone bundles + +on: + push: + branches: [main, feature/*, release/agentic/*] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + lfs: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm i + + - name: Compile project + run: npm run compile + + - name: Generate agent standalone + run: | + npm run ci:generate:agent-standalone -w app/aws-lsp-codewhisperer-runtimes + npm run ci:generate:agentic:attribution + + # We "flatten" out each clients.zip-servers.zip pairing so that the + # downloadable artifacts are nicely organized, one per platform. + - name: Prepare and upload artifacts + run: | + platforms=("linux-arm64" "linux-x64" "mac-arm64" "mac-x64" "win-x64") + for platform in "${platforms[@]}"; do + echo "Preparing artifacts for $platform" + mkdir -p "_artifacts/$platform" + + cp "app/aws-lsp-codewhisperer-runtimes/build/archives/shared/clients.zip" "_artifacts/$platform/" + cp "app/aws-lsp-codewhisperer-runtimes/build/archives/agent-standalone/$platform/servers.zip" "_artifacts/$platform/" + done + mkdir -p "_artifacts/clients" + unzip "app/aws-lsp-codewhisperer-runtimes/build/archives/shared/clients.zip" -d _artifacts/clients + + # GitHub Actions zips the archive, so we upload the folder used to + # produce clients.zip. Otherwise we have a clients.zip artifact + # that contains our clients.zip file. + # app/aws-lsp-codewhisperer-runtimes/build/archives/shared/clients.zip + - name: Upload clients.zip + uses: actions/upload-artifact@v4 + with: + name: clients + path: _artifacts/clients/ + if-no-files-found: error + + - name: Upload linux-arm64 + uses: actions/upload-artifact@v4 + with: + name: linux-arm64 + path: _artifacts/linux-arm64/ + if-no-files-found: error + + - name: Upload linux-x64 + uses: actions/upload-artifact@v4 + with: + name: linux-x64 + path: _artifacts/linux-x64/ + if-no-files-found: error + + - name: Upload mac-arm64 + uses: actions/upload-artifact@v4 + with: + name: mac-arm64 + path: _artifacts/mac-arm64/ + if-no-files-found: error + + - name: Upload mac-x64 + uses: actions/upload-artifact@v4 + with: + name: mac-x64 + path: _artifacts/mac-x64/ + if-no-files-found: error + + - name: Upload win-x64 + uses: actions/upload-artifact@v4 + with: + name: win-x64 + path: _artifacts/win-x64/ + if-no-files-found: error + + - name: Upload THIRD_PARTY_LICENSES + uses: actions/upload-artifact@v4 + with: + name: THIRD_PARTY_LICENSES + path: attribution/THIRD_PARTY_LICENSES + if-no-files-found: error diff --git a/.github/workflows/create-agentic-github-prerelease.yml b/.github/workflows/create-agentic-github-prerelease.yml new file mode 100644 index 0000000000..48873b7503 --- /dev/null +++ b/.github/workflows/create-agentic-github-prerelease.yml @@ -0,0 +1,167 @@ +name: Create GitHub Prerelease - Agentic Chat + +permissions: + actions: read + contents: read + +on: + workflow_run: + workflows: [Create agent-standalone bundles] + types: + - completed + branches: [main, feature/*, release/agentic/*] + +jobs: + setup-vars: + runs-on: ubuntu-latest + outputs: + tagname: ${{ steps.build.outputs.tagname }} + serverversion: ${{ steps.build.outputs.serverversion }} + prereleasename: ${{ steps.build.outputs.prereleasename }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + # if user ran this action manually + - if: github.event_name == 'workflow_dispatch' + run: | + echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV + echo "PRERELEASE_NAME=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV + + # Otherwise a push to a branch triggered this action. + # Set TAG_NAME and PRERELEASE_NAME based on branch name + - if: github.event_name != 'workflow_dispatch' + run: | + BRANCH_NAME="${{ github.event.workflow_run.head_branch }}" + if [[ "$BRANCH_NAME" == "main" ]]; then + echo "TAG_NAME=agentic-alpha" >> $GITHUB_ENV + echo "PRERELEASE_NAME=alpha" >> $GITHUB_ENV + elif [[ "$BRANCH_NAME" == feature/* ]]; then + REMAINDER=$(echo "$BRANCH_NAME" | sed 's/^feature\///') + echo "TAG_NAME=agentic-pre-$REMAINDER" >> $GITHUB_ENV + echo "PRERELEASE_NAME=$REMAINDER" >> $GITHUB_ENV + elif [[ "$BRANCH_NAME" == release/agentic/* ]]; then + REMAINDER=$(echo "$BRANCH_NAME" | sed 's/^release\/agentic\///') + echo "TAG_NAME=agentic-rc-$REMAINDER" >> $GITHUB_ENV + echo "PRERELEASE_NAME=rc" >> $GITHUB_ENV + else + echo "Error: creating agentic releases for this branch is not supported" + exit 1 + fi + + # Make a sever version that is "decorated" as prerelease + - name: Create SERVER_VERSION + run: | + # example: 1.0.999-pre-main.commitid + # SERVER_VERSION - we're making "imitation" manifests that are accessible + # from GitHub releases, as a convenience for plugins to easily consume + # test/development builds. The version is pulled from the agenticChat field + # in the version.json file. + + AGENTIC_VERSION=$(jq -r '.agenticChat' app/aws-lsp-codewhisperer-runtimes/src/version.json) + COMMIT_SHORT=$(echo "${{ github.event.workflow_run.head_sha }}" | cut -c1-8) + echo "SERVER_VERSION=$AGENTIC_VERSION-$PRERELEASE_NAME.$COMMIT_SHORT" >> $GITHUB_ENV + + - name: Export outputs + id: build + run: | + # tag name is the git tag that the github release is linked with + echo "tagname=$TAG_NAME" >> $GITHUB_OUTPUT + # pre-release name is the semver pre-release decorator (eg 'alpha', 'rc', ...) + echo "prereleasename=$PRERELEASE_NAME" >> $GITHUB_OUTPUT + echo "serverversion=$SERVER_VERSION" >> $GITHUB_OUTPUT + + create-release: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + needs: [setup-vars] + + env: + # + # For `gh` cli. + # + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ needs.setup-vars.outputs.tagname }} + # + # Used in release_notes.md and git tag + # + BRANCH: ${{ github.event.workflow_run.head_branch }} + COMMIT_ID: ${{ github.event.workflow_run.head_sha }} + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + # To run a ts script to create the manifest + - name: Install dependencies + run: npm i + + # Download all the files uploaded by .github/workflows/create-agent-standalone.yml + - name: Download all platform artifacts + uses: actions/download-artifact@v4 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + path: ./downloaded-artifacts + + # actions/download-artifact@v4 unzips all of the artifacts + # Flatten all files we want to attach to the Release into _release-artifacts/ + - name: Create Release Artifacts + run: | + mkdir -p _release-artifacts + + # servers.zip - one per platform + platforms=("linux-arm64" "linux-x64" "mac-arm64" "mac-x64" "win-x64") + for platform in "${platforms[@]}"; do + cp downloaded-artifacts/$platform/servers.zip _release-artifacts/$platform-servers.zip + done + + # clients.zip : just pick one of the platforms, they're all the same file + cp downloaded-artifacts/linux-x64/clients.zip _release-artifacts/clients.zip + + # THIRD_PARTY_LICENSES + cp downloaded-artifacts/THIRD_PARTY_LICENSES/THIRD_PARTY_LICENSES _release-artifacts/THIRD_PARTY_LICENSES + + # Manifest assigned to the GitHub release will only ever contain one version, + # which points to the assets uploaded to the release (the latest commit). + - name: Create Artifact Manifest + env: + SERVER_VERSION: ${{ needs.setup-vars.outputs.serverversion }} + RELEASE_ARTIFACTS_PATH: ${{ github.workspace }}/_release-artifacts + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + + run: | + npm run ci:generate:manifest -w app/aws-lsp-codewhisperer-runtimes/ + + - name: Remove existing release + run: | + # Remove the existing release (if it exists), we (re)create it next. + gh release delete "$TAG_NAME" --cleanup-tag --yes || true + + - name: Create GitHub Release + env: + SERVER_VERSION: ${{ needs.setup-vars.outputs.serverversion }} + PRERELEASE_NAME: ${{ needs.setup-vars.outputs.prereleasename }} + # MANIFEST_URL example: + # https://github.com/aws/language-servers/releases/download/pre-main/manifest.json + MANIFEST_URL: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ needs.setup-vars.outputs.tagname }}/manifest.json + + run: | + # Produce the text for the release description + envsubst < "$GITHUB_WORKSPACE/.github/workflows/agentic-prerelease-release-notes.md" > "$RUNNER_TEMP/release_notes.md" + + # main and feature branches create alpha builds. + # In the future, release candidate branches will create preprod builds + gh release create $TAG_NAME --prerelease --notes-file "$RUNNER_TEMP/release_notes.md" --title "Agentic Chat: $PRERELEASE_NAME ($BRANCH)" --target $COMMIT_ID _release-artifacts/* diff --git a/.github/workflows/create-release-candidate-branch.yml b/.github/workflows/create-release-candidate-branch.yml new file mode 100644 index 0000000000..7c6b449a94 --- /dev/null +++ b/.github/workflows/create-release-candidate-branch.yml @@ -0,0 +1,117 @@ +name: Set up a new Release Candidate + +on: + workflow_dispatch: + inputs: + versionIncrement: + description: 'Release Version Increment' + default: 'Minor' + required: true + type: choice + options: + - Major + - Minor + - Patch + - Custom + customVersion: + description: "Custom Release Version (only used if release increment is 'Custom') - Format: 1.2.3" + default: '' + required: false + type: string + commitId: + description: 'The commit Id to produce a release candidate with' + default: '' + required: true + type: string + +jobs: + setupRcBranch: + name: Set up a Release Candidate Branch + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Sync code + uses: actions/checkout@v4 + with: + ref: ${{ inputs.commitId }} + token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + # Needed to format the json file being checked in + - name: Install dependencies + run: npm ci + + - name: Calculate Release Version + id: release-version + env: + VERSION_FILE: app/aws-lsp-codewhisperer-runtimes/src/version.json + run: | + customVersion="${{ inputs.customVersion }}" + versionIncrement="${{ inputs.versionIncrement }}" + + # Read current version + currentVersion=$(jq -r '.agenticChat' "$VERSION_FILE") + + if [[ "$versionIncrement" == "Custom" && -n "$customVersion" ]]; then + newVersion="$customVersion" + else + # Parse current version + IFS='.' read -r major minor patch <<< "$currentVersion" + + case "$versionIncrement" in + "Major") + major=$((major + 1)) + minor=0 + patch=0 + ;; + "Minor") + minor=$((minor + 1)) + patch=0 + ;; + "Patch") + patch=$((patch + 1)) + ;; + esac + + newVersion="$major.$minor.$patch" + fi + + # Update version.json + jq --arg version "$newVersion" '.agenticChat = $version' "$VERSION_FILE" > tmp.json && mv tmp.json "$VERSION_FILE" + + # Set output only + echo "RELEASE_VERSION=$newVersion" >> $GITHUB_OUTPUT + + git add "$VERSION_FILE" + + # Ensure the file does not cause issues when merged to main + npm run format-staged + + - name: Create Release Candidate Branch + id: release-branch + env: + RELEASE_VERSION: ${{ steps.release-version.outputs.RELEASE_VERSION }} + run: | + branch="release/agentic/$RELEASE_VERSION" + git checkout -b "$branch" + + # Save the branch value as output only + echo "BRANCH_NAME=$branch" >> $GITHUB_OUTPUT + + - name: Commit and Push changes + env: + BRANCH_NAME: ${{ steps.release-branch.outputs.BRANCH_NAME }} + RELEASE_VERSION: ${{ steps.release-version.outputs.RELEASE_VERSION }} + run: | + git config --global user.email "<>" + git config --global user.name "aws-toolkit-automation" + git commit --no-verify -m "chore: bump agentic version: $RELEASE_VERSION" + git push --set-upstream origin "$BRANCH_NAME" diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000000..48da971401 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,77 @@ +name: Integration Tests + +on: + workflow_run: + workflows: [Create agent-standalone bundles] + types: + - completed + branches: [main, feature/*, release/agentic/*] + +jobs: + agent-server-tests: + name: Agent Server Tests (${{ matrix.target }}) + if: ${{ github.event.workflow_run.conclusion == 'success' }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04-arm + target: linux-arm64 + - os: ubuntu-latest + target: linux-x64 + - os: macos-latest + target: mac-arm64 + - os: macos-13 + target: mac-x64 + - os: windows-latest + target: win-x64 + runs-on: ${{ matrix.os }} + permissions: + id-token: write + contents: read + steps: + - name: Sync Code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + name: ${{ matrix.target }} + path: ./downloaded-artifacts + - name: Extract server files + run: | + cd ./downloaded-artifacts/ + unzip servers.zip + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::964765661569:role/GitHubActionsTokenRefresherRole + role-session-name: language-servers-github + aws-region: us-east-1 + - name: Build + run: | + npm ci + npm run compile + - name: Refresh Token + run: aws lambda invoke --function-name TokenRefresherLambda --region us-east-1 --payload '{}' response.json + - name: Get SSO Token + uses: aws-actions/aws-secretsmanager-get-secrets@v2 + with: + secret-ids: | + ,SsoTokenSecret + parse-json-secrets: true + - name: Run Integration Tests + run: | + npm run test-integ -w integration-tests/q-agentic-chat-server + env: + TEST_SSO_TOKEN: ${{ env.SSOTOKEN }} + TEST_SSO_START_URL: ${{ secrets.TEST_SSO_START_URL }} + TEST_PROFILE_ARN: ${{ secrets.TEST_PROFILE_ARN }} + TEST_RUNTIME_FILE: ${{ github.workspace }}/downloaded-artifacts/aws-lsp-codewhisperer.js diff --git a/.github/workflows/lsp-ci.yaml b/.github/workflows/lsp-ci.yaml index f717b46ee1..814c198f05 100644 --- a/.github/workflows/lsp-ci.yaml +++ b/.github/workflows/lsp-ci.yaml @@ -1,9 +1,9 @@ name: Language Server CI on: push: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] pull_request: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] jobs: test: @@ -15,14 +15,20 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 24 - name: Build run: | npm ci npm run check:formatting - - name: Test + - name: Test with Coverage run: | - npm run test + npm run test:coverage + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v5 + with: + flags: unittests + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} build: name: Package runs-on: ubuntu-latest @@ -32,7 +38,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 24 - name: Build run: | npm ci @@ -57,7 +63,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 24 - name: Build run: | npm ci @@ -73,7 +79,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 24 - name: Build run: | npm ci diff --git a/.github/workflows/npm-packaging.yaml b/.github/workflows/npm-packaging.yaml index 724c9a0c05..4b509d9294 100644 --- a/.github/workflows/npm-packaging.yaml +++ b/.github/workflows/npm-packaging.yaml @@ -1,9 +1,9 @@ name: NPM Packaging on: push: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] pull_request: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] jobs: build: @@ -15,7 +15,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 24 - name: Install dependencies run: npm ci - name: Build all monorepo packages diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index d23eb79213..4d28d449b0 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -6,7 +6,7 @@ on: - main permissions: - id-token: write # This is required for requesting the JWT (aws-actions/configure-aws-credentials) + id-token: write # Required for OIDC authentication with npm contents: write # to create release commit (google-github-actions/release-please-action) pull-requests: write # to create release PR (google-github-actions/release-please-action) @@ -31,34 +31,14 @@ jobs: persist-credentials: false if: ${{ fromJson(steps.release.outputs.releases_created) }} - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::935785792371:role/GithubNpmPublishAction - role-session-name: language-servers-github - aws-region: us-east-1 - if: ${{ fromJson(steps.release.outputs.releases_created) }} - - - name: Get npm access token - uses: aws-actions/aws-secretsmanager-get-secrets@v2 - with: - secret-ids: | - npmjs/github_automation - parse-json-secrets: true - if: ${{ fromJson(steps.release.outputs.releases_created) }} - - name: Setup Nodejs uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: '24.x' registry-url: 'https://registry.npmjs.org' scope: '@aws' if: ${{ fromJson(steps.release.outputs.releases_created) }} - - name: Set token - run: echo "NODE_AUTH_TOKEN=${{ env.NPMJS_GITHUB_AUTOMATION_TOKEN }}" >> $GITHUB_ENV - if: ${{ fromJson(steps.release.outputs.releases_created) }} - - name: Compile and test packages run: | npm clean-install @@ -92,3 +72,15 @@ jobs: - name: Publish LSP Yaml to npm run: npm publish --workspace server/aws-lsp-yaml if: ${{ steps.release.outputs['server/aws-lsp-yaml--release_created'] }} + + - name: Publish LSP Identity to npm + run: npm publish --workspace server/aws-lsp-identity + if: ${{ steps.release.outputs['server/aws-lsp-identity--release_created'] }} + + - name: Publish LSP Notification to npm + run: npm publish --workspace server/aws-lsp-notification + if: ${{ steps.release.outputs['server/aws-lsp-notification--release_created'] }} + + - name: Publish LSP S3 to npm + run: npm publish --workspace server/aws-lsp-s3 + if: ${{ steps.release.outputs['server/aws-lsp-s3--release_created'] }} diff --git a/.gitignore b/.gitignore index c40dde5d7c..8f3af70b45 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,10 @@ build .gradle .idea **/*.tgz +!core/codewhisperer-streaming/amzn-codewhisperer-streaming-*.tgz !core/q-developer-streaming-client/amzn-amazon-q-developer-streaming-client-*.tgz +!core/codewhisperer-runtime/amzn-codewhisperer-runtime-*.tgz +!core/codewhisperer/amzn-codewhisperer-*.tgz !server/aws-lsp-codewhisperer/types/types-local-indexing-*.tgz .testresults/** diff --git a/.prettierignore b/.prettierignore index d115549b16..89ecd86fb3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,7 +4,5 @@ node_modules/ out/ **/bin/ **/obj/ -server/aws-lsp-codewhisperer/src/client/sigv4/codewhisperersigv4client.d.ts -server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts **/*.md **/antlr-generated/ \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b964b20038..caae52434b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,9 +1,9 @@ { - "chat-client": "0.1.16", - "core/aws-lsp-core": "0.0.9", - "server/aws-lsp-antlr4": "0.1.11", - "server/aws-lsp-codewhisperer": "0.0.49", - "server/aws-lsp-json": "0.1.11", - "server/aws-lsp-partiql": "0.0.12", - "server/aws-lsp-yaml": "0.1.11" + "chat-client": "0.1.41", + "core/aws-lsp-core": "0.0.16", + "server/aws-lsp-antlr4": "0.1.20", + "server/aws-lsp-codewhisperer": "0.0.88", + "server/aws-lsp-json": "0.1.21", + "server/aws-lsp-partiql": "0.0.18", + "server/aws-lsp-yaml": "0.1.21" } diff --git a/README.md b/README.md index e559c296a0..0578e63646 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Language Servers for AWS +[![codecov](https://codecov.io/github/aws/language-servers/graph/badge.svg?token=ZSHpIVkG8S)](https://codecov.io/github/aws/language-servers) +[![Integration Tests](https://github.com/aws/language-servers/actions/workflows/integration-tests.yml/badge.svg)](https://github.com/aws/language-servers/actions/workflows/integration-tests.yml) + Language servers for integration with IDEs and Editors, which implement the protocol (LSP extensions) defined in the [language-server-runtimes](https://github.com/aws/language-server-runtimes/tree/main/runtimes) repo. ## Where things go diff --git a/app/aws-lsp-antlr4-runtimes/package.json b/app/aws-lsp-antlr4-runtimes/package.json index 7047f279dc..747645c40d 100644 --- a/app/aws-lsp-antlr4-runtimes/package.json +++ b/app/aws-lsp-antlr4-runtimes/package.json @@ -12,10 +12,10 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.96", + "@aws/language-server-runtimes": "^0.3.1", "@aws/lsp-antlr4": "*", - "antlr4-c3": "^3.4.1", - "antlr4ng": "^3.0.4" + "antlr4-c3": "^3.4.2", + "antlr4ng": "^3.0.14" }, "devDependencies": { "@types/chai": "^4.3.5", @@ -26,7 +26,7 @@ "chai-as-promised": "^7.1.1", "mocha": "^11.0.1", "ts-loader": "^9.4.4", - "ts-lsp-client": "^1.0.3", + "ts-lsp-client": "1.0.3", "webpack": "^5.94.0", "webpack-cli": "^6.0.1" } diff --git a/app/aws-lsp-buildspec-runtimes/package.json b/app/aws-lsp-buildspec-runtimes/package.json index aeaa60226f..8daa91b5e1 100644 --- a/app/aws-lsp-buildspec-runtimes/package.json +++ b/app/aws-lsp-buildspec-runtimes/package.json @@ -7,6 +7,7 @@ "compile": "tsc --build" }, "dependencies": { + "@aws/language-server-runtimes": "^0.3.1", "@aws/lsp-buildspec": "^0.0.1" } } diff --git a/app/aws-lsp-cloudformation-runtimes/package.json b/app/aws-lsp-cloudformation-runtimes/package.json index 817591a6b6..e4e277ecde 100644 --- a/app/aws-lsp-cloudformation-runtimes/package.json +++ b/app/aws-lsp-cloudformation-runtimes/package.json @@ -7,6 +7,7 @@ "compile": "tsc --build" }, "dependencies": { + "@aws/language-server-runtimes": "^0.3.1", "@aws/lsp-cloudformation": "^0.0.1" } } diff --git a/app/aws-lsp-codewhisperer-runtimes/README.md b/app/aws-lsp-codewhisperer-runtimes/README.md index 977d514bd6..71fbcf0456 100644 --- a/app/aws-lsp-codewhisperer-runtimes/README.md +++ b/app/aws-lsp-codewhisperer-runtimes/README.md @@ -76,6 +76,17 @@ The server is managed via scripts/dev-server.js, which ensures: **NOTE**: Tests are currently disabled for Windows as we currently face issues with automatically shutting down devserver and cleaning resources after tests are executed. +## Binary Dependencies + +### registry.node +The file `_bundle-assets/registry-js/win32-x64/registry.node` is a precompiled binary downloaded from the [registry-js](https://github.com/desktop/registry-js) project. + +- **Current version**: v1.16.1 (released May 21, 2024) +- **Source**: https://github.com/desktop/registry-js/releases +- **Purpose**: Provides Windows registry access functionality + +**To update**: Download the latest `registry.node` binary for win32-x64 from the registry-js releases page and replace the existing file. + #### Tests configuration - Test settings are defined in `wdio.conf.ts` - The actual test implementation is in the `test/e2e` folder diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-arm64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-arm64.zip new file mode 100644 index 0000000000..5ec48e8dcb --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-arm64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fbc1d7b3946a1589e6402c29508e47169cd5cddaa959fd78c79981628b02be6 +size 96647671 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-x64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-x64.zip new file mode 100644 index 0000000000..852d8e9a90 --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-x64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cab6cbcc4673a88c0ff7bee6a7dc4fc07a49fb96bdd57d6fb7c535214f28117e +size 98328308 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-arm64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-arm64.zip new file mode 100644 index 0000000000..d5faff8c01 --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-arm64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c270b57e543a50c21f834311167a5586f333e77f40372b17758755f35d12b96 +size 102589992 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-x64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-x64.zip new file mode 100644 index 0000000000..445e268d3e --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-x64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4bdaf1ef6c8cdf2ecce6879d4ae736fcb21a735e62bf0fa3ae0ae403b63a249 +size 114552321 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-win32-x64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-win32-x64.zip new file mode 100644 index 0000000000..af1593dc7c --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-win32-x64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80f09bbfc11c343b6e1d069b06cec853c6e2372e4ae7884ff466d9b67262755f +size 113890429 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node new file mode 100644 index 0000000000..d7b5091a2c Binary files /dev/null and b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node differ diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-darwin-arm64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-darwin-arm64.zip new file mode 100644 index 0000000000..bc380df2dc --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-darwin-arm64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29ed930601e40f565e0b9a0aa6a108e74d3582c117c7c4c494f709c9b4df5400 +size 1797214 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-darwin-x64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-darwin-x64.zip new file mode 100644 index 0000000000..186b837dbb --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-darwin-x64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5af3be09ec028ed8b1c39ea27016f535eca97f7824fb946c8d82ced4c21e007a +size 2093945 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-linux-arm64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-linux-arm64.zip new file mode 100644 index 0000000000..a90cff5167 --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-linux-arm64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57e99369471681728a193bc8c47b375891978056997ad033d2accc20b5c7f0a8 +size 2051466 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-linux-x64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-linux-x64.zip new file mode 100644 index 0000000000..fc656ccf2f --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-linux-x64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b5c18e5d60bd39ba44c63aa6a52663782297376d5fc9742b5208d40c06dbbcd +size 2569247 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-win32-x64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-win32-x64.zip new file mode 100644 index 0000000000..d2bcea4461 --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/ripgrep-win32-x64.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5dcdb115d6e5909d2d4bc7e555e9fa1af4ea582e98fada3bc25a7a2e93f943b +size 2123710 diff --git a/app/aws-lsp-codewhisperer-runtimes/package.json b/app/aws-lsp-codewhisperer-runtimes/package.json index 99cf0cc5bf..fac1a07ce9 100644 --- a/app/aws-lsp-codewhisperer-runtimes/package.json +++ b/app/aws-lsp-codewhisperer-runtimes/package.json @@ -5,9 +5,17 @@ "main": "out/index.js", "scripts": { "clean": "rm -rf out/ bin/ node_modules/ build/ dist/ tsconfig.tsbuildinfo .tsbuildinfo", - "compile": "tsc --build", + "compile": "tsc --build && copyfiles -f src/version.json out/", "package": "npm run compile && cross-env NODE_OPTIONS=--max_old_space_size=8172 npm run webpack", + "package:prod": "npm run compile && cross-env NODE_OPTIONS=--max_old_space_size=8172 npm run webpack:prod", "webpack": "webpack", + "webpack:prod": "webpack --config webpack.config.prod.js", + "copy:native-deps:agent-standalone": "copyfiles -f _bundle-assets/registry-js/win32-x64/registry.node build/private/bundle/agent-standalone", + "copy:resources:agent-standalone": "copyfiles -f --error ../../node_modules/@aws/lsp-identity/src/sso/authorizationCodePkce/resources/**/* build/private/bundle/agent-standalone/resources", + "generate:node-assets": "./scripts/download-node.sh && ts-node src/scripts/copy-node-assets.ts", + "generate:build-archive": "./scripts/package.sh", + "ci:generate:agent-standalone": "npm run package:prod && npm run copy:native-deps:agent-standalone && npm run copy:resources:agent-standalone && npm run generate:node-assets && npm run generate:build-archive", + "ci:generate:manifest": "ts-node scripts/create-repo-manifest.ts", "start": "cross-env NODE_OPTIONS=--max_old_space_size=8172 node scripts/dev-server.js start", "stop-dev-server": "node scripts/dev-server.js stop", "test": "node scripts/test-runner.js", @@ -15,7 +23,7 @@ "local-build": "node scripts/local-build.js" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.96", + "@aws/language-server-runtimes": "^0.3.1", "@aws/lsp-codewhisperer": "*", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", @@ -27,6 +35,7 @@ "process": "^0.11.10", "stream-browserify": "^3.0.0", "stream-http": "^3.2.0", + "url": "^0.11.4", "vscode-languageserver": "^9.0.1", "wdio": "^6.0.1", "webpack-dev-server": "^5.2.0" diff --git a/app/aws-lsp-codewhisperer-runtimes/scripts/create-repo-manifest.ts b/app/aws-lsp-codewhisperer-runtimes/scripts/create-repo-manifest.ts new file mode 100644 index 0000000000..dad9ce270c --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/scripts/create-repo-manifest.ts @@ -0,0 +1,346 @@ +// Produces an artifact manifest that is "close enough" to a real one, to +// reference artifacts attached to a GitHub release for main or feature branches. +// The GitHub release only ever holds one artifact version (the most recent commit), +// so the manifest will only ever contain a single artifact version. + +import * as fs from 'fs' +import { exec } from 'child_process' +import * as path from 'path' + +export type SemVer = string + +export type Version = { + name: string + version: SemVer +} + +export type Platform = 'windows' | 'linux' | 'darwin' +export type Arch = 'arm64' | 'x64' +export type TargetContent = { + filename: string + url: string + hashes: string[] + bytes: number +} + +export type PlatformTarget = { + platform: Platform + arch: Arch + contents: TargetContent[] +} + +export type ManifestServerVersionEntry = { + serverVersion: string + isDelisted: boolean + runtime: Version + capabilities?: Version[] + protocol?: Version[] + thirdPartyLicenses: string + targets: PlatformTarget[] +} + +export type Manifest = { + manifestSchemaVersion: string + artifactId: string + artifactDescription: string + isManifestDeprecated: boolean + versions: ManifestServerVersionEntry[] +} + +interface Params { + serverVersion: string + releaseArtifactsPath: string + repoUrl: string + gitHubReleaseName: string +} + +const serversZipName = 'servers.zip' +const clientsZipName = 'clients.zip' + +/** + * Updates the manifest file with new version information or performs rollback operations. + * + * @async + * @param {string} manifestPath - file path to save the manifest to. + * @param {Object} params - The parameters for updating the manifest. + * @param {string} params.serverVersion - The server version. + * @param {string} params.releaseArtifactsPath - folder containing the artifacts to load file attributes from + * @param {string} params.repoUrl - url to the github repo (https://github.com/aws/language-servers) + * @param {string} params.gitHubReleaseName - the name of the GitHub release this will refer to (pre-main) + * + * @description + * This function performs the following operations: + * 1. Calculates SHA384 checksums and file sizes for clients.zip and servers.zip files. + * 2. Generates a new manifest entry with the provided and calculated information. + * 3. Produces a manifest file, containing only this one version. + * 4. Saves the updated manifest to a file. + */ +export async function updateManifest( + manifestPath: string, + { serverVersion, releaseArtifactsPath, repoUrl, gitHubReleaseName }: Params +) { + function getGitHubReleaseDownloadUrl(filename: string) { + return `${repoUrl}/releases/download/${gitHubReleaseName}/${filename}` + } + + function getServerZipPath(platform: string, arch: string): string { + return path.join(releaseArtifactsPath, `${platform}-${arch}-${serversZipName}`) + } + + async function getServerZipFileInfo(platform: string, arch: string): Promise { + const serverZipPath = getServerZipPath(platform, arch) + const sha = await run(`sha384sum ${serverZipPath} | awk '{print $1}'`) + const bytes = await run(`wc -c < ${serverZipPath}`) + return { + url: getGitHubReleaseDownloadUrl(path.basename(serverZipPath)), + hash: sha, + bytes: bytes, + } + } + + const clientZipPath = path.join(releaseArtifactsPath, clientsZipName) + + async function getClientZipFileInfo() { + const sha = await run(`sha384sum ${clientZipPath} | awk '{print $1}'`) + const bytes = await run(`wc -c < ${clientZipPath}`) + return { + url: getGitHubReleaseDownloadUrl(clientsZipName), + hash: sha, + bytes: bytes, + } + } + + const manifest: Manifest = { + manifestSchemaVersion: '0.1', + artifactId: 'CodeWhispererStandaloneRuntimeServer', + artifactDescription: 'LSP servers with CodeWhisperer on standalone runtime', + isManifestDeprecated: false, + versions: [], + } + + const licensesURL = getGitHubReleaseDownloadUrl('THIRD_PARTY_LICENSES') + const newEntry = generateNewEntry({ + version: { + server: serverVersion, + }, + serverZips: { + win: { + x64: await getServerZipFileInfo('win', 'x64'), + arm64: await getServerZipFileInfo('win', 'x64'), + }, + linux: { + x64: await getServerZipFileInfo('linux', 'x64'), + arm64: await getServerZipFileInfo('linux', 'arm64'), + }, + mac: { + x64: await getServerZipFileInfo('mac', 'x64'), + arm64: await getServerZipFileInfo('mac', 'arm64'), + }, + }, + clientZip: await getClientZipFileInfo(), + licensesURL, + }) + + manifest.versions = [newEntry] + + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) +} + +interface FileInfo { + url: string + hash: string + bytes: string +} + +interface EntryParameters { + version: { + server: string + } + serverZips: { + win: { + x64: FileInfo + arm64: FileInfo + } + linux?: { + x64: FileInfo + arm64: FileInfo + } + mac?: { + x64: FileInfo + arm64: FileInfo + } + } + clientZip: FileInfo + licensesURL: string +} + +function generateNewEntry({ + version, + serverZips, + clientZip, + licensesURL, +}: EntryParameters): ManifestServerVersionEntry { + return { + serverVersion: version.server, + isDelisted: false, + runtime: { + name: 'standalone', + version: '0.0.1', // arbitrary, not used for alpha/preprod manifests + }, + thirdPartyLicenses: licensesURL, + targets: [ + { + platform: 'windows', + arch: 'x64', + contents: [ + { + filename: serversZipName, + url: serverZips.win.x64.url, + hashes: [`sha384:${serverZips.win.x64.hash}`], + bytes: parseInt(serverZips.win.x64.bytes), + }, + { + filename: clientsZipName, + url: clientZip.url, + hashes: [`sha384:${clientZip.hash}`], + bytes: parseInt(clientZip.bytes), + }, + ], + }, + { + platform: 'windows', + arch: 'arm64', + contents: [ + { + filename: serversZipName, + url: serverZips.win.arm64.url, + hashes: [`sha384:${serverZips.win.arm64.hash}`], + bytes: parseInt(serverZips.win.arm64.bytes), + }, + { + filename: clientsZipName, + url: clientZip.url, + hashes: [`sha384:${clientZip.hash}`], + bytes: parseInt(clientZip.bytes), + }, + ], + }, + { + platform: 'linux', + arch: 'x64', + contents: [ + { + filename: serversZipName, + url: serverZips.linux!.x64.url, + hashes: [`sha384:${serverZips.linux!.x64.hash}`], + bytes: parseInt(serverZips.linux!.x64.bytes), + }, + { + filename: clientsZipName, + url: clientZip.url, + hashes: [`sha384:${clientZip.hash}`], + bytes: parseInt(clientZip.bytes), + }, + ], + }, + { + platform: 'linux', + arch: 'arm64', + contents: [ + { + filename: serversZipName, + url: serverZips.linux!.arm64.url, + hashes: [`sha384:${serverZips.linux!.arm64.hash}`], + bytes: parseInt(serverZips.linux!.arm64.bytes), + }, + { + filename: clientsZipName, + url: clientZip.url, + hashes: [`sha384:${clientZip.hash}`], + bytes: parseInt(clientZip.bytes), + }, + ], + }, + { + platform: 'darwin', + arch: 'x64', + contents: [ + { + filename: serversZipName, + url: serverZips.mac!.x64.url, + hashes: [`sha384:${serverZips.mac!.x64.hash}`], + bytes: parseInt(serverZips.mac!.x64.bytes), + }, + { + filename: clientsZipName, + url: clientZip.url, + hashes: [`sha384:${clientZip.hash}`], + bytes: parseInt(clientZip.bytes), + }, + ], + }, + { + platform: 'darwin', + arch: 'arm64', + contents: [ + { + filename: serversZipName, + url: serverZips.mac!.arm64.url, + hashes: [`sha384:${serverZips.mac!.arm64.hash}`], + bytes: parseInt(serverZips.mac!.arm64.bytes), + }, + { + filename: clientsZipName, + url: clientZip.url, + hashes: [`sha384:${clientZip.hash}`], + bytes: parseInt(clientZip.bytes), + }, + ], + }, + ], + } +} + +function run(command: string): Promise { + return new Promise((resolve, reject) => { + exec(command, (error: any, stdout: string, stderr: string) => { + if (error) { + reject(error) + } else { + resolve(stdout.trim()) + } + }) + }) +} + +;(async () => { + console.log(`SERVER_VERSION: ${process.env.SERVER_VERSION}`) + console.log(`RELEASE_ARTIFACTS_PATH: ${process.env.RELEASE_ARTIFACTS_PATH}`) + console.log(`REPO_URL: ${process.env.REPO_URL}`) + console.log(`TAG_NAME: ${process.env.TAG_NAME}`) + + if (!process.env.SERVER_VERSION) { + throw new Error('Missing envvar: SERVER_VERSION') + } + + if (!process.env.RELEASE_ARTIFACTS_PATH) { + throw new Error('Missing envvar: RELEASE_ARTIFACTS_PATH') + } + + if (!process.env.REPO_URL) { + throw new Error('Missing envvar: REPO_URL') + } + + if (!process.env.TAG_NAME) { + throw new Error('Missing envvar: TAG_NAME') + } + + const releaseArtifactsPath = process.env.RELEASE_ARTIFACTS_PATH + const manifestPath = path.join(releaseArtifactsPath, 'manifest.json') + await updateManifest(manifestPath, { + serverVersion: process.env.SERVER_VERSION, + releaseArtifactsPath, + repoUrl: process.env.REPO_URL, + gitHubReleaseName: process.env.TAG_NAME, + }) +})() diff --git a/app/aws-lsp-codewhisperer-runtimes/scripts/download-node.sh b/app/aws-lsp-codewhisperer-runtimes/scripts/download-node.sh new file mode 100755 index 0000000000..35322d871b --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/scripts/download-node.sh @@ -0,0 +1,99 @@ +#!/bin/bash + +# Downloads node distibutions and places them in +# build/node-assets, which is picked up +# by src/scripts/copy-node-assets.ts, to produce the final bundle. + +set -eo pipefail +NODE_VERSION="24" +BASE_URL="https://nodejs.org/download/release/v24.9.0" +SHASUMS_FILE="SHASUMS256.txt" +ASSETS_DIR="build/node-assets" + +# Download SHASUMS256.txt +curl -s "$BASE_URL/$SHASUMS_FILE" -o "$SHASUMS_FILE" + +# Extract exact Node.js version from any entry in SHASUMS256.txt +# NODE_SEMVER=$(grep -o 'node-v[0-9]*\.[0-9]*\.[0-9]*' SHASUMS256.txt | head -1 | cut -d'v' -f2) + +# temporarily lock node.js version to 24.9.0 due to https://github.com/nodejs/node/issues/60176 +NODE_SEMVER="24.9.0" + +if [ -z "$NODE_SEMVER" ]; then + echo "Failed to extract Node.js version from SHASUMS256.txt" + exit 1 +fi + +echo "Found latest Node.js version: $NODE_SEMVER" + +echo "Downloading assets for node version $NODE_SEMVER" + +# Remove all files from ASSETS directory +rm -rf "$ASSETS_DIR" && mkdir "$ASSETS_DIR" + +# Define expected files +EXPECTED_FILES=( + "win-x64/node.exe" + "node-v$NODE_SEMVER-linux-x64.tar.gz" + "node-v$NODE_SEMVER-darwin-arm64.tar.gz" + "node-v$NODE_SEMVER-linux-arm64.tar.gz" + "node-v$NODE_SEMVER-darwin-x64.tar.gz" +) + +# Process each expected file pattern +for actual_file in "${EXPECTED_FILES[@]}"; do + # Search for the file in SHASUMS256.txt + if grep -q "$actual_file" SHASUMS256.txt; then + filepath="$ASSETS_DIR/$actual_file" + expected_sum=$(grep "$actual_file" SHASUMS256.txt | awk '{print $1}') + echo "Found $actual_file with shasum: $expected_sum" + + echo "Updating $actual_file" + mkdir -p "$(dirname "$filepath")" + curl -s "$BASE_URL/$actual_file" -o "$filepath" + else + echo "Warning: $actual_file not found in SHASUMS256.txt" + fi +done + +# Fetch and escape the license text +LICENSE_URL="https://raw.githubusercontent.com/nodejs/node/v${NODE_SEMVER}/LICENSE" +LICENSE_FILE="$ASSETS_DIR/LICENSE" + +echo "Fetching Node.js license from $LICENSE_URL" +curl -s "$LICENSE_URL" -o "$LICENSE_FILE" + +# Verify the license file was downloaded successfully +if [ ! -s "$LICENSE_FILE" ]; then + echo "Downloaded license file is empty" + rm -f "$LICENSE_FILE" + exit 1 +fi + +echo "License file has been updated in $LICENSE_FILE" + +# Update the attribution overrides file +ATTRIBUTION_FILE="../../attribution/overrides.json" + +# Create attribution file with empty JSON object if it doesn't exist +if [ ! -f "$ATTRIBUTION_FILE" ]; then + mkdir -p "$(dirname "$ATTRIBUTION_FILE")" + echo "{}" > "$ATTRIBUTION_FILE" +fi + +# Update version and licenseText fields using jq +# jq also escapes text by default +jq --indent 4 \ + --arg name "Node.js" \ + --arg version "$NODE_SEMVER" \ + --rawfile licenseText "$LICENSE_FILE" \ + --arg url "https://github.com/nodejs/node" \ + --arg license "MIT" \ + '.node.name = $name | .node.version = $version | .node.url = $url | .node.license = $license | .node.licenseText = $licenseText' \ + "$ATTRIBUTION_FILE" > "$ATTRIBUTION_FILE.tmp" + +mv "$ATTRIBUTION_FILE.tmp" "$ATTRIBUTION_FILE" +echo "Successfully updated Node.js version and license in $ATTRIBUTION_FILE" + +# Cleanup +rm -f "$SHASUMS_FILE" diff --git a/app/aws-lsp-codewhisperer-runtimes/scripts/package.sh b/app/aws-lsp-codewhisperer-runtimes/scripts/package.sh new file mode 100755 index 0000000000..5ebfc9aafc --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/scripts/package.sh @@ -0,0 +1,133 @@ +#!/bin/bash + +# This script collects all of the files needed for bundling and packages them +# into clients.zip and one servers.zip file per platform. +# Bundled outputs are placed in +# - build/archives/agent-standalone/(platform)-(architecture) +# - build/archives/shared + +set -euxo pipefail + +configs=("agent-standalone") + +# Move chat client bundle to bundle folder +START_DIR=$(pwd) +CHAT_CLIENT_BUNDLE_DIR=$(pwd)/../../node_modules/@aws/chat-client/build +TARGET_BUILD_DIR=./build/private/bundle/client + +mkdir -p $TARGET_BUILD_DIR +cp -r $CHAT_CLIENT_BUNDLE_DIR/* $TARGET_BUILD_DIR + +# Add benign files to avoid single-file archive flagging +echo "Amazon Q Developer UI Bundle - $(date)" > $TARGET_BUILD_DIR/README.txt +echo "This archive contains UI assets for Amazon Q Developer." >> $TARGET_BUILD_DIR/README.txt +cat > $TARGET_BUILD_DIR/client-metadata.json << EOF +{ + "name": "amazonq-ui-bundle", + "description": "UI assets for Amazon Q Developer", + "main": "amazonq-ui.js", + "dateCreated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} +EOF + +# ZIP client files +ARCHIVES_DIR=./build/archives +mkdir -p $ARCHIVES_DIR/shared +zip -j $ARCHIVES_DIR/shared/clients.zip $TARGET_BUILD_DIR/* + +# Create tempdir for unzipped qcontext files +TEMP_DIR=$(mktemp -d) +trap 'rm -rf -- "$TEMP_DIR"' EXIT + +# Unzip each platform-specific file into its own subdirectory +# Windows x64 +mkdir -p $TEMP_DIR/win-x64 +unzip -o ./_bundle-assets/qserver-win32-x64.zip -d $TEMP_DIR/win-x64 +mv $TEMP_DIR/win-x64/qserver $TEMP_DIR/win-x64/indexing +unzip -o ./_bundle-assets/ripgrep-win32-x64.zip -d $TEMP_DIR/win-x64 + +# Linux x64 +mkdir -p $TEMP_DIR/linux-x64 +unzip -o ./_bundle-assets/qserver-linux-x64.zip -d $TEMP_DIR/linux-x64 +mv $TEMP_DIR/linux-x64/qserver $TEMP_DIR/linux-x64/indexing +unzip -o ./_bundle-assets/ripgrep-linux-x64.zip -d $TEMP_DIR/linux-x64 + +# Mac x64 +mkdir -p $TEMP_DIR/mac-x64 +unzip -o ./_bundle-assets/qserver-darwin-x64.zip -d $TEMP_DIR/mac-x64 +mv $TEMP_DIR/mac-x64/qserver $TEMP_DIR/mac-x64/indexing +unzip -o ./_bundle-assets/ripgrep-darwin-x64.zip -d $TEMP_DIR/mac-x64 + +# Linux ARM64 +mkdir -p $TEMP_DIR/linux-arm64 +unzip -o ./_bundle-assets/qserver-linux-arm64.zip -d $TEMP_DIR/linux-arm64 +mv $TEMP_DIR/linux-arm64/qserver $TEMP_DIR/linux-arm64/indexing +unzip -o ./_bundle-assets/ripgrep-linux-arm64.zip -d $TEMP_DIR/linux-arm64 + +# Mac ARM64 +mkdir -p $TEMP_DIR/mac-arm64 +unzip -o ./_bundle-assets/qserver-darwin-arm64.zip -d $TEMP_DIR/mac-arm64 +mv $TEMP_DIR/mac-arm64/qserver $TEMP_DIR/mac-arm64/indexing +unzip -o ./_bundle-assets/ripgrep-darwin-arm64.zip -d $TEMP_DIR/mac-arm64 + +# ZIP server files +for config in "${configs[@]}"; do + mkdir -p $ARCHIVES_DIR/${config}/linux-x64 + mkdir -p $ARCHIVES_DIR/${config}/mac-x64 + mkdir -p $ARCHIVES_DIR/${config}/linux-arm64 + mkdir -p $ARCHIVES_DIR/${config}/mac-arm64 + mkdir -p $ARCHIVES_DIR/${config}/win-x64 + + # Win x64 + zip -j $ARCHIVES_DIR/${config}/win-x64/servers.zip \ + ./build/private/assets/win-x64/* + if [ "$config" = "agent-standalone" ]; then + (cd $TEMP_DIR/win-x64 && zip -r $OLDPWD/$ARCHIVES_DIR/${config}/win-x64/servers.zip indexing ripgrep/rg.exe) + fi + + # Linux x64 + zip -j $ARCHIVES_DIR/${config}/linux-x64/servers.zip \ + ./build/private/assets/linux-x64/* + if [ "$config" = "agent-standalone" ]; then + (cd $TEMP_DIR/linux-x64 && zip -r $OLDPWD/$ARCHIVES_DIR/${config}/linux-x64/servers.zip indexing ripgrep/rg) + fi + + # Mac x64 + zip -j $ARCHIVES_DIR/${config}/mac-x64/servers.zip \ + ./build/private/assets/mac-x64/* + if [ "$config" = "agent-standalone" ]; then + (cd $TEMP_DIR/mac-x64 && zip -r $OLDPWD/$ARCHIVES_DIR/${config}/mac-x64/servers.zip indexing ripgrep/rg) + fi + + # Linux ARM64 + zip -j $ARCHIVES_DIR/${config}/linux-arm64/servers.zip \ + ./build/private/assets/linux-arm64/* + if [ "$config" = "agent-standalone" ]; then + (cd $TEMP_DIR/linux-arm64 && zip -r $OLDPWD/$ARCHIVES_DIR/${config}/linux-arm64/servers.zip indexing ripgrep/rg) + fi + + # Mac ARM64 + zip -j $ARCHIVES_DIR/${config}/mac-arm64/servers.zip \ + ./build/private/assets/mac-arm64/* + if [ "$config" = "agent-standalone" ]; then + (cd $TEMP_DIR/mac-arm64 && zip -r $OLDPWD/$ARCHIVES_DIR/${config}/mac-arm64/servers.zip indexing ripgrep/rg) + fi +done + +cd ./build/private/bundle +for config in "${configs[@]}"; do + cd ${config} + zip -r ../../../../$ARCHIVES_DIR/${config}/win-x64/servers.zip . + zip -r ../../../../$ARCHIVES_DIR/${config}/linux-x64/servers.zip . + zip -r ../../../../$ARCHIVES_DIR/${config}/mac-x64/servers.zip . + zip -r ../../../../$ARCHIVES_DIR/${config}/linux-arm64/servers.zip . + zip -r ../../../../$ARCHIVES_DIR/${config}/mac-arm64/servers.zip . + + cd .. +done + +cd $START_DIR + +for config in "${configs[@]}"; do + echo "Artifact Bundle Available in: $START_DIR/build/archives/${config}" +done diff --git a/app/aws-lsp-codewhisperer-runtimes/src/agent-standalone.ts b/app/aws-lsp-codewhisperer-runtimes/src/agent-standalone.ts index 9867384c38..49600a91b8 100644 --- a/app/aws-lsp-codewhisperer-runtimes/src/agent-standalone.ts +++ b/app/aws-lsp-codewhisperer-runtimes/src/agent-standalone.ts @@ -1,10 +1,12 @@ import { standalone } from '@aws/language-server-runtimes/runtimes' import { + AmazonQServiceServerIAM, + AmazonQServiceServerToken, CodeWhispererSecurityScanServerTokenProxy, - CodeWhispererServerTokenProxy, - QAgenticChatServerTokenProxy, + CodeWhispererServer, + QAgenticChatServerProxy, QConfigurationServerTokenProxy, - QLocalProjectContextServerTokenProxy, + QLocalProjectContextServerProxy, QNetTransformServerTokenProxy, WorkspaceContextServerTokenProxy, } from '@aws/lsp-codewhisperer' @@ -12,28 +14,34 @@ import { IdentityServer } from '@aws/lsp-identity' import { BashToolsServer, FsToolsServer, + QCodeAnalysisServer, McpToolsServer, } from '@aws/lsp-codewhisperer/out/language-server/agenticChat/tools/toolServer' -import { createTokenRuntimeProps } from './standalone-common' +import { RuntimeProps } from '@aws/language-server-runtimes/runtimes/runtime' -const MAJOR = 0 -const MINOR = 1 -const PATCH = 0 -const VERSION = `${MAJOR}.${MINOR}.${PATCH}` +const versionJson = require('./version.json') +const version = versionJson.agenticChat -const props = createTokenRuntimeProps(VERSION, [ - CodeWhispererServerTokenProxy, - CodeWhispererSecurityScanServerTokenProxy, - QConfigurationServerTokenProxy, - QNetTransformServerTokenProxy, - QAgenticChatServerTokenProxy, - IdentityServer.create, - FsToolsServer, - BashToolsServer, - QLocalProjectContextServerTokenProxy, - WorkspaceContextServerTokenProxy, - McpToolsServer, - // LspToolsServer, -]) +const props = { + version: version, + servers: [ + CodeWhispererServer, + CodeWhispererSecurityScanServerTokenProxy, + QConfigurationServerTokenProxy, + QNetTransformServerTokenProxy, + QAgenticChatServerProxy, + IdentityServer.create, + FsToolsServer, + QCodeAnalysisServer, + BashToolsServer, + QLocalProjectContextServerProxy, + WorkspaceContextServerTokenProxy, + McpToolsServer, + // LspToolsServer, + AmazonQServiceServerIAM, + AmazonQServiceServerToken, + ], + name: 'AWS CodeWhisperer', +} as RuntimeProps standalone(props) diff --git a/app/aws-lsp-codewhisperer-runtimes/src/scripts/copy-node-assets.ts b/app/aws-lsp-codewhisperer-runtimes/src/scripts/copy-node-assets.ts new file mode 100644 index 0000000000..c1ac59c576 --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/src/scripts/copy-node-assets.ts @@ -0,0 +1,84 @@ +import * as fsPromises from 'fs/promises' +import * as path from 'path' +import { exec } from 'child_process' + +// This script takes node distributions downloaded by scripts/download-node.sh +// and places the necessary files in locations for bundling into servers.zip. +// node application files are extracted from tgz files in build/node-assets +// into build/private/assets/(platform)-(architecture)/ + +// Ensure directory exists +async function ensureDirectory(dirPath: string): Promise { + try { + await fsPromises.access(dirPath) + } catch { + await fsPromises.mkdir(dirPath, { recursive: true }) + } +} + +// Copy directory recursively +async function copyDirectory(src: string, dest: string): Promise { + await ensureDirectory(dest) + const entries = await fsPromises.readdir(src, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + + if (entry.isDirectory()) { + await copyDirectory(srcPath, destPath) + } else { + await fsPromises.copyFile(srcPath, destPath) + } + } +} + +async function copyWindowsAssets() { + const sourceDir = 'build/node-assets/win-x64' + const destDir = 'build/private/assets/win-x64' + await ensureDirectory(path.dirname(destDir)) + await copyDirectory(sourceDir, destDir) +} + +async function copyLinuxAndMacAssets() { + const overridesContent = await fsPromises.readFile('../../attribution/overrides.json', 'utf8') + const version = JSON.parse(overridesContent).node.version + const nodeAssetsRoot = 'build/node-assets' + const linuxX64 = `node-v${version}-linux-x64` + const macX64 = `node-v${version}-darwin-x64` + const linuxArm64 = `node-v${version}-linux-arm64` + const macArm64 = `node-v${version}-darwin-arm64` + + await run(`cd ${nodeAssetsRoot} && tar -xzf ${linuxX64}.tar.gz --strip-components=2 ${linuxX64}/bin/node`) + await ensureDirectory('build/private/assets/linux-x64') + await fsPromises.rename(`${nodeAssetsRoot}/node`, 'build/private/assets/linux-x64/node') + + await run(`cd ${nodeAssetsRoot} && tar -xzf ${macX64}.tar.gz --strip-components=2 ${macX64}/bin/node`) + await ensureDirectory('build/private/assets/mac-x64') + await fsPromises.rename(`${nodeAssetsRoot}/node`, 'build/private/assets/mac-x64/node') + + await run(`cd ${nodeAssetsRoot} && tar -xzf ${linuxArm64}.tar.gz --strip-components=2 ${linuxArm64}/bin/node`) + await ensureDirectory('build/private/assets/linux-arm64') + await fsPromises.rename(`${nodeAssetsRoot}/node`, 'build/private/assets/linux-arm64/node') + + await run(`cd ${nodeAssetsRoot} && tar -xzf ${macArm64}.tar.gz --strip-components=2 ${macArm64}/bin/node`) + await ensureDirectory('build/private/assets/mac-arm64') + await fsPromises.rename(`${nodeAssetsRoot}/node`, 'build/private/assets/mac-arm64/node') +} + +function run(command: string): Promise { + return new Promise((resolve, reject) => { + exec(command, (error: any, stdout: string, stderr: string) => { + if (error) { + reject(error) + } else { + resolve(stdout.trim()) + } + }) + }) +} + +;(async () => { + await copyWindowsAssets() + await copyLinuxAndMacAssets() +})() diff --git a/app/aws-lsp-codewhisperer-runtimes/src/token-standalone.ts b/app/aws-lsp-codewhisperer-runtimes/src/token-standalone.ts index 59eb533bd3..266dd06535 100644 --- a/app/aws-lsp-codewhisperer-runtimes/src/token-standalone.ts +++ b/app/aws-lsp-codewhisperer-runtimes/src/token-standalone.ts @@ -5,7 +5,7 @@ import { QChatServerTokenProxy, QConfigurationServerTokenProxy, QNetTransformServerTokenProxy, - QLocalProjectContextServerTokenProxy, + QLocalProjectContextServerProxy, WorkspaceContextServerTokenProxy, } from '@aws/lsp-codewhisperer' import { IdentityServer } from '@aws/lsp-identity' @@ -23,7 +23,7 @@ const props = createTokenRuntimeProps(VERSION, [ QNetTransformServerTokenProxy, QChatServerTokenProxy, IdentityServer.create, - QLocalProjectContextServerTokenProxy, + QLocalProjectContextServerProxy, WorkspaceContextServerTokenProxy, ]) diff --git a/app/aws-lsp-codewhisperer-runtimes/src/version.json b/app/aws-lsp-codewhisperer-runtimes/src/version.json new file mode 100644 index 0000000000..c46863531b --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/src/version.json @@ -0,0 +1,3 @@ +{ + "agenticChat": "1.43.0" +} diff --git a/app/aws-lsp-codewhisperer-runtimes/webpack.config.js b/app/aws-lsp-codewhisperer-runtimes/webpack.config.js index 0611d76a4d..f10f3d0202 100644 --- a/app/aws-lsp-codewhisperer-runtimes/webpack.config.js +++ b/app/aws-lsp-codewhisperer-runtimes/webpack.config.js @@ -80,6 +80,7 @@ const webworkerConfig = { http: 'stream-http', crypto: 'crypto-browserify', stream: 'stream-browserify', + url: require.resolve('url/'), fs: path.resolve(__dirname, 'src/mock-fs.js'), child_process: false, vm: false, @@ -88,6 +89,7 @@ const webworkerConfig = { net: false, tls: false, http2: false, + buffer: require.resolve('buffer/'), }, extensions: ['.ts', '.tsx', '.js', '.jsx'], }, @@ -109,6 +111,9 @@ const webworkerConfig = { new webpack.ProvidePlugin({ process: 'process/browser', }), + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + }), ], } diff --git a/app/aws-lsp-codewhisperer-runtimes/webpack.config.prod.js b/app/aws-lsp-codewhisperer-runtimes/webpack.config.prod.js new file mode 100644 index 0000000000..d3b81acfcd --- /dev/null +++ b/app/aws-lsp-codewhisperer-runtimes/webpack.config.prod.js @@ -0,0 +1,54 @@ +const path = require('node:path') + +// This script is used to produce the distributable webpacked version of the agentic chat server. + +const baseConfig = { + mode: 'production', + resolve: { + extensions: ['.ts', '.tsx', '.js', '.node'], + }, + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + { + test: /\.node$/, + loader: 'node-loader', + options: { + name: '[name].[ext]', // Preserves original path and filename + }, + }, + ], + }, + output: { + path: __dirname, + globalObject: 'this', + library: { + type: 'umd', + }, + }, + target: 'node', + experiments: { + asyncWebAssembly: true, + }, +} + +const serverConfig = config => { + return { + ...baseConfig, + output: { + ...baseConfig.output, + path: path.resolve(__dirname, 'build', 'private', 'bundle', config), + filename: `[name].js`, + chunkFormat: false, + }, + entry: { + 'aws-lsp-codewhisperer': `./src/${config}.ts`, + }, + } +} + +module.exports = [serverConfig('agent-standalone')] diff --git a/app/aws-lsp-identity-runtimes/package.json b/app/aws-lsp-identity-runtimes/package.json index e29a041ea1..74fa7c332b 100644 --- a/app/aws-lsp-identity-runtimes/package.json +++ b/app/aws-lsp-identity-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.96", + "@aws/language-server-runtimes": "^0.3.1", "@aws/lsp-identity": "^0.0.1" } } diff --git a/app/aws-lsp-json-runtimes/package.json b/app/aws-lsp-json-runtimes/package.json index 68a851e40e..4348ae866a 100644 --- a/app/aws-lsp-json-runtimes/package.json +++ b/app/aws-lsp-json-runtimes/package.json @@ -11,7 +11,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.96", + "@aws/language-server-runtimes": "^0.3.1", "@aws/lsp-json": "*" }, "devDependencies": { @@ -22,7 +22,7 @@ "chai-as-promised": "^7.1.1", "mocha": "^11.0.1", "ts-loader": "^9.4.4", - "ts-lsp-client": "^1.0.3", + "ts-lsp-client": "1.0.3", "webpack": "^5.94.0", "webpack-cli": "^6.0.1" } diff --git a/app/aws-lsp-json-runtimes/src/tests/jsonServerCFInteg.test.ts b/app/aws-lsp-json-runtimes/src/tests/jsonServerCFInteg.test.ts index d788db6ea7..3b3de35710 100644 --- a/app/aws-lsp-json-runtimes/src/tests/jsonServerCFInteg.test.ts +++ b/app/aws-lsp-json-runtimes/src/tests/jsonServerCFInteg.test.ts @@ -47,7 +47,7 @@ async function createLSPServer(runtimeFile: string) { return { client, endpoint, process } } -describe('Test JsonServer with CloudFormation schema', () => { +;(describe('Test JsonServer with CloudFormation schema', () => { let client: LspClient let endpoint: JSONRPCEndpoint let process: ChildProcessWithoutNullStreams @@ -200,4 +200,4 @@ describe('Test JsonServer with CloudFormation schema', () => { expect(clientResult).to.deep.equal(HOVER_JSON_CUSTOMIZED) }) - }) + })) diff --git a/app/aws-lsp-notification-runtimes/package.json b/app/aws-lsp-notification-runtimes/package.json index 717b6ea61e..47ea043b34 100644 --- a/app/aws-lsp-notification-runtimes/package.json +++ b/app/aws-lsp-notification-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.96", + "@aws/language-server-runtimes": "^0.3.1", "@aws/lsp-notification": "^0.0.1" } } diff --git a/app/aws-lsp-partiql-runtimes/package.json b/app/aws-lsp-partiql-runtimes/package.json index 3391f57bac..9b8ef07a25 100644 --- a/app/aws-lsp-partiql-runtimes/package.json +++ b/app/aws-lsp-partiql-runtimes/package.json @@ -11,12 +11,12 @@ "package": "npm run compile && npm run compile:webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.89", - "@aws/lsp-partiql": "^0.0.5" + "@aws/language-server-runtimes": "^0.3.1", + "@aws/lsp-partiql": "^0.0.18" }, "devDependencies": { "ts-loader": "^9.4.4", - "ts-lsp-client": "^1.0.3", + "ts-lsp-client": "1.0.3", "webpack": "^5.94.0", "webpack-cli": "^6.0.1" } diff --git a/app/aws-lsp-s3-runtimes/package.json b/app/aws-lsp-s3-runtimes/package.json index 94e6880b7a..6afd693a46 100644 --- a/app/aws-lsp-s3-runtimes/package.json +++ b/app/aws-lsp-s3-runtimes/package.json @@ -10,6 +10,7 @@ "compile": "tsc --build" }, "dependencies": { + "@aws/language-server-runtimes": "^0.3.1", "@aws/lsp-s3": "^0.0.1" } } diff --git a/app/aws-lsp-yaml-json-webworker/package.json b/app/aws-lsp-yaml-json-webworker/package.json index e4b59139c5..262e48b798 100644 --- a/app/aws-lsp-yaml-json-webworker/package.json +++ b/app/aws-lsp-yaml-json-webworker/package.json @@ -11,7 +11,7 @@ "serve:webpack": "NODE_ENV=development webpack serve" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.96", + "@aws/language-server-runtimes": "^0.3.1", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*" }, diff --git a/app/aws-lsp-yaml-runtimes/package.json b/app/aws-lsp-yaml-runtimes/package.json index ba68af8220..6bd43690ef 100644 --- a/app/aws-lsp-yaml-runtimes/package.json +++ b/app/aws-lsp-yaml-runtimes/package.json @@ -11,7 +11,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.96", + "@aws/language-server-runtimes": "^0.3.1", "@aws/lsp-yaml": "*" }, "devDependencies": { @@ -22,7 +22,7 @@ "chai-as-promised": "^7.1.1", "mocha": "^11.0.1", "ts-loader": "^9.4.4", - "ts-lsp-client": "^1.0.3", + "ts-lsp-client": "1.0.3", "umd-compat-loader": "^2.1.2", "webpack": "^5.94.0", "webpack-cli": "^6.0.1" diff --git a/app/aws-lsp-yaml-runtimes/src/tests/yamlServerCFInteg.test.ts b/app/aws-lsp-yaml-runtimes/src/tests/yamlServerCFInteg.test.ts index 49201a2f25..0e3031892a 100644 --- a/app/aws-lsp-yaml-runtimes/src/tests/yamlServerCFInteg.test.ts +++ b/app/aws-lsp-yaml-runtimes/src/tests/yamlServerCFInteg.test.ts @@ -47,7 +47,7 @@ async function createLSPServer(runtimeFile: string) { return { client, endpoint, process } } -describe('Test YamlServer with CloudFormation schema', () => { +;(describe('Test YamlServer with CloudFormation schema', () => { const rootPath = path.resolve(__dirname) let process: ChildProcessWithoutNullStreams let endpoint: JSONRPCEndpoint @@ -203,4 +203,4 @@ describe('Test YamlServer with CloudFormation schema', () => { expect(clientResult).to.deep.equal(HOVER_YAML_CUSTOMIZED) }) - }) + })) diff --git a/app/hello-world-lsp-runtimes/package.json b/app/hello-world-lsp-runtimes/package.json index 64b7033d0a..7b1bf6f9cd 100644 --- a/app/hello-world-lsp-runtimes/package.json +++ b/app/hello-world-lsp-runtimes/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@aws/hello-world-lsp": "^0.0.1", - "@aws/language-server-runtimes": "^0.2.96" + "@aws/language-server-runtimes": "^0.3.1" }, "devDependencies": { "@types/chai": "^4.3.5", @@ -25,7 +25,7 @@ "chai-as-promised": "^7.1.1", "mocha": "^11.0.1", "ts-loader": "^9.4.4", - "ts-lsp-client": "^1.0.3", + "ts-lsp-client": "1.0.3", "webpack": "^5.94.0", "webpack-cli": "^6.0.1" } diff --git a/attribution/overrides.json b/attribution/overrides.json new file mode 100644 index 0000000000..ed989c78d2 --- /dev/null +++ b/attribution/overrides.json @@ -0,0 +1,15 @@ +{ + "file-description": "Used by generate-attribution in script/generate-attribution.sh", + "@aws/language-server-runtimes": { + "ignore": true + }, + "@aws/lsp-codewhisperer": { + "ignore": true + }, + "@aws/lsp-core": { + "ignore": true + }, + "caniuse-lite": { + "ignore": true + } +} diff --git a/binaries/hello-world.zip b/binaries/hello-world.zip new file mode 100644 index 0000000000..2ce7cb3fd5 --- /dev/null +++ b/binaries/hello-world.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ffd47793c855fce289c46ea6ca76f547a58dc9f8eb59e54f7ef5783ed639567 +size 160 diff --git a/chat-client/.c8rc.json b/chat-client/.c8rc.json new file mode 100644 index 0000000000..d70dc0699d --- /dev/null +++ b/chat-client/.c8rc.json @@ -0,0 +1,12 @@ +{ + "all": true, + "check-coverage": false, + "reporter": ["text", "html", "lcov"], + "reports-dir": "coverage", + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/test/**", "src/**/*.d.ts"], + "branches": 80, + "lines": 80, + "functions": 80, + "statements": 80 +} diff --git a/chat-client/CHANGELOG.md b/chat-client/CHANGELOG.md index edcb9d36d2..3a243a84c8 100644 --- a/chat-client/CHANGELOG.md +++ b/chat-client/CHANGELOG.md @@ -1,5 +1,267 @@ # Changelog +## [0.1.41](https://github.com/aws/language-servers/compare/chat-client/v0.1.40...chat-client/v0.1.41) (2025-11-04) + + +### Bug Fixes + +* **amazonq:** mcp tool panel blocks amazon q chat interface when using right-click context menu ([#2442](https://github.com/aws/language-servers/issues/2442)) ([11900ca](https://github.com/aws/language-servers/commit/11900ca371adee2611698427dbec7c9323ef8e01)) + +## [0.1.40](https://github.com/aws/language-servers/compare/chat-client/v0.1.39...chat-client/v0.1.40) (2025-10-21) + + +### Features + +* send pinned context button immediately with pending state ([#2353](https://github.com/aws/language-servers/issues/2353)) ([bee5cad](https://github.com/aws/language-servers/commit/bee5cadeaf8840a8af08acfe8b58442aac7ad567)) + +## [0.1.39](https://github.com/aws/language-servers/compare/chat-client/v0.1.38...chat-client/v0.1.39) (2025-10-09) + + +### Features + +* add model description to dropdown ([#2374](https://github.com/aws/language-servers/issues/2374)) ([ed8c6dd](https://github.com/aws/language-servers/commit/ed8c6dda1312f728e9ee7472f7ca447196ad9d84)) + +## [0.1.38](https://github.com/aws/language-servers/compare/chat-client/v0.1.37...chat-client/v0.1.38) (2025-10-01) + + +### Bug Fixes + +* **amazonq:** Fix mock fs clean; Node version upgrade ([#2324](https://github.com/aws/language-servers/issues/2324)) ([1d9afd4](https://github.com/aws/language-servers/commit/1d9afd410e19624223e300ca06ea7d08a112cc82)) +* optimize memory bank token usage and add new tab support ([#2366](https://github.com/aws/language-servers/issues/2366)) ([3057d56](https://github.com/aws/language-servers/commit/3057d56e4a3047d1715d6e3560e9f934d1de469c)) + +## [0.1.37](https://github.com/aws/language-servers/compare/chat-client/v0.1.36...chat-client/v0.1.37) (2025-09-24) + + +### Features + +* memory bank support ([#2314](https://github.com/aws/language-servers/issues/2314)) ([0e215fc](https://github.com/aws/language-servers/commit/0e215fc0e475b4c40a8237492371716982d4d532)) + +## [0.1.36](https://github.com/aws/language-servers/compare/chat-client/v0.1.35...chat-client/v0.1.36) (2025-09-16) + + +### Bug Fixes + +* migration from /agents ux ([#2248](https://github.com/aws/language-servers/issues/2248)) ([debeb41](https://github.com/aws/language-servers/commit/debeb414fd0d4d873af2f36cde0ebbeab16d16a4)) + +## [0.1.35](https://github.com/aws/language-servers/compare/chat-client/v0.1.34...chat-client/v0.1.35) (2025-09-09) + + +### Features + +* add support for getSupplementalContext LSP API ([#2212](https://github.com/aws/language-servers/issues/2212)) ([2ddcae7](https://github.com/aws/language-servers/commit/2ddcae7a4fac6b89cbc9784911959743ea0a6d11)) +* **amazonq:** default to diff-based scans ([#2195](https://github.com/aws/language-servers/issues/2195)) ([da4c3db](https://github.com/aws/language-servers/commit/da4c3db5329bd50cfe249bf8c1d59afa9bcb0157)) + +## [0.1.34](https://github.com/aws/language-servers/compare/chat-client/v0.1.33...chat-client/v0.1.34) (2025-08-27) + + +### Features + +* Auto fetch models from listAvailableModels API ([#2171](https://github.com/aws/language-servers/issues/2171)) ([8600c52](https://github.com/aws/language-servers/commit/8600c524877abb459e9338399352446c0dcff6f0)) + + +### Bug Fixes + +* **amazonq:** disable typewriter animation ([#2160](https://github.com/aws/language-servers/issues/2160)) ([db45d01](https://github.com/aws/language-servers/commit/db45d01adba10e8a04d868e1062f899df4f5b7e4)) + +## [0.1.33](https://github.com/aws/language-servers/compare/chat-client/v0.1.32...chat-client/v0.1.33) (2025-08-19) + + +### Features + +* **amazonq:** added mcp admin level configuration with GetProfile ([#2000](https://github.com/aws/language-servers/issues/2000)) ([fd6e9a8](https://github.com/aws/language-servers/commit/fd6e9a829c6229c276de5340dffce52b426a864d)) +* **amazonq:** read tool ui revamp ([#2113](https://github.com/aws/language-servers/issues/2113)) ([#2121](https://github.com/aws/language-servers/issues/2121)) ([93cf229](https://github.com/aws/language-servers/commit/93cf229149ba60491f9f5763793db4a9f570b611)) + + +### Bug Fixes + +* fix for button text and remove profilearn caching ([#2137](https://github.com/aws/language-servers/issues/2137)) ([2a4171a](https://github.com/aws/language-servers/commit/2a4171a74c15c23c23c481060496162bcc9e6284)) +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + +## [0.1.32](https://github.com/aws/language-servers/compare/chat-client/v0.1.31...chat-client/v0.1.32) (2025-08-11) + + +### Features + +* **amazonq:** read tool ui revamp ([c65428b](https://github.com/aws/language-servers/commit/c65428bab2cf5e47badf1e3a9453babcf881e60c)) + +## [0.1.31](https://github.com/aws/language-servers/compare/chat-client/v0.1.30...chat-client/v0.1.31) (2025-08-06) + + +### Features + +* **amazonq:** add two more tips for the did you know section ([#2063](https://github.com/aws/language-servers/issues/2063)) ([9949c6d](https://github.com/aws/language-servers/commit/9949c6dd81c56b5ea82563310da2eaee4d00a059)) +* **amazonq:** enable sonnet 4 for fra region ([#2069](https://github.com/aws/language-servers/issues/2069)) ([3a4b8df](https://github.com/aws/language-servers/commit/3a4b8df981b2c3b0532360a11472169fffec7924)) + + +### Bug Fixes + +* **amazonq:** fix to add disable/enable feature back to mcp servers ([#2052](https://github.com/aws/language-servers/issues/2052)) ([c03e017](https://github.com/aws/language-servers/commit/c03e017b9ccbbbb9c80a3c3afd5da38a50bd6cff)) + +## [0.1.30](https://github.com/aws/language-servers/compare/chat-client/v0.1.29...chat-client/v0.1.30) (2025-08-04) + + +### Features + +* support http transport without authorization for MCP ([97e806c](https://github.com/aws/language-servers/commit/97e806ce7ea5e5be1fd60c4a4d9a54cf76c8f8cb)) + + +### Bug Fixes + +* **amazonq:** fix the issue that invalid image notification always show ([#2007](https://github.com/aws/language-servers/issues/2007)) ([ceed762](https://github.com/aws/language-servers/commit/ceed762ace5f94cb0e0a7978eb6c4894bc11ce42)) +* **amazonq:** improve cross theme support ([#2036](https://github.com/aws/language-servers/issues/2036)) ([002a255](https://github.com/aws/language-servers/commit/002a255c28ea07ca6623dbd074101cbc6082ceb8)) +* **amazonq:** improve welcome screen and enable tips ([#2035](https://github.com/aws/language-servers/issues/2035)) ([ac00b94](https://github.com/aws/language-servers/commit/ac00b94df45c2bba0666423c937757fad4db95cc)) +* **amazonq:** refactor the welcome screen to make it look better ([#2027](https://github.com/aws/language-servers/issues/2027)) ([1f7c608](https://github.com/aws/language-servers/commit/1f7c608ba2f89c8b0675e62451e27d2dc547613c)) +* enable test flag for amazon q ui testing ([#2046](https://github.com/aws/language-servers/issues/2046)) ([f18ea96](https://github.com/aws/language-servers/commit/f18ea96c1e5cd9b93974a047d7f2bb1aba0d9436)) +* use new language server runtime ([#2023](https://github.com/aws/language-servers/issues/2023)) ([83ea1e4](https://github.com/aws/language-servers/commit/83ea1e42fe52990696eb9b878fa11e2c5331bec5)) + +## [0.1.29](https://github.com/aws/language-servers/compare/chat-client/v0.1.28...chat-client/v0.1.29) (2025-07-29) + + +### Features + +* **amazonq:** redirect /review, rename CodeReview tool, emit metrics, modify prompts ([#1964](https://github.com/aws/language-servers/issues/1964)) ([ad8e2db](https://github.com/aws/language-servers/commit/ad8e2db77e34f369fef9af71cdda2d3522f555c6)) +* **amazonq:** revert auto-approve ([#2002](https://github.com/aws/language-servers/issues/2002)) ([c8181f7](https://github.com/aws/language-servers/commit/c8181f7a1de224dfcc7a77cd0bfc905fa1018372)) + +## [0.1.28](https://github.com/aws/language-servers/compare/chat-client/v0.1.27...chat-client/v0.1.28) (2025-07-23) + + +### Bug Fixes + +* **amazonq:** revert commit f17b631d9e06371a11ef8e9cb1413762fb51a143 ([#1965](https://github.com/aws/language-servers/issues/1965)) ([8c2cab6](https://github.com/aws/language-servers/commit/8c2cab6995922c96030b5bbdf3cbbdef7eadd7c2)) + +## [0.1.27](https://github.com/aws/language-servers/compare/chat-client/v0.1.26...chat-client/v0.1.27) (2025-07-22) + + +### Features + +* **amazonq:** enable show logs feature ([#1947](https://github.com/aws/language-servers/issues/1947)) ([86ea83d](https://github.com/aws/language-servers/commit/86ea83dd53b447f6decccf16559b76f4989ea712)) + +## [0.1.26](https://github.com/aws/language-servers/compare/chat-client/v0.1.25...chat-client/v0.1.26) (2025-07-22) + + +### Features + +* **chat-client:** add auto-approve (trust mode) for built-in tools ([#1949](https://github.com/aws/language-servers/issues/1949)) ([f17b631](https://github.com/aws/language-servers/commit/f17b631d9e06371a11ef8e9cb1413762fb51a143)) +* **chat-client:** add shortcut for stop/reject/run commands ([#1932](https://github.com/aws/language-servers/issues/1932)) ([087f338](https://github.com/aws/language-servers/commit/087f3387ba736e92d014274e195f7ef76e377f1e)) + + +### Bug Fixes + +* **amazonq:** fix for mcp server unnecessary refresh from file watchers ([#1933](https://github.com/aws/language-servers/issues/1933)) ([208909b](https://github.com/aws/language-servers/commit/208909b55ecda40ff8d412b2b3be890eccee70bc)) +* **amazonq:** update mcp and persona config to agent config ([#1897](https://github.com/aws/language-servers/issues/1897)) ([530977f](https://github.com/aws/language-servers/commit/530977f8c73c7946a0205f02014248d71b4b1fe0)) +* replace cancel with stop ([#1935](https://github.com/aws/language-servers/issues/1935)) ([2f51923](https://github.com/aws/language-servers/commit/2f51923f9d3497469c70162235482b569e2d796e)) + +## [0.1.25](https://github.com/aws/language-servers/compare/chat-client/v0.1.24...chat-client/v0.1.25) (2025-07-17) + + +### Features + +* add conversation compaction ([#1895](https://github.com/aws/language-servers/issues/1895)) ([8bb7144](https://github.com/aws/language-servers/commit/8bb7144e45cfce6cc9337fd49de7edbee61105b8)) + + +### Bug Fixes + +* **amazonq:** change to use promptStickyCard to show image verification notification ([#1904](https://github.com/aws/language-servers/issues/1904)) ([caaefef](https://github.com/aws/language-servers/commit/caaefef2c9b2af66840ec2f7ccabe9bf937c2983)) +* remove disclaimer message ([#1884](https://github.com/aws/language-servers/issues/1884)) ([0845eed](https://github.com/aws/language-servers/commit/0845eeda8d73ed1df3b8801e79dad1ddd7016781)) +* replace thinking with working and replace stop with cancel ([#1922](https://github.com/aws/language-servers/issues/1922)) ([371e731](https://github.com/aws/language-servers/commit/371e731545f7572d3356d061cd8b94db35e4c333)) +* use document change events for auto trigger classifier input ([#1912](https://github.com/aws/language-servers/issues/1912)) ([2204da6](https://github.com/aws/language-servers/commit/2204da6193f2030ee546f61c969b1a664d8025e3)) + +## [0.1.24](https://github.com/aws/language-servers/compare/chat-client/v0.1.23...chat-client/v0.1.24) (2025-07-15) + + +### Features + +* **chat-client:** add built-in tool permission and enable auto-approve ([#1890](https://github.com/aws/language-servers/issues/1890)) ([03b59c8](https://github.com/aws/language-servers/commit/03b59c8fba58db0f6b6c387cf5d53227c3f54673)) +* **chat-client:** handle keyboard shortcut for run/reject/stop shell commands and tooltips ([#1885](https://github.com/aws/language-servers/issues/1885)) ([f8e9461](https://github.com/aws/language-servers/commit/f8e94615b5ce8a3f4bf8837627fa4816a09cbef2)) + + +### Bug Fixes + +* **chat-client:** revert for add built-in tool permission and enable auto-approve ([#1890](https://github.com/aws/language-servers/issues/1890)) ([#1900](https://github.com/aws/language-servers/issues/1900)) ([34b41b8](https://github.com/aws/language-servers/commit/34b41b8f87c21d2ee6b98643339dbdfa71efcb77)) +* **chat-client:** revert for amazon q keyboard shortcuts feature ([#1901](https://github.com/aws/language-servers/issues/1901)) ([522f8de](https://github.com/aws/language-servers/commit/522f8de6dba8dfa9b4363934cd7fcea905add1ce)) +* validate Create Prompt & Create Rule prompts input onChange ([#1854](https://github.com/aws/language-servers/issues/1854)) ([ee215c4](https://github.com/aws/language-servers/commit/ee215c4bc652a54556d31e64f86ed5179d174b4b)) + +## [0.1.23](https://github.com/aws/language-servers/compare/chat-client/v0.1.22...chat-client/v0.1.23) (2025-07-08) + + +### Features + +* **chat-client:** add stringOverrides to createChat config ([#1847](https://github.com/aws/language-servers/issues/1847)) ([89f85ff](https://github.com/aws/language-servers/commit/89f85ff6c676eb30d2cb6bc3368676b0d0913bac)) +* support listAvailableModels server request ([#1808](https://github.com/aws/language-servers/issues/1808)) ([9f1ddb3](https://github.com/aws/language-servers/commit/9f1ddb327778dba6da49337b79c5fef19023b52d)) + + +### Bug Fixes + +* **amazonq:** allow taking .jpg file as image context, add image cont… ([#1814](https://github.com/aws/language-servers/issues/1814)) ([4d36fa4](https://github.com/aws/language-servers/commit/4d36fa4a0a04692dba720bc0288c6cee7f45a1fc)) +* **amazonq:** use config to render the overlay ([#1851](https://github.com/aws/language-servers/issues/1851)) ([f5c2038](https://github.com/aws/language-servers/commit/f5c2038c090f9bb66b3cbd7e31f4d26c37943aeb)) +* image context drag and drop fix on windows ([#1837](https://github.com/aws/language-servers/issues/1837)) ([14df236](https://github.com/aws/language-servers/commit/14df23633138d9b84875fba79a3eaf2d18dca8ce)) +* imagecontext image name bug, mutliple images in pinned context ([#1834](https://github.com/aws/language-servers/issues/1834)) ([27d60ab](https://github.com/aws/language-servers/commit/27d60ab5f5249635a9e73be1ee96ecb820133f9a)) + +## [0.1.22](https://github.com/aws/language-servers/compare/chat-client/v0.1.21...chat-client/v0.1.22) (2025-07-02) + + +### Features + +* **amazonq:** migrating / agents to q agentic chat ([#1799](https://github.com/aws/language-servers/issues/1799)) ([559b2ba](https://github.com/aws/language-servers/commit/559b2baec7da7b8fffb697990e3b249ffffcb85c)) +* **amazonq:** read and validate the images as context ([#1716](https://github.com/aws/language-servers/issues/1716)) ([7a5d41f](https://github.com/aws/language-servers/commit/7a5d41f3cff7309d04d952fbb5dc063fb8658a06)) + + +### Bug Fixes + +* **amazonq:** add slight delay to print chat string after card ([#1800](https://github.com/aws/language-servers/issues/1800)) ([c7d08ab](https://github.com/aws/language-servers/commit/c7d08abd7cac95b5aad83fe93157a815522338ac)) + +## [0.1.21](https://github.com/aws/language-servers/compare/chat-client/v0.1.20...chat-client/v0.1.21) (2025-06-30) + + +### Bug Fixes + +* **amazonq:** change the icon for error and reduce the count ([#1789](https://github.com/aws/language-servers/issues/1789)) ([758d31c](https://github.com/aws/language-servers/commit/758d31c186b163712312fdffb04ee692cfe11de8)) +* **amazonq:** change the icon for error and reduce the count ([#1789](https://github.com/aws/language-servers/issues/1789)) ([758d31c](https://github.com/aws/language-servers/commit/758d31c186b163712312fdffb04ee692cfe11de8)) + +## [0.1.20](https://github.com/aws/language-servers/compare/chat-client/v0.1.19...chat-client/v0.1.20) (2025-06-26) + + +### Features + +* add client side ide diagnostics to telemetry event ([#1768](https://github.com/aws/language-servers/issues/1768)) ([d08fc6c](https://github.com/aws/language-servers/commit/d08fc6cccb9238cef9c2ba485e116c0516839537)) +* Implement dynamic model selection based on extension capabilities and improve error handling ([#1737](https://github.com/aws/language-servers/issues/1737)) ([97db5d8](https://github.com/aws/language-servers/commit/97db5d8dd0a2c8214d37429375ec57aa68a462ee)) + + +### Bug Fixes + +* Add persistent pair programming mode setting with database storage and UI synchronization([#1757](https://github.com/aws/language-servers/issues/1757)) ([ba683cc](https://github.com/aws/language-servers/commit/ba683cc6dc120863350025a4a082ecf3a95b5905)) +* **amazonq:** fix the order of publishing the chat stop ack message ([#1761](https://github.com/aws/language-servers/issues/1761)) ([20c2263](https://github.com/aws/language-servers/commit/20c22638a34d557fc755e33aed798abc1ce3a6d9)) +* **amazonq:** updated stopping message to a better string for new chat ([#1765](https://github.com/aws/language-servers/issues/1765)) ([814bff8](https://github.com/aws/language-servers/commit/814bff848b970ec0192e36b8764c9cb08508f6ce)) + +## [0.1.19](https://github.com/aws/language-servers/compare/chat-client/v0.1.18...chat-client/v0.1.19) (2025-06-23) + + +### Bug Fixes + +* change model unavailable message ([#1711](https://github.com/aws/language-servers/issues/1711)) ([d4e1298](https://github.com/aws/language-servers/commit/d4e1298a5e00b2c3466ba1378aaaa28b89d75fb9)) +* intermediate file card does not have border ([#1734](https://github.com/aws/language-servers/issues/1734)) ([24e0497](https://github.com/aws/language-servers/commit/24e049705ce4ab982700839d012afb35786d8e4f)) + +## [0.1.18](https://github.com/aws/language-servers/compare/chat-client/v0.1.17...chat-client/v0.1.18) (2025-06-17) + + +### Features + +* support per region model selection ([#1683](https://github.com/aws/language-servers/issues/1683)) ([0b81b37](https://github.com/aws/language-servers/commit/0b81b37c15a8c407ec04904abb4bdccf829aa1c1)) + +## [0.1.17](https://github.com/aws/language-servers/compare/chat-client/v0.1.16...chat-client/v0.1.17) (2025-06-16) + + +### Features + +* **amazonq:** model throttling message as card instead of chat message ([#1657](https://github.com/aws/language-servers/issues/1657)) ([7ee1f2a](https://github.com/aws/language-servers/commit/7ee1f2ac0bdaa9f73fb63fc6d20d0de6d7b07523)) +* **amazonq:** pinned context and rules ([#1663](https://github.com/aws/language-servers/issues/1663)) ([25e7a5a](https://github.com/aws/language-servers/commit/25e7a5ab8b6630525a4fd6acc0524f67f00af817)) +* update list of models and set default to 4 ([#1659](https://github.com/aws/language-servers/issues/1659)) ([1991658](https://github.com/aws/language-servers/commit/19916584d3f46049d30f0c23b41c3857a07bc622)) + + +### Bug Fixes + +* **agenticChat:** UX fixes for MCP ([#1661](https://github.com/aws/language-servers/issues/1661)) ([bbdb4b4](https://github.com/aws/language-servers/commit/bbdb4b451352af50a914df684d7654686142a13b)) + ## [0.1.16](https://github.com/aws/language-servers/compare/chat-client/v0.1.15...chat-client/v0.1.16) (2025-06-12) @@ -248,8 +510,8 @@ ### Changed -- Update `@aws/chat-client-ui-types` to 0.1.35 -- Update `@aws/language-server-runtimes-types` to 0.1.29 +- Update `@aws/chat-client-ui-types` to 0.1.63 +- Update `@aws/language-server-runtimes-types` to 0.1.57 - Shortened legal text in the footer ## [0.0.9] - 2024-11-20 @@ -273,8 +535,8 @@ ### Changed - Changed legal text in the footer -- Update `@aws/chat-client-ui-types` to 0.1.35 -- Update `@aws/language-server-runtimes-types` to 0.1.29 +- Update `@aws/chat-client-ui-types` to 0.1.63 +- Update `@aws/language-server-runtimes-types` to 0.1.57 - Upgraded dependency: `@aws/mynah-ui` from 4.15.11 to 4.18.0: - Inline code elements now wrap onto new lines - Send button no longer shifts out of the window when horizontally filling the prompt input without spaces (now it wraps) diff --git a/chat-client/README.md b/chat-client/README.md index 5128a97674..1b18a381ed 100644 --- a/chat-client/README.md +++ b/chat-client/README.md @@ -36,6 +36,10 @@ interface SomeEvent { | conversationClick response | Provides response to conversation click or action, specifying action execution result | `aws/chat/conversationClick` | [ConversationClickResult](https://github.com/aws/language-server-runtimes/blob/112feba70219a98a12f13727d67c540205fa9c9f/types/chat.ts#L512) | | getSerializedChat request | Request to get serialized chat | `aws/chat/getSerializedChat` | [GetSerializedChatParams](https://github.com/aws/language-server-runtimes/blob/112feba70219a98a12f13727d67c540205fa9c9f/types/chat.ts#L550) | | chatOptionsUpdate | Sends chat options update request from server | `aws/chat/chatOptionsUpdate` | [ChatOptionsUpdateParams](https://github.com/aws/language-server-runtimes/blob/112feba70219a98a12f13727d67c540205fa9c9f/types/chat.ts#L365) | +| listRules response | Provides response with list of workspace rules to the UI | `aws/chat/listRules` | [ListRulesResult](https://github.com/aws/language-server-runtimes/blob/0d9d55751bd977e82ded0906d31dbfd8bf027893/types/chat.ts#540) | +| ruleClicked response | Provides response to rule click or action, specifying action execution result | `aws/chat/ruleClick` | [RuleClickResult](https://github.com/aws/language-server-runtimes/blob/0d9d55751bd977e82ded0906d31dbfd8bf027893/types/chat.ts#572) | +| addSelectedFilesToContext | Request to add selected files to context | `aws/chat/openFileDialog` | [OpenFileDialogResult](https://github.com/aws/language-server-runtimes/blob/0d9d55751bd977e82ded0906d31dbfd8bf027893/types/chat.ts#450) | +| sendPinnedContext | Sends pinned context information to the UI | `aws/chat/sendPinnedContext` | [PinnedContextParams](https://github.com/aws/language-server-runtimes/blob/0d9d55751bd977e82ded0906d31dbfd8bf027893/types/chat.ts#L433) | ### Outbound events @@ -64,6 +68,16 @@ interface SomeEvent { | conversationClick | Notifies when a conversation is clicked | `aws/chat/conversationClick` | [ConversationClickParams](https://github.com/aws/language-server-runtimes/blob/112feba70219a98a12f13727d67c540205fa9c9f/types/chat.ts#L507) | | tabBarAction | Notifies when a tab bar action is requested | `aws/chat/tabBarAction` | [TabBarActionParams](https://github.com/aws/language-server-runtimes/blob/112feba70219a98a12f13727d67c540205fa9c9f/types/chat.ts#L541) | | getSerializedChat response | Provides response to getSerializedChat request | `aws/chat/getSerializedChat` | [GetSerializedChatResult](https://github.com/aws/language-server-runtimes/blob/112feba70219a98a12f13727d67c540205fa9c9f/types/chat.ts#L554) | +| stopChatResponse | Requests to stop current chat response | `stopChatResponse` | [StopChatResponseParams](https://github.com/aws/language-server-runtimes/blob/8c9cac765137ca9f3ab08d6a79e6edac768f2c04/chat-client-ui-types/src/uiContracts.ts#L123) | +| sendButtonClickEvent | Sends button click event | `aws/chat/buttonClick` | [ButtonClickParams](https://github.com/aws/language-server-runtimes/blob/112feba70219a98a12f13727d67c540205fa9c9f/types/chat.ts#L339) | +| onOpenSettings | Requests to open settings | `openSettings` | [OpenSettingsParams](https://github.com/aws/language-server-runtimes/blob/8c9cac765137ca9f3ab08d6a79e6edac768f2c04/chat-client-ui-types/src/uiContracts.ts#L165) | +| onRuleClick | Notifies when a rule is clicked | `aws/chat/ruleClick` | [RuleClickParams](https://github.com/aws/language-server-runtimes/blob/0d9d55751bd977e82ded0906d31dbfd8bf027893/types/chat.ts#L566) | +| listRules | Requests to list workspace rules | `aws/chat/listRules` | [ListRulesParams](https://github.com/aws/language-server-runtimes/blob/0d9d55751bd977e82ded0906d31dbfd8bf027893/types/chat.ts#536) | +| onAddPinnedContext | Requests to add pinned context | `aws/chat/addPinnedContext` | [PinnedContextParams](https://github.com/aws/language-server-runtimes/blob/0d9d55751bd977e82ded0906d31dbfd8bf027893/types/chat.ts#433) | +| onRemovePinnedContext | Requests to remove pinned context | `aws/chat/removePinnedContext` | [PinnedContextParams](https://github.com/aws/language-server-runtimes/blob/0d9d55751bd977e82ded0906d31dbfd8bf027893/types/chat.ts#433) | +| onOpenFileDialogClick | Requests to open file dialog | `openFileDialog` | [OpenFileDialogParams](https://github.com/aws/language-server-runtimes/blob/0d9d55751bd977e82ded0906d31dbfd8bf027893/types/chat.ts#444) | +| onFilesDropped | Notifies when files are dropped | `filesDropped` | [FilesDroppedParams](https://github.com/aws/language-server-runtimes/blob/8c9cac765137ca9f3ab08d6a79e6edac768f2c04/chat-client-ui-types/src/uiContracts.ts#L169) | +| promptInputOptionChange | Notifies when prompt input options change | `aws/chat/promptInputOptionChange` | [PromptInputOptionChangeParams](https://github.com/aws/language-server-runtimes/blob/112feba70219a98a12f13727d67c540205fa9c9f/types/chat.ts#L558) | ### Configuration diff --git a/chat-client/package.json b/chat-client/package.json index 23b18d6897..3a87cd8755 100644 --- a/chat-client/package.json +++ b/chat-client/package.json @@ -1,6 +1,6 @@ { "name": "@aws/chat-client", - "version": "0.1.16", + "version": "0.1.41", "description": "AWS Chat Client", "main": "out/index.js", "repository": { @@ -16,19 +16,24 @@ "scripts": { "compile": "tsc --build && npm run package", "test:unit": "ts-mocha -b \"./src/**/*.test.ts\"", + "test:unit:coverage": "c8 ts-mocha -b \"./src/**/*.test.ts\"", "test": "npm run test:unit", + "test:coverage": "npm run test:unit:coverage", + "coverage:report": "c8 report --reporter=html --reporter=text", "fix:prettier": "prettier . --write", "package": "webpack" }, "dependencies": { - "@aws/chat-client-ui-types": "^0.1.40", - "@aws/language-server-runtimes-types": "^0.1.34", - "@aws/mynah-ui": "^4.35.3" + "@aws/chat-client-ui-types": "^0.1.63", + "@aws/language-server-runtimes": "^0.3.1", + "@aws/language-server-runtimes-types": "^0.1.57", + "@aws/mynah-ui": "^4.36.8" }, "devDependencies": { "@types/jsdom": "^21.1.6", "@types/mocha": "^10.0.9", "assert": "^2.0.0", + "c8": "^10.1.2", "jsdom": "^24.0.0", "sinon": "^19.0.2", "ts-mocha": "^11.1.0", diff --git a/chat-client/src/client/chat.test.ts b/chat-client/src/client/chat.test.ts index a475104f59..b64cd7e55d 100644 --- a/chat-client/src/client/chat.test.ts +++ b/chat-client/src/client/chat.test.ts @@ -72,20 +72,20 @@ describe('Chat', () => { }) it('publishes ready event when initialized', () => { - assert.callCount(clientApi.postMessage, 4) + assert.callCount(clientApi.postMessage, 5) - assert.calledWithExactly(clientApi.postMessage.firstCall, { + assert.calledWithExactly(clientApi.postMessage.getCall(0), { command: TELEMETRY, params: { name: 'enterFocus' }, }) - assert.calledWithExactly(clientApi.postMessage.secondCall, { command: READY_NOTIFICATION_METHOD }) + assert.calledWithExactly(clientApi.postMessage.getCall(1), { command: READY_NOTIFICATION_METHOD }) - assert.calledWithExactly(clientApi.postMessage.thirdCall, { + assert.calledWithExactly(clientApi.postMessage.getCall(2), { command: TAB_ADD_NOTIFICATION_METHOD, - params: { tabId: initialTabId }, + params: { tabId: initialTabId, restoredTab: undefined }, }) - assert.calledWithExactly(clientApi.postMessage.lastCall, { + assert.calledWithExactly(clientApi.postMessage.getCall(3), { command: TELEMETRY, params: { triggerType: 'click', @@ -93,6 +93,11 @@ describe('Chat', () => { tabId: initialTabId, }, }) + + assert.calledWithMatch(clientApi.postMessage.getCall(4), { + command: 'aws/chat/listAvailableModels', + params: { tabId: initialTabId }, + }) }) it('publishes telemetry event, when send to prompt is triggered', () => { diff --git a/chat-client/src/client/chat.ts b/chat-client/src/client/chat.ts index 20b7ddda15..58519d96ef 100644 --- a/chat-client/src/client/chat.ts +++ b/chat-client/src/client/chat.ts @@ -32,6 +32,8 @@ import { CHAT_PROMPT_OPTION_ACKNOWLEDGED, STOP_CHAT_RESPONSE, OPEN_SETTINGS, + OPEN_FILE_DIALOG, + FILES_DROPPED, } from '@aws/chat-client-ui-types' import { BUTTON_CLICK_REQUEST_METHOD, @@ -62,10 +64,13 @@ import { InfoLinkClickParams, LINK_CLICK_NOTIFICATION_METHOD, LIST_CONVERSATIONS_REQUEST_METHOD, + LIST_RULES_REQUEST_METHOD, LIST_MCP_SERVERS_REQUEST_METHOD, LinkClickParams, ListConversationsParams, ListConversationsResult, + ListRulesParams, + ListRulesResult, ListMcpServersParams, ListMcpServersResult, MCP_SERVER_CLICK_REQUEST_METHOD, @@ -74,11 +79,18 @@ import { OPEN_TAB_REQUEST_METHOD, OpenTabParams, OpenTabResult, + PINNED_CONTEXT_ADD_NOTIFICATION_METHOD, + PINNED_CONTEXT_NOTIFICATION_METHOD, + PINNED_CONTEXT_REMOVE_NOTIFICATION_METHOD, PROMPT_INPUT_OPTION_CHANGE_METHOD, + PinnedContextParams, PromptInputOptionChangeParams, QUICK_ACTION_REQUEST_METHOD, QuickActionParams, READY_NOTIFICATION_METHOD, + RULE_CLICK_REQUEST_METHOD, + RuleClickParams, + RuleClickResult, SOURCE_LINK_CLICK_NOTIFICATION_METHOD, SourceLinkClickParams, TAB_ADD_NOTIFICATION_METHOD, @@ -89,8 +101,15 @@ import { TabBarActionParams, TabChangeParams, TabRemoveParams, + ListAvailableModelsParams, + LIST_AVAILABLE_MODELS_REQUEST_METHOD, + ListAvailableModelsResult, + OpenFileDialogParams, + OPEN_FILE_DIALOG_METHOD, + OpenFileDialogResult, + EXECUTE_SHELL_COMMAND_SHORTCUT_METHOD, } from '@aws/language-server-runtimes-types' -import { MynahUIDataModel, MynahUITabStoreModel } from '@aws/mynah-ui' +import { ConfigTexts, MynahUIDataModel, MynahUITabStoreModel } from '@aws/mynah-ui' import { ServerMessage, TELEMETRY, TelemetryParams } from '../contracts/serverContracts' import { Messager, OutboundChatApi } from './messager' import { InboundChatApi, createMynahUi } from './mynahUi' @@ -101,8 +120,6 @@ import { toMynahContextCommand, toMynahIcon } from './utils' const getDefaultTabConfig = (agenticMode?: boolean) => { return { tabTitle: 'Chat', - promptInputInfo: - 'Amazon Q Developer uses generative AI. You may need to verify responses. See the [AWS Responsible AI Policy](https://aws.amazon.com/machine-learning/responsible-ai/policy/).', promptInputPlaceholder: `Ask a question. Use${agenticMode ? ' @ to add context,' : ''} / for quick actions`, } } @@ -112,6 +129,8 @@ type ChatClientConfig = Pick & { pairProgrammingAcknowledged?: boolean agenticMode?: boolean modelSelectionEnabled?: boolean + stringOverrides?: Partial + os?: string } export const createChat = ( @@ -165,6 +184,9 @@ export const createChat = ( } switch (message?.command) { + case EXECUTE_SHELL_COMMAND_SHORTCUT_METHOD: + mynahApi.executeShellCommandShortCut(message.params) + break case CHAT_REQUEST_METHOD: mynahApi.addChatResponse(message.params, message.tabId, message.isPartialResult) break @@ -193,9 +215,18 @@ export const createChat = ( case CONTEXT_COMMAND_NOTIFICATION_METHOD: mynahApi.sendContextCommands(message.params as ContextCommandParams) break + case PINNED_CONTEXT_NOTIFICATION_METHOD: + mynahApi.sendPinnedContext(message.params as PinnedContextParams) + break case LIST_CONVERSATIONS_REQUEST_METHOD: mynahApi.listConversations(message.params as ListConversationsResult) break + case LIST_RULES_REQUEST_METHOD: + mynahApi.listRules(message.params as ListRulesResult) + break + case RULE_CLICK_REQUEST_METHOD: + mynahApi.ruleClicked(message.params as RuleClickResult) + break case CONVERSATION_CLICK_REQUEST_METHOD: mynahApi.conversationClicked(message.params as ConversationClickResult) break @@ -205,17 +236,32 @@ export const createChat = ( case MCP_SERVER_CLICK_REQUEST_METHOD: mynahApi.mcpServerClick(message.params as McpServerClickResult) break + case OPEN_FILE_DIALOG_METHOD: + mynahApi.addSelectedFilesToContext(message.params as OpenFileDialogResult) + break case GET_SERIALIZED_CHAT_REQUEST_METHOD: mynahApi.getSerializedChat(message.requestId, message.params as GetSerializedChatParams) break + case LIST_AVAILABLE_MODELS_REQUEST_METHOD: + mynahApi.listAvailableModels(message.params as ListAvailableModelsResult) + break case CHAT_OPTIONS_UPDATE_NOTIFICATION_METHOD: - if (message.params.modelId !== undefined) { + if (message.params.modelId !== undefined || message.params.pairProgrammingMode !== undefined) { const tabId = message.params.tabId const options = mynahUi.getTabData(tabId).getStore()?.promptInputOptions mynahUi.updateStore(tabId, { - promptInputOptions: options?.map(option => - option.id === 'model-selection' ? { ...option, value: message.params.modelId } : option - ), + promptInputOptions: options?.map(option => { + if (option.id === 'model-selection' && message.params.modelId !== undefined) { + return { ...option, value: message.params.modelId } + } + if ( + option.id === 'pair-programmer-mode' && + message.params.pairProgrammingMode !== undefined + ) { + return { ...option, value: message.params.pairProgrammingMode ? 'true' : 'false' } + } + return option + }), }) } else { tabFactory.setInfoMessages((message.params as ChatOptionsUpdateParams).chatNotifications) @@ -228,6 +274,15 @@ export const createChat = ( tabFactory.setInfoMessages((message.params as ChatOptionsUpdateParams).chatNotifications) } + // Enable reroute FIRST before processing other options + if ((params as any)?.reroute) { + tabFactory.enableReroute() + } + + if ((params as any)?.codeReviewInChat) { + tabFactory.enableCodeReviewInChat() + } + if (params?.quickActions?.quickActionsCommandGroups) { const quickActionCommandGroups = params.quickActions.quickActionsCommandGroups.map(group => ({ ...group, @@ -251,6 +306,10 @@ export const createChat = ( tabFactory.enableExport() } + if (params?.showLogs) { + tabFactory.enableShowLogs() + } + const allExistingTabs: MynahUITabStoreModel = mynahUi.getAllTabs() const highlightCommand = featureConfig.get('highlightCommand') @@ -432,6 +491,27 @@ export const createChat = ( onOpenSettings: (settingKey: string) => { sendMessageToClient({ command: OPEN_SETTINGS, params: { settingKey } }) }, + onRuleClick: (params: RuleClickParams) => { + sendMessageToClient({ command: RULE_CLICK_REQUEST_METHOD, params }) + }, + listRules: (params: ListRulesParams) => { + sendMessageToClient({ command: LIST_RULES_REQUEST_METHOD, params }) + }, + onAddPinnedContext: (params: PinnedContextParams) => { + sendMessageToClient({ command: PINNED_CONTEXT_ADD_NOTIFICATION_METHOD, params }) + }, + onRemovePinnedContext: (params: PinnedContextParams) => { + sendMessageToClient({ command: PINNED_CONTEXT_REMOVE_NOTIFICATION_METHOD, params }) + }, + onListAvailableModels(params: ListAvailableModelsParams) { + sendMessageToClient({ command: LIST_AVAILABLE_MODELS_REQUEST_METHOD, params }) + }, + onOpenFileDialogClick: (params: OpenFileDialogParams) => { + sendMessageToClient({ command: OPEN_FILE_DIALOG, params: params }) + }, + onFilesDropped: (params: { tabId: string; files: FileList; insertPosition: number }) => { + sendMessageToClient({ command: FILES_DROPPED, params: params }) + }, } const messager = new Messager(chatApi) @@ -454,7 +534,9 @@ export const createChat = ( config?.pairProgrammingAcknowledged ?? false, chatClientAdapter, featureConfig, - !!config?.agenticMode + !!config?.agenticMode, + config?.stringOverrides, + config?.os ) mynahApi = api diff --git a/chat-client/src/client/features/history.ts b/chat-client/src/client/features/history.ts index 20d3bc9579..b9702a3555 100644 --- a/chat-client/src/client/features/history.ts +++ b/chat-client/src/client/features/history.ts @@ -7,7 +7,7 @@ export const ChatHistory = { TabBarButtonId: 'history_sheet', } as const -interface MynahDetailedList { +export interface MynahDetailedList { update: (data: DetailedList) => void close: () => void changeTarget: (direction: 'up' | 'down', snapOnLastAndFirst?: boolean) => void diff --git a/chat-client/src/client/features/rules.test.ts b/chat-client/src/client/features/rules.test.ts new file mode 100644 index 0000000000..e37be8cdb8 --- /dev/null +++ b/chat-client/src/client/features/rules.test.ts @@ -0,0 +1,319 @@ +import { MynahUI, DetailedListItem } from '@aws/mynah-ui' +import { Messager } from '../messager' +import * as sinon from 'sinon' +import { RulesList, ContextRule, convertRulesListToDetailedListGroup } from './rules' +import { ListRulesResult, RulesFolder } from '@aws/language-server-runtimes-types' +import * as assert from 'assert' + +describe('rules', () => { + let mynahUi: MynahUI + let messager: Messager + let openTopBarButtonOverlayStub: sinon.SinonStub + let showCustomFormStub: sinon.SinonStub + let rulesList: RulesList + + beforeEach(() => { + mynahUi = { + openTopBarButtonOverlay: sinon.stub(), + showCustomForm: sinon.stub(), + getAllTabs: sinon.stub().returns({}), + updateStore: sinon.stub().returns('new-tab-id'), + notify: sinon.stub(), + } as unknown as MynahUI + openTopBarButtonOverlayStub = mynahUi.openTopBarButtonOverlay as sinon.SinonStub + showCustomFormStub = mynahUi.showCustomForm as sinon.SinonStub + + messager = { + onRuleClick: sinon.stub(), + onChatPrompt: sinon.stub(), + onTabAdd: sinon.stub(), + } as unknown as Messager + + rulesList = new RulesList(mynahUi, messager) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('showLoading', () => { + it('opens top bar button overlay with loading message', () => { + const mockOverlay = { + update: sinon.stub(), + close: sinon.stub(), + } + openTopBarButtonOverlayStub.returns(mockOverlay) + + rulesList.showLoading('test-tab-id') + + sinon.assert.calledOnce(openTopBarButtonOverlayStub) + const arg = openTopBarButtonOverlayStub.getCall(0).args[0] + assert.equal(arg.tabId, 'test-tab-id') + assert.equal(arg.topBarButtonOverlay.list[0].groupName, 'Loading rules...') + assert.equal(arg.topBarButtonOverlay.selectable, false) + }) + }) + + describe('show', () => { + it('opens top bar button overlay when called first time', () => { + const mockParams: ListRulesResult = { + tabId: 'test-tab-id', + rules: [ + { + folderName: 'test-folder', + active: true, + rules: [ + { + id: 'rule-1', + name: 'Test Rule', + active: true, + }, + ], + }, + ], + filterOptions: [ + { + id: 'filter-1', + type: 'textinput', + icon: 'search', + }, + ], + } + + const mockOverlay = { + update: sinon.stub(), + close: sinon.stub(), + } + openTopBarButtonOverlayStub.returns(mockOverlay) + + rulesList.show(mockParams) + + sinon.assert.calledOnce(openTopBarButtonOverlayStub) + const arg = openTopBarButtonOverlayStub.getCall(0).args[0] + assert.equal(arg.tabId, 'test-tab-id') + assert.equal(arg.topBarButtonOverlay.selectable, 'clickable') + }) + + it('updates existing overlay when called second time', () => { + const mockParams: ListRulesResult = { + tabId: 'test-tab-id', + rules: [], + } + + const mockOverlay = { + update: sinon.stub(), + close: sinon.stub(), + } + openTopBarButtonOverlayStub.returns(mockOverlay) + + // First call + rulesList.showLoading('test-tab-id') + + // Second call + rulesList.show(mockParams) + + sinon.assert.calledOnce(mockOverlay.update) + }) + }) + + describe('rule click handling', () => { + let mockOverlay: ReturnType + let onItemClick: (item: DetailedListItem) => void + + beforeEach(() => { + mockOverlay = { + update: sinon.stub(), + close: sinon.stub(), + } + openTopBarButtonOverlayStub.returns(mockOverlay) + + rulesList.showLoading('test-tab-id') + onItemClick = openTopBarButtonOverlayStub.getCall(0).args[0].events.onItemClick + }) + + it('shows custom form when create rule is clicked', () => { + const createRuleItem: DetailedListItem = { + id: ContextRule.CreateRuleId, + description: 'Create a new rule', + } + + onItemClick(createRuleItem) + + sinon.assert.calledOnce(showCustomFormStub) + const formArgs = showCustomFormStub.getCall(0).args + assert.equal(formArgs[0], 'test-tab-id') + assert.equal(formArgs[1][0].id, ContextRule.RuleNameFieldId) + assert.equal(formArgs[2][0].id, ContextRule.CancelButtonId) + assert.equal(formArgs[2][1].id, ContextRule.SubmitButtonId) + }) + + it('calls messager when create memory bank is clicked', () => { + const createMemoryBankItem: DetailedListItem = { + id: ContextRule.CreateMemoryBankId, + description: 'Generate Memory Bank', + } + + onItemClick(createMemoryBankItem) + + // Should create new tab and send chat prompt + sinon.assert.calledOnce(messager.onTabAdd as sinon.SinonStub) + sinon.assert.calledOnce(messager.onChatPrompt as sinon.SinonStub) + + const tabAddArgs = (messager.onTabAdd as sinon.SinonStub).getCall(0).args[0] + assert.equal(tabAddArgs, 'new-tab-id') + + const chatPromptArgs = (messager.onChatPrompt as sinon.SinonStub).getCall(0).args[0] + assert.equal(chatPromptArgs.prompt.prompt, 'Generate a Memory Bank for this project') + assert.equal(chatPromptArgs.prompt.escapedPrompt, 'Generate a Memory Bank for this project') + assert.equal(chatPromptArgs.tabId, 'new-tab-id') + }) + + it('calls messager when regular rule is clicked', () => { + const ruleItem: DetailedListItem = { + id: 'test-rule-id', + description: 'Test Rule', + } + + onItemClick(ruleItem) + + sinon.assert.calledOnce(messager.onRuleClick as sinon.SinonStub) + const callArgs = (messager.onRuleClick as sinon.SinonStub).getCall(0).args[0] + assert.equal(callArgs.tabId, 'test-tab-id') + assert.equal(callArgs.type, 'rule') + assert.equal(callArgs.id, 'test-rule-id') + }) + + it('does nothing when item has no id', () => { + const itemWithoutId: DetailedListItem = { + description: 'Item without ID', + } + + onItemClick(itemWithoutId) + + sinon.assert.notCalled(messager.onRuleClick as sinon.SinonStub) + sinon.assert.notCalled(showCustomFormStub) + }) + }) + + describe('folder click handling', () => { + it('calls messager when folder is clicked', () => { + const mockOverlay = { + update: sinon.stub(), + close: sinon.stub(), + } + openTopBarButtonOverlayStub.returns(mockOverlay) + + rulesList.showLoading('test-tab-id') + const onGroupClick = openTopBarButtonOverlayStub.getCall(0).args[0].events.onGroupClick + + onGroupClick('test-folder') + + sinon.assert.calledOnce(messager.onRuleClick as sinon.SinonStub) + const callArgs = (messager.onRuleClick as sinon.SinonStub).getCall(0).args[0] + assert.equal(callArgs.tabId, 'test-tab-id') + assert.equal(callArgs.type, 'folder') + assert.equal(callArgs.id, 'test-folder') + }) + }) + + describe('keyboard handling', () => { + it('closes overlay when Escape is pressed', () => { + const mockOverlay = { + update: sinon.stub(), + close: sinon.stub(), + } + openTopBarButtonOverlayStub.returns(mockOverlay) + + rulesList.showLoading('test-tab-id') + const onKeyPress = openTopBarButtonOverlayStub.getCall(0).args[0].events.onKeyPress + + const escapeEvent = { key: 'Escape' } as KeyboardEvent + onKeyPress(escapeEvent) + + sinon.assert.calledOnce(mockOverlay.close) + }) + + it('does nothing when other keys are pressed', () => { + const mockOverlay = { + update: sinon.stub(), + close: sinon.stub(), + } + openTopBarButtonOverlayStub.returns(mockOverlay) + + rulesList.showLoading('test-tab-id') + const onKeyPress = openTopBarButtonOverlayStub.getCall(0).args[0].events.onKeyPress + + const enterEvent = { key: 'Enter' } as KeyboardEvent + onKeyPress(enterEvent) + + sinon.assert.notCalled(mockOverlay.close) + }) + }) + + describe('close', () => { + it('closes the overlay', () => { + const mockOverlay = { + update: sinon.stub(), + close: sinon.stub(), + } + openTopBarButtonOverlayStub.returns(mockOverlay) + + rulesList.showLoading('test-tab-id') + rulesList.close() + + sinon.assert.calledOnce(mockOverlay.close) + }) + }) + + describe('convertRulesListToDetailedListGroup', () => { + it('converts rules folder to detailed list group', () => { + const rulesFolder: RulesFolder[] = [ + { + folderName: 'test-folder', + active: true, + rules: [ + { + id: 'rule-1', + name: 'Test Rule 1', + active: true, + }, + { + id: 'rule-2', + name: 'Test Rule 2', + active: false, + }, + ], + }, + { + folderName: 'inactive-folder', + active: 'indeterminate', + rules: [], + }, + ] + + const result = convertRulesListToDetailedListGroup(rulesFolder) + + assert.equal(result.length, 3) // 2 folders + actions group + assert.equal(result[0].groupName, 'test-folder') + assert.equal(result[0].children?.length, 2) + assert.equal(result[0].children?.[0].id, 'rule-1') + assert.equal(result[0].children?.[0].description, 'Test Rule 1') + assert.equal(result[1].groupName, 'inactive-folder') + assert.equal(result[1].children?.length, 0) + assert.equal(result[2].groupName, 'Actions') + assert.equal(result[2].children?.length, 2) // Memory Bank + Create Rule + assert.equal(result[2].children?.[0].id, ContextRule.CreateMemoryBankId) + assert.equal(result[2].children?.[1].id, ContextRule.CreateRuleId) + }) + + it('handles empty rules array', () => { + const result = convertRulesListToDetailedListGroup([]) + + assert.equal(result.length, 1) // Only actions group + assert.equal(result[0].groupName, 'Actions') + assert.equal(result[0].children?.length, 2) // Memory Bank + Create Rule + assert.equal(result[0].children?.[0].id, ContextRule.CreateMemoryBankId) + assert.equal(result[0].children?.[1].id, ContextRule.CreateRuleId) + }) + }) +}) diff --git a/chat-client/src/client/features/rules.ts b/chat-client/src/client/features/rules.ts new file mode 100644 index 0000000000..585b33144c --- /dev/null +++ b/chat-client/src/client/features/rules.ts @@ -0,0 +1,262 @@ +import { + MynahIconsType, + MynahUI, + DetailedListItem, + DetailedListItemGroup, + MynahIcons, + NotificationType, +} from '@aws/mynah-ui' +import { Messager } from '../messager' +import { ListRulesResult } from '@aws/language-server-runtimes-types' +import { RulesFolder } from '@aws/language-server-runtimes-types' +import { MynahDetailedList } from './history' + +export const ContextRule = { + CreateRuleId: 'create-rule', + CreateMemoryBankId: 'create-memory-bank', + CancelButtonId: 'cancel-create-rule', + SubmitButtonId: 'submit-create-rule', + RuleNameFieldId: 'rule-name', +} as const + +export class RulesList { + rulesList: MynahDetailedList | undefined + tabId: string = '' + + constructor( + private mynahUi: MynahUI, + private messager: Messager + ) {} + + private onRuleFolderClick = (groupName: string) => { + this.messager.onRuleClick({ tabId: this.tabId, type: 'folder', id: groupName }) + } + + private onRuleClick = (item: DetailedListItem) => { + if (item.id) { + if (item.id === ContextRule.CreateRuleId) { + this.rulesList?.close() + this.mynahUi.showCustomForm( + this.tabId, + [ + { + id: ContextRule.RuleNameFieldId, + type: 'textinput', + mandatory: true, + autoFocus: true, + title: 'Rule name', + placeholder: 'Enter rule name', + validationPatterns: { + patterns: [ + { + pattern: /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}$/, + errorMessage: + 'Use only letters, numbers, hyphens, and underscores, starting with a letter or number. Maximum 100 characters.', + }, + ], + }, + validateOnChange: true, + description: + "This will create a [rule name].md file in your project's .amazonq/rules folder.", + }, + ], + [ + { + id: ContextRule.CancelButtonId, + text: 'Cancel', + status: 'clear', + waitMandatoryFormItems: false, + }, + { + id: ContextRule.SubmitButtonId, + text: 'Create', + status: 'main', + waitMandatoryFormItems: true, + }, + ], + `Create a rule` + ) + } else if (item.id === ContextRule.CreateMemoryBankId) { + this.rulesList?.close() + this.handleMemoryBankCreation() + } else { + this.messager.onRuleClick({ tabId: this.tabId, type: 'rule', id: item.id }) + } + } + } + + private handleMemoryBankCreation = () => { + // Close the rules list first + this.rulesList?.close() + + // Check if we're at the tab limit (10 tabs max) + const currentTabCount = Object.keys(this.mynahUi.getAllTabs()).length + if (currentTabCount >= 10) { + // Show notification that max tabs reached + this.mynahUi.notify({ + content: 'You can only open ten conversation tabs at a time.', + type: NotificationType.WARNING, + }) + return + } + + // Create a new tab for the memory bank generation + const newTabId = this.mynahUi.updateStore('', { tabTitle: 'Memory Bank' }) + if (newTabId) { + // Add the new tab and switch to it + this.messager.onTabAdd(newTabId) + + // Send the chat prompt to the new tab + this.messager.onChatPrompt({ + prompt: { + prompt: 'Generate a Memory Bank for this project', + escapedPrompt: 'Generate a Memory Bank for this project', + }, + tabId: newTabId, + }) + } else { + // Show error notification if tab creation failed + this.mynahUi.notify({ + content: 'Failed to create new tab for Memory Bank generation.', + type: NotificationType.ERROR, + }) + } + } + + showLoading(tabId: string) { + this.tabId = tabId + const rulesList = this.mynahUi.openTopBarButtonOverlay({ + tabId: this.tabId, + topBarButtonOverlay: { + list: [{ groupName: 'Loading rules...' }], + selectable: false, + }, + events: { + onGroupClick: this.onRuleFolderClick, + onItemClick: this.onRuleClick, + onClose: this.onClose, + onKeyPress: this.onKeyPress, + }, + }) + + this.rulesList = { + ...rulesList, + changeTarget: () => {}, + getTargetElementId: () => { + return undefined + }, + } + } + + show(params: ListRulesResult) { + this.tabId = params.tabId + if (this.rulesList) { + this.rulesList.update({ + filterOptions: params.filterOptions?.map(option => ({ + ...option, + icon: option.icon as MynahIconsType, + })), + list: convertRulesListToDetailedListGroup(params.rules), + selectable: 'clickable', + }) + } else { + const rulesList = this.mynahUi.openTopBarButtonOverlay({ + tabId: this.tabId, + topBarButtonOverlay: { + list: convertRulesListToDetailedListGroup(params.rules), + selectable: 'clickable', + }, + events: { + onGroupClick: this.onRuleFolderClick, + onItemClick: this.onRuleClick, + onClose: this.onClose, + onKeyPress: this.onKeyPress, + }, + }) + + this.rulesList = { + ...rulesList, + changeTarget: () => {}, + getTargetElementId: () => { + return undefined + }, + } + } + } + + private onKeyPress = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.close() + } + } + + close() { + this.rulesList?.close() + } + + private onClose = () => { + this.rulesList = undefined + } +} + +const createRuleListItem: DetailedListItem = { + description: 'Create a new rule', + icon: MynahIcons.LIST_ADD, + id: ContextRule.CreateRuleId, +} + +function createMemoryBankListItem(rules: RulesFolder[]): DetailedListItem { + // Handles button text changes between "Generation" and "Regenerate" + const memoryBankFiles = ['product', 'structure', 'tech', 'guidelines'] + + const memoryBankFolder = rules.find(folder => folder.folderName === 'memory-bank') + + const hasMemoryBankFiles = + memoryBankFolder && memoryBankFolder.rules.some(rule => memoryBankFiles.includes(rule.name)) + + const buttonText = hasMemoryBankFiles ? 'Regenerate Memory Bank' : 'Generate Memory Bank' + + return { + description: buttonText, + icon: MynahIcons.FOLDER, + id: ContextRule.CreateMemoryBankId, + } +} + +export function convertRulesListToDetailedListGroup(rules: RulesFolder[]): DetailedListItemGroup[] { + return rules + .map( + ruleFolder => + ({ + groupName: ruleFolder.folderName, + actions: [ + { + id: ruleFolder.folderName, + icon: convertRuleStatusToIcon(ruleFolder.active), + status: 'clear', + }, + ], + icon: MynahIcons.FOLDER, + childrenIndented: true, + children: ruleFolder.rules.map(rule => ({ + id: rule.id, + icon: MynahIcons.CHECK_LIST, + description: rule.name, + actions: [{ id: rule.id, icon: convertRuleStatusToIcon(rule.active), status: 'clear' }], + })), + }) as DetailedListItemGroup + ) + .concat({ + groupName: 'Actions', + children: [createMemoryBankListItem(rules), createRuleListItem], + }) +} + +function convertRuleStatusToIcon(status: boolean | 'indeterminate'): MynahIcons | undefined { + if (status === true) { + return MynahIcons.OK + } else if (status === 'indeterminate') { + return MynahIcons.MINUS + } + return undefined +} diff --git a/chat-client/src/client/imageVerification.test.ts b/chat-client/src/client/imageVerification.test.ts new file mode 100644 index 0000000000..3d769b2088 --- /dev/null +++ b/chat-client/src/client/imageVerification.test.ts @@ -0,0 +1,294 @@ +import * as assert from 'assert' +import * as sinon from 'sinon' +import { + isSupportedImageExtension, + isFileSizeValid, + areImageDimensionsValid, + verifyClientImage, + verifyClientImages, + DEFAULT_IMAGE_VERIFICATION_OPTIONS, + MAX_IMAGE_CONTEXT, +} from './imageVerification' + +class MockImage { + onload: (() => void) | null = null + onerror: (() => void) | null = null + width = 800 + height = 600 + _src = '' + get src() { + return this._src + } + set src(value: string) { + this._src = value + // Simulate image loading + Promise.resolve().then(() => this.onload?.()) + } +} + +class MockFileReader { + onload: ((event: any) => void) | null = null + onerror: (() => void) | null = null + result: string | ArrayBuffer | null = null + readAsDataURL(file: File) { + setTimeout(() => { + this.result = 'data:image/png;base64,mock-data' + this.onload?.({ target: { result: this.result } }) + }, 0) + } +} + +describe('imageVerification', () => { + let imageStub: sinon.SinonStub + let urlStub: sinon.SinonStub + let fileReaderStub: sinon.SinonStub + + beforeEach(() => { + imageStub = sinon.stub(global, 'Image').callsFake(() => new MockImage()) + urlStub = sinon.stub(global, 'URL').value({ + createObjectURL: sinon.stub().returns('blob:mock-url'), + revokeObjectURL: sinon.stub(), + }) + fileReaderStub = sinon.stub(global, 'FileReader').callsFake(() => new MockFileReader()) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('constants', () => { + it('has correct MAX_IMAGE_CONTEXT value', () => { + assert.equal(MAX_IMAGE_CONTEXT, 20) + }) + + it('has correct default options', () => { + assert.equal(DEFAULT_IMAGE_VERIFICATION_OPTIONS.maxSizeBytes, 3.75 * 1024 * 1024) + assert.equal(DEFAULT_IMAGE_VERIFICATION_OPTIONS.maxDimension, 8000) + assert.deepEqual(DEFAULT_IMAGE_VERIFICATION_OPTIONS.supportedExtensions, [ + 'jpeg', + 'jpg', + 'png', + 'gif', + 'webp', + ]) + }) + }) + + describe('isSupportedImageExtension', () => { + it('returns true for supported extensions', () => { + assert.equal(isSupportedImageExtension('jpg'), true) + assert.equal(isSupportedImageExtension('jpeg'), true) + assert.equal(isSupportedImageExtension('png'), true) + assert.equal(isSupportedImageExtension('gif'), true) + assert.equal(isSupportedImageExtension('webp'), true) + }) + + it('returns true for supported extensions with dots', () => { + assert.equal(isSupportedImageExtension('.jpg'), true) + assert.equal(isSupportedImageExtension('.png'), true) + }) + + it('returns true for uppercase extensions', () => { + assert.equal(isSupportedImageExtension('JPG'), true) + assert.equal(isSupportedImageExtension('PNG'), true) + }) + + it('returns false for unsupported extensions', () => { + assert.equal(isSupportedImageExtension('txt'), false) + assert.equal(isSupportedImageExtension('pdf'), false) + assert.equal(isSupportedImageExtension('doc'), false) + }) + }) + + describe('isFileSizeValid', () => { + it('returns true for valid file sizes', () => { + assert.equal(isFileSizeValid(1024), true) // 1KB + assert.equal(isFileSizeValid(1024 * 1024), true) // 1MB + }) + + it('returns false for oversized files', () => { + const maxSize = DEFAULT_IMAGE_VERIFICATION_OPTIONS.maxSizeBytes + assert.equal(isFileSizeValid(maxSize + 1), false) + }) + + it('accepts custom max size', () => { + assert.equal(isFileSizeValid(2048, 1024), false) + assert.equal(isFileSizeValid(512, 1024), true) + }) + }) + + describe('areImageDimensionsValid', () => { + it('returns true for valid dimensions', () => { + assert.equal(areImageDimensionsValid(800, 600), true) + assert.equal(areImageDimensionsValid(1920, 1080), true) + }) + + it('returns false for oversized dimensions', () => { + const maxDim = DEFAULT_IMAGE_VERIFICATION_OPTIONS.maxDimension + assert.equal(areImageDimensionsValid(maxDim + 1, 600), false) + assert.equal(areImageDimensionsValid(800, maxDim + 1), false) + }) + + it('accepts custom max dimension', () => { + assert.equal(areImageDimensionsValid(1200, 800, 1000), false) + assert.equal(areImageDimensionsValid(800, 600, 1000), true) + }) + }) + + describe('verifyClientImage', () => { + let mockFile: File + + beforeEach(() => { + mockFile = { + name: 'test.jpg', + size: 1024 * 1024, // 1MB + type: 'image/jpeg', + } as File + }) + + it('validates a correct image file', async () => { + const result = await verifyClientImage(mockFile, 'test.jpg') + assert.equal(result.isValid, true) + assert.equal(result.errors.length, 0) + }) + + it('rejects unsupported file extension', async () => { + const result = await verifyClientImage(mockFile, 'test.txt') + assert.equal(result.isValid, false) + assert.equal(result.errors.length, 1) + assert.ok(result.errors[0].includes('File must be an image')) + }) + + it('rejects oversized files', async () => { + const largeFile = { + ...mockFile, + size: DEFAULT_IMAGE_VERIFICATION_OPTIONS.maxSizeBytes + 1, + } as File + + const result = await verifyClientImage(largeFile, 'large.jpg') + assert.equal(result.isValid, false) + assert.equal(result.errors.length, 1) + assert.ok(result.errors[0].includes('must be no more than')) + }) + + it('rejects images with oversized dimensions', async () => { + // Stub Image to return oversized dimensions + imageStub.callsFake(() => ({ + onload: null, + onerror: null, + width: DEFAULT_IMAGE_VERIFICATION_OPTIONS.maxDimension + 1, + height: 600, + _src: '', + get src() { + return this._src + }, + set src(value: string) { + this._src = value + Promise.resolve().then(() => this.onload?.()) + }, + })) + + const result = await verifyClientImage(mockFile, 'oversized.jpg') + assert.equal(result.isValid, false) + assert.equal(result.errors.length, 1) + assert.ok(result.errors[0].includes('must be no more than')) + }) + + it('handles image loading errors', async () => { + // Stub Image to fail loading + imageStub.callsFake(() => ({ + onload: null, + onerror: null, + width: 0, + height: 0, + _src: '', + get src() { + return this._src + }, + set src(value: string) { + this._src = value + Promise.resolve().then(() => this.onerror?.()) + }, + })) + + // Stub FileReader to also fail + fileReaderStub.callsFake(() => ({ + onload: null, + onerror: null, + result: null, + readAsDataURL() { + setTimeout(() => this.onerror?.(), 0) + }, + })) + + const result = await verifyClientImage(mockFile, 'failing.jpg') + assert.equal(result.isValid, false) + assert.equal(result.errors.length, 1) + assert.ok(result.errors[0].includes('Unable to read image dimensions')) + }) + }) + + describe('verifyClientImages', () => { + let mockFileList: FileList + + beforeEach(() => { + const validFile = { + name: 'valid.jpg', + size: 1024 * 1024, + type: 'image/jpeg', + } as File + + const invalidFile = { + name: 'invalid.txt', + size: 1024, + type: 'text/plain', + } as File + + mockFileList = { + length: 2, + 0: validFile, + 1: invalidFile, + item: (index: number) => (index === 0 ? validFile : invalidFile), + } as unknown as FileList + }) + + it('separates valid and invalid files', async () => { + const result = await verifyClientImages(mockFileList) + assert.equal(result.validFiles.length, 1) + assert.equal(result.errors.length, 1) + assert.equal(result.validFiles[0].name, 'valid.jpg') + assert.ok(result.errors[0].includes('invalid.txt')) + }) + + it('handles empty file list', async () => { + const emptyFileList = { + length: 0, + item: () => null, + } as unknown as FileList + + const result = await verifyClientImages(emptyFileList) + assert.equal(result.validFiles.length, 0) + assert.equal(result.errors.length, 0) + }) + + it('handles files without names', async () => { + const fileWithoutName = { + name: '', + size: 1024, + type: 'image/jpeg', + } as File + + const fileListWithUnnamed = { + length: 1, + 0: fileWithoutName, + item: () => fileWithoutName, + } as unknown as FileList + + const result = await verifyClientImages(fileListWithUnnamed) + // File without extension should be rejected + assert.equal(result.validFiles.length, 0) + assert.equal(result.errors.length, 1) + assert.ok(result.errors[0].includes('Unknown file')) + }) + }) +}) diff --git a/chat-client/src/client/imageVerification.ts b/chat-client/src/client/imageVerification.ts new file mode 100644 index 0000000000..7bde7f73f1 --- /dev/null +++ b/chat-client/src/client/imageVerification.ts @@ -0,0 +1,148 @@ +/** + * Shared image verification utilities for AWS LSP packages + * Provides consistent image validation across client and server components + * This is a standalone version that doesn't depend on Node.js modules + */ + +export const MAX_IMAGE_CONTEXT: number = 20 + +export interface ImageVerificationResult { + isValid: boolean + errors: string[] +} + +export interface ImageVerificationOptions { + maxSizeBytes?: number + maxDimension?: number + supportedExtensions?: string[] +} + +export const DEFAULT_IMAGE_VERIFICATION_OPTIONS: Required = { + maxSizeBytes: 3.75 * 1024 * 1024, // 3.75MB + maxDimension: 8000, // 8000px + supportedExtensions: ['jpeg', 'jpg', 'png', 'gif', 'webp'], +} + +/** + * Verifies if a file extension is supported for images + */ +export function isSupportedImageExtension(extension: string): boolean { + const ext = extension.toLowerCase().replace('.', '') + return DEFAULT_IMAGE_VERIFICATION_OPTIONS.supportedExtensions.includes(ext) +} + +/** + * Verifies if a file size is within acceptable limits + */ +export function isFileSizeValid(fileSize: number, maxSizeBytes?: number): boolean { + const maxSize = maxSizeBytes ?? DEFAULT_IMAGE_VERIFICATION_OPTIONS.maxSizeBytes + return fileSize <= maxSize +} + +/** + * Verifies if image dimensions are within acceptable limits + */ +export function areImageDimensionsValid(width: number, height: number, maxDimension?: number): boolean { + const maxDim = maxDimension ?? DEFAULT_IMAGE_VERIFICATION_OPTIONS.maxDimension + return width <= maxDim && height <= maxDim +} + +/** + * Client-side image verification for File objects (browser environment) + */ +export async function verifyClientImage(file: File, fileName: string): Promise { + const opts = DEFAULT_IMAGE_VERIFICATION_OPTIONS + const errors: string[] = [] + + // Check file extension + const extension = fileName.split('.').pop()?.toLowerCase() || '' + if (!isSupportedImageExtension(extension)) { + errors.push(`${fileName}: File must be an image in JPEG, PNG, GIF, or WebP format.`) + return { isValid: false, errors } + } + + // Check file size + if (!isFileSizeValid(file.size, opts.maxSizeBytes)) { + errors.push( + `${fileName}: Image must be no more than ${(opts.maxSizeBytes / (1024 * 1024)).toFixed(2)}MB in size.` + ) + return { isValid: false, errors } + } + + // Check image dimensions + try { + const dimensions = await getClientImageDimensions(file) + if (!areImageDimensionsValid(dimensions.width, dimensions.height, opts.maxDimension)) { + errors.push(`${fileName}: Image must be no more than ${opts.maxDimension}px in width or height.`) + return { isValid: false, errors } + } + } catch (error) { + errors.push(`${fileName}: Unable to read image dimensions.`) + return { isValid: false, errors } + } + + return { isValid: true, errors: [] } +} + +/** + * Batch verification for multiple client files + */ +export async function verifyClientImages(files: FileList): Promise<{ validFiles: File[]; errors: string[] }> { + const validFiles: File[] = [] + const errors: string[] = [] + + for (let i = 0; i < files.length; i++) { + const file = files[i] + const fileName = file.name || 'Unknown file' + + const result = await verifyClientImage(file, fileName) + if (result.isValid) { + validFiles.push(file) + } else { + errors.push(...result.errors) + } + } + + return { validFiles, errors } +} + +async function getClientImageDimensions(file: File): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const img = new Image() + const objectUrl = URL.createObjectURL(file) + + img.onload = () => { + URL.revokeObjectURL(objectUrl) + resolve({ width: img.width, height: img.height }) + } + + img.onerror = () => { + URL.revokeObjectURL(objectUrl) + // Fall back to FileReader if ObjectURL fails + const reader = new FileReader() + + reader.onload = e => { + const fallbackImg = new Image() + + fallbackImg.onload = () => { + resolve({ width: fallbackImg.width, height: fallbackImg.height }) + } + + fallbackImg.onerror = () => { + reject(new Error('Failed to load image')) + } + + if (e.target?.result) { + fallbackImg.src = e.target.result as string + } else { + reject(new Error('Failed to read image file')) + } + } + + reader.onerror = reject + reader.readAsDataURL(file) + } + + img.src = objectUrl + }) +} diff --git a/chat-client/src/client/mcpMynahUi.test.ts b/chat-client/src/client/mcpMynahUi.test.ts index 9d1dec5407..947e5bc604 100644 --- a/chat-client/src/client/mcpMynahUi.test.ts +++ b/chat-client/src/client/mcpMynahUi.test.ts @@ -107,10 +107,8 @@ describe('McpMynahUi', () => { assert.strictEqual(callArgs.detailedList.header.description, 'Test Description') assert.deepStrictEqual(callArgs.detailedList.header.status, { status: 'success' }) - // Verify the actions in the header - assert.strictEqual(callArgs.detailedList.header.actions.length, 2) - assert.strictEqual(callArgs.detailedList.header.actions[0].id, 'add-new-mcp') - assert.strictEqual(callArgs.detailedList.header.actions[1].id, 'refresh-mcp-list') + // Verify the actions in the header (no default actions are added when header is provided) + assert.strictEqual(callArgs.detailedList.header.actions.length, 0) // Verify the list structure assert.strictEqual(callArgs.detailedList.list.length, 1) diff --git a/chat-client/src/client/mcpMynahUi.ts b/chat-client/src/client/mcpMynahUi.ts index 2b3b1e7a0b..0ee418e925 100644 --- a/chat-client/src/client/mcpMynahUi.ts +++ b/chat-client/src/client/mcpMynahUi.ts @@ -29,6 +29,7 @@ export const MCP_IDS = { EDIT: 'edit-mcp', SAVE: 'save-mcp', CANCEL: 'cancel-mcp', + CHANGE_TRANSPORT: 'change-transport', // Permission actions PERMISSION_CHANGE: 'mcp-permission-change', @@ -80,12 +81,17 @@ export class McpMynahUi { private mynahUi: MynahUI private messager: Messager private isMcpServersListActive = false + private mcpDetailedList: { close: () => void } | undefined constructor(mynahUi: MynahUI, messager: Messager) { this.mynahUi = mynahUi this.messager = messager } + close() { + this.mcpDetailedList?.close() + } + /** * Processes filter options by converting icons to Mynah icons */ @@ -271,20 +277,12 @@ export class McpMynahUi { title: params.header.title, description: params.header.description, status: params.header.status, - actions: [ - { - id: MCP_IDS.ADD_NEW, - icon: toMynahIcon('plus'), - status: 'clear', - description: 'Add new MCP', - }, - { - id: MCP_IDS.REFRESH_LIST, - icon: toMynahIcon('refresh'), - status: 'clear', - description: 'Refresh MCP servers', - }, - ], + actions: + params.header.actions?.map(action => ({ + ...action, + icon: action.icon ? toMynahIcon(action.icon) : undefined, + text: undefined, + })) || [], } : undefined, filterOptions: params.filterOptions?.map(filter => ({ @@ -404,7 +402,7 @@ export class McpMynahUi { ;(detailedList.filterOptions[0] as any).autoFocus = true } - const mcpSheet = this.mynahUi.openDetailedList({ + this.mcpDetailedList = this.mynahUi.openDetailedList({ detailedList: detailedList, events: { onFilterValueChange: (filterValues: Record) => { @@ -412,7 +410,7 @@ export class McpMynahUi { }, onKeyPress: (e: KeyboardEvent) => { if (e.key === 'Escape') { - mcpSheet.close() + this.mcpDetailedList?.close() } }, onItemSelect: (item: DetailedListItem) => { @@ -433,6 +431,7 @@ export class McpMynahUi { }, onClose: () => { this.isMcpServersListActive = false + this.mcpDetailedList = undefined }, onTitleActionClick: button => { this.messager.onMcpServerClick(button.id) @@ -448,12 +447,26 @@ export class McpMynahUi { const typedParams = params as McpServerParams if (params.id === MCP_IDS.ADD_NEW || params.id === MCP_IDS.EDIT || params.id === MCP_IDS.FIX_SERVER) { this.mynahUi.toggleSplashLoader(false) + + const uiFilters = (typedParams.filterOptions ?? []) as McpFilterOption[] + const initial = uiFilters.find(f => f.id === 'transport') + let _lastTransport = initial?.value as unknown as string + const detailedList = this.createAddMcpServerDetailedList(typedParams) const events = { onBackClick: () => { this.messager.onListMcpServers() }, + onFilterValueChange: (filterValues: Record) => { + const newTransport = filterValues?.transport + if (!newTransport || newTransport === _lastTransport) { + return + } + + _lastTransport = newTransport + this.messager.onMcpServerClick(MCP_IDS.CHANGE_TRANSPORT, filterValues.name, filterValues) + }, onFilterActionClick: ( actionParams: McpServerClickResult, filterValues?: Record, @@ -508,6 +521,7 @@ export class McpMynahUi { }, onClose: () => { this.messager.onMcpServerClick(MCP_IDS.SAVE_PERMISSION_CHANGE) + this.isMcpServersListActive = false }, onBackClick: () => { this.messager.onMcpServerClick(MCP_IDS.SAVE_PERMISSION_CHANGE) diff --git a/chat-client/src/client/messager.ts b/chat-client/src/client/messager.ts index e0ee13eaa1..9472881b87 100644 --- a/chat-client/src/client/messager.ts +++ b/chat-client/src/client/messager.ts @@ -34,16 +34,21 @@ import { InfoLinkClickParams, LinkClickParams, ListConversationsParams, + ListRulesParams, ListMcpServersParams, McpServerClickParams, + OpenFileDialogParams, OpenTabResult, + PinnedContextParams, PromptInputOptionChangeParams, QuickActionParams, + RuleClickParams, SourceLinkClickParams, TabAddParams, TabBarActionParams, TabChangeParams, TabRemoveParams, + ListAvailableModelsParams, } from '@aws/language-server-runtimes-types' import { TelemetryParams } from '../contracts/serverContracts' import { @@ -100,16 +105,27 @@ export interface OutboundChatApi { stopChatResponse(tabId: string): void sendButtonClickEvent(params: ButtonClickParams): void onOpenSettings(settingKey: string): void + onRuleClick(params: RuleClickParams): void + listRules(params: ListRulesParams): void + onAddPinnedContext(params: PinnedContextParams): void + onRemovePinnedContext(params: PinnedContextParams): void + onListAvailableModels(params: ListAvailableModelsParams): void + onOpenFileDialogClick(params: OpenFileDialogParams): void + onFilesDropped(params: { tabId: string; files: FileList; insertPosition: number }): void } export class Messager { constructor(private readonly chatApi: OutboundChatApi) {} - onTabAdd = (tabId: string, triggerType?: TriggerType): void => { - this.chatApi.tabAdded({ tabId }) + onTabAdd = (tabId: string, triggerType?: TriggerType, restoredTab?: boolean): void => { + this.chatApi.tabAdded({ tabId, restoredTab }) this.chatApi.telemetry({ triggerType: triggerType ?? 'click', tabId, name: TAB_ADD_TELEMETRY_EVENT }) } + onRuleClick = (params: RuleClickParams): void => { + this.chatApi.onRuleClick(params) + } + onTabChange = (tabId: string): void => { this.chatApi.tabChanged({ tabId }) } @@ -203,8 +219,8 @@ export class Messager { this.chatApi.onOpenTab(requestId, result) } - onCreatePrompt = (promptName: string): void => { - this.chatApi.createPrompt({ promptName }) + onCreatePrompt = (params: CreatePromptParams): void => { + this.chatApi.createPrompt(params) } onFileClick = (params: FileClickParams): void => { @@ -218,6 +234,10 @@ export class Messager { } } + onListRules = (params: ListRulesParams): void => { + this.chatApi.listRules(params) + } + onConversationClick = (conversationId: string, action?: ConversationAction): void => { this.chatApi.conversationClick({ id: conversationId, action }) } @@ -257,4 +277,24 @@ export class Messager { onOpenSettings = (settingKey: string): void => { this.chatApi.onOpenSettings(settingKey) } + + onAddPinnedContext = (params: PinnedContextParams) => { + this.chatApi.onAddPinnedContext(params) + } + + onRemovePinnedContext = (params: PinnedContextParams) => { + this.chatApi.onRemovePinnedContext(params) + } + + onListAvailableModels = (params: ListAvailableModelsParams): void => { + this.chatApi.onListAvailableModels(params) + } + + onOpenFileDialogClick = (params: OpenFileDialogParams): void => { + this.chatApi.onOpenFileDialogClick(params) + } + + onFilesDropped = (params: { tabId: string; files: FileList; insertPosition: number }): void => { + this.chatApi.onFilesDropped(params) + } } diff --git a/chat-client/src/client/mynahUi.test.ts b/chat-client/src/client/mynahUi.test.ts index 07cd76f2fa..1f9f6c4e57 100644 --- a/chat-client/src/client/mynahUi.test.ts +++ b/chat-client/src/client/mynahUi.test.ts @@ -7,15 +7,16 @@ import { handleChatPrompt, DEFAULT_HELP_PROMPT, handlePromptInputChange, + uiComponentsTexts, } from './mynahUi' import { Messager, OutboundChatApi } from './messager' import { TabFactory } from './tabs/tabFactory' import { ChatItemType, MynahUI, NotificationType } from '@aws/mynah-ui' import { ChatClientAdapter } from '../contracts/chatClientAdapter' -import { ChatMessage } from '@aws/language-server-runtimes-types' +import { ChatMessage, ContextCommand, ListAvailableModelsResult } from '@aws/language-server-runtimes-types' import { ChatHistory } from './features/history' import { pairProgrammingModeOn, pairProgrammingModeOff } from './texts/pairProgramming' -import { BedrockModel } from './texts/modelSelection' +import { strictEqual } from 'assert' describe('MynahUI', () => { let messager: Messager @@ -70,6 +71,13 @@ describe('MynahUI', () => { stopChatResponse: sinon.stub(), sendButtonClickEvent: sinon.stub(), onOpenSettings: sinon.stub(), + onRuleClick: sinon.stub(), + listRules: sinon.stub(), + onAddPinnedContext: sinon.stub(), + onRemovePinnedContext: sinon.stub(), + onListAvailableModels: sinon.stub(), + onOpenFileDialogClick: sinon.stub(), + onFilesDropped: sinon.stub(), } messager = new Messager(outboundChatApi) @@ -238,9 +246,16 @@ describe('MynahUI', () => { }) describe('sendGenericCommand', () => { - it('should create a new tab if none exits', () => { + it('should create a new tab if none exits', function () { + this.timeout(10000) // Increase timeout to 10 seconds // clear create tab stub since set up process calls it twice createTabStub.resetHistory() + // Stub setTimeout to execute immediately + const setTimeoutStub = sinon.stub(global, 'setTimeout').callsFake((fn: Function) => { + fn() + return {} as any + }) + const genericCommand = 'Explain' const selection = 'const x = 5;' const tabId = '' @@ -250,11 +265,18 @@ describe('MynahUI', () => { sinon.assert.calledOnceWithExactly(createTabStub, false) sinon.assert.calledThrice(updateStoreSpy) + setTimeoutStub.restore() }) - it('should create a new tab if current tab is loading', () => { + it('should create a new tab if current tab is loading', function () { + this.timeout(10000) // clear create tab stub since set up process calls it twice createTabStub.resetHistory() + // Stub setTimeout to execute immediately + const setTimeoutStub = sinon.stub(global, 'setTimeout').callsFake((fn: Function) => { + fn() + return {} as any + }) getAllTabsStub.returns({ 'tab-1': { store: { loadingChat: true } } }) const genericCommand = 'Explain' @@ -266,10 +288,16 @@ describe('MynahUI', () => { sinon.assert.calledOnceWithExactly(createTabStub, false) sinon.assert.calledThrice(updateStoreSpy) + setTimeoutStub.restore() }) it('should not create a new tab if one exists already', () => { createTabStub.resetHistory() + // Stub setTimeout to execute immediately + const setTimeoutStub = sinon.stub(global, 'setTimeout').callsFake((fn: Function) => { + fn() + return {} as any + }) const genericCommand = 'Explain' const selection = 'const x = 5;' const tabId = 'tab-1' @@ -279,9 +307,15 @@ describe('MynahUI', () => { sinon.assert.notCalled(createTabStub) sinon.assert.calledOnce(updateStoreSpy) + setTimeoutStub.restore() }) it('should call handleChatPrompt when sendGenericCommand is called', () => { + // Stub setTimeout to execute immediately + const setTimeoutStub = sinon.stub(global, 'setTimeout').callsFake((fn: Function) => { + fn() + return {} as any + }) const genericCommand = 'Explain' const selection = 'const x = 5;' const tabId = 'tab-1' @@ -311,6 +345,7 @@ describe('MynahUI', () => { loadingChat: true, promptInputDisabledState: false, }) + setTimeoutStub.restore() }) }) @@ -427,20 +462,63 @@ describe('MynahUI', () => { const newValues = { 'pair-programmer-mode': 'true', - 'model-selection': BedrockModel.CLAUDE_3_5_SONNET_20241022_V2_0, + 'model-selection': 'CLAUDE_3_7_SONNET_20250219_V1_0', } handlePromptInputChange(mynahUi, tabId, newValues) const expectedOptions = [ { id: 'pair-programmer-mode', value: 'true' }, - { id: 'model-selection', value: BedrockModel.CLAUDE_3_5_SONNET_20241022_V2_0 }, + { id: 'model-selection', value: 'CLAUDE_3_7_SONNET_20250219_V1_0' }, ] sinon.assert.calledWith(updateStoreSpy, tabId, { promptInputOptions: expectedOptions, }) }) + + it('should add model selection notification when model is changed', () => { + const tabId = 'tab-1' + const modelOptions = [ + { value: 'CLAUDE_3_7_SONNET_20250219_V1_0', label: 'Claude Sonnet 3.7' }, + { value: 'CLAUDE_SONNET_4_20250514_V1_0', label: 'Claude Sonnet 4' }, + ] + const promptInputOptions = [ + { + id: 'model-selection', + type: 'select', + value: 'CLAUDE_3_7_SONNET_20250219_V1_0', + options: modelOptions, + }, + ] + + const getTabDataStub = sinon.stub(mynahUi, 'getTabData') + getTabDataStub.returns({ + getStore: () => ({ + // @ts-expect-error partial object + promptInputOptions, + }), + }) + + // Reset addChatItem spy to track new calls + addChatItemSpy.resetHistory() + + // Change model from Claude 3.7 to Claude 4 + const newValues = { + 'model-selection': 'CLAUDE_SONNET_4_20250514_V1_0', + } + + handlePromptInputChange(mynahUi, tabId, newValues) + + // Verify that a model selection notification was added + sinon.assert.calledOnce(addChatItemSpy) + sinon.assert.calledWithMatch(addChatItemSpy, tabId, { + type: ChatItemType.DIRECTIVE, + contentHorizontalAlignment: 'center', + fullWidth: true, + body: 'Switched model to Claude Sonnet 4', + }) + }) }) describe('getSerializedChat', () => { @@ -476,6 +554,221 @@ describe('MynahUI', () => { }) }) }) + + describe('listAvailableModels', () => { + it('should update promptInputOptions with available models', () => { + const tabId = 'tab-1' + + // Setup tab data with existing promptInputOptions + const getTabDataStub = sinon.stub(mynahUi, 'getTabData') + getTabDataStub.returns({ + getStore: () => ({ + // @ts-expect-error partial object + promptInputOptions: [{ id: 'model-selection', options: [] }], + }), + }) + + // Simulate the response from the server + const models = [ + { id: 'CLAUDE_3_7_SONNET_20250219_V1_0', name: 'Claude Sonnet 3.7' }, + { id: 'CLAUDE_SONNET_4_20250514_V1_0', name: 'Claude Sonnet 4', description: 'Test description' }, + ] + + const result: ListAvailableModelsResult = { + tabId, + models, + selectedModelId: 'CLAUDE_3_7_SONNET_20250219_V1_0', + } + + // Call the listAvailableModels method + inboundChatApi.listAvailableModels(result) + + // Verify updateStore was called with the correct options + sinon.assert.calledWith(updateStoreSpy, tabId, { + promptInputOptions: [ + { + id: 'model-selection', + options: [ + { value: 'CLAUDE_3_7_SONNET_20250219_V1_0', label: 'Claude Sonnet 3.7', description: '' }, + { + value: 'CLAUDE_SONNET_4_20250514_V1_0', + label: 'Claude Sonnet 4', + description: 'Test description', + }, + ], + type: 'select', + value: 'CLAUDE_3_7_SONNET_20250219_V1_0', + }, + ], + }) + }) + }) + + describe('sendPinnedContext', () => { + it('should update UI with pinned context items', () => { + const tabId = 'tab-1' + const pinnedContextCommands = [ + { + id: 'pinned-file-1', + command: 'File 1', + label: 'file', + route: ['/workspace', 'src/file1.ts'], + icon: 'file', + }, + ] as ContextCommand[] + + // Call sendPinnedContext with pinned context items + inboundChatApi.sendPinnedContext({ + tabId, + contextCommandGroups: [{ commands: pinnedContextCommands }], + showRules: true, + }) + + // Verify updateStore was called with the correct parameters + sinon.assert.calledWith(updateStoreSpy, tabId, { + promptTopBarContextItems: [ + { + id: 'pinned-file-1', + command: 'File 1', + label: 'file', + route: ['/workspace', 'src/file1.ts'], + icon: 'file', + children: undefined, + disabled: false, + }, + ], + promptTopBarTitle: '@', + promptTopBarButton: { + id: 'Rules', + status: 'clear', + text: 'Rules', + icon: 'check-list', + }, + }) + }) + + it('should show full title when no pinned context items exist', () => { + const tabId = 'tab-1' + + // Call sendPinnedContext with empty context items + inboundChatApi.sendPinnedContext({ + tabId, + contextCommandGroups: [{ commands: [] }], + showRules: false, + }) + + // Verify updateStore was called with the correct parameters + sinon.assert.calledWith(updateStoreSpy, tabId, { + promptTopBarContextItems: [], + promptTopBarTitle: '@Pin Context', + promptTopBarButton: null, + }) + }) + + it('should handle active editor context item', () => { + const tabId = 'tab-1' + const activeEditorCommand = { + id: 'active-editor', + command: 'Active file', + label: 'file', + icon: 'file', + description: '', + } + + // Call sendPinnedContext with active editor context + inboundChatApi.sendPinnedContext({ + tabId, + contextCommandGroups: [{ commands: [activeEditorCommand] as ContextCommand[] }], + showRules: true, + textDocument: { uri: 'file:///workspace/src/active.ts' }, + }) + + // Verify updateStore was called with the correct parameters + // Active editor description should be updated with the URI + sinon.assert.calledWith(updateStoreSpy, tabId, { + promptTopBarContextItems: [ + { + ...activeEditorCommand, + description: 'file:///workspace/src/active.ts', + children: undefined, + disabled: false, + }, + ], + promptTopBarTitle: '@Pin Context', + promptTopBarButton: { + id: 'Rules', + status: 'clear', + text: 'Rules', + icon: 'check-list', + }, + }) + }) + + it('should remove active editor when no textDocument is provided', () => { + const tabId = 'tab-1' + const activeEditorCommand = { + id: 'active-editor', + command: 'Active file', + label: 'file', + icon: 'file', + } + + const fileCommand = { + id: 'pinned-file-1', + command: 'File 1', + label: 'file', + route: ['/workspace', 'src/file1.ts'], + icon: 'file', + } + + // Call sendPinnedContext with active editor context but no textDocument + inboundChatApi.sendPinnedContext({ + tabId, + contextCommandGroups: [{ commands: [activeEditorCommand, fileCommand] as ContextCommand[] }], + showRules: false, + }) + + // Verify updateStore was called with empty context items + // Active editor should be removed since no textDocument was provided + sinon.assert.calledWith(updateStoreSpy, tabId, { + promptTopBarContextItems: [{ ...fileCommand, children: undefined, disabled: false }], + promptTopBarTitle: '@', + promptTopBarButton: null, + }) + }) + }) + + describe('stringOverrides', () => { + it('should apply string overrides to config texts', () => { + const stringOverrides = { + spinnerText: 'Custom loading message...', + stopGenerating: 'Custom stop text', + showMore: 'Custom show more text', + } + + const messager = new Messager(outboundChatApi) + const tabFactory = new TabFactory({}) + const [customMynahUi] = createMynahUi( + messager, + tabFactory, + true, + true, + undefined, + undefined, + true, + stringOverrides + ) + + // Access the config texts from the instance + const configTexts = (customMynahUi as any).props.config.texts + + // Verify that string overrides were applied and defaults are preserved + strictEqual(configTexts.spinnerText, 'Custom loading message...') + strictEqual(configTexts.stopGenerating, 'Custom stop text') + strictEqual(configTexts.showMore, 'Custom show more text') + strictEqual(configTexts.clickFileToViewDiff, uiComponentsTexts.clickFileToViewDiff) + }) + }) }) describe('withAdapter', () => { @@ -502,6 +795,7 @@ describe('withAdapter', () => { uiReady: sinon.stub(), tabAdded: sinon.stub(), telemetry: sinon.stub(), + onListAvailableModels: sinon.stub(), } as OutboundChatApi) const tabFactory = new TabFactory({}) const mynahUiResult = createMynahUi( diff --git a/chat-client/src/client/mynahUi.ts b/chat-client/src/client/mynahUi.ts index 14a864b60c..90b0e1a8d2 100644 --- a/chat-client/src/client/mynahUi.ts +++ b/chat-client/src/client/mynahUi.ts @@ -26,11 +26,18 @@ import { InfoLinkClickParams, LinkClickParams, ListConversationsResult, + ListRulesResult, ListMcpServersResult, McpServerClickResult, OPEN_WORKSPACE_INDEX_SETTINGS_BUTTON_ID, + OpenFileDialogParams, + OpenFileDialogResult, OpenTabParams, + PinnedContextParams, + RuleClickResult, SourceLinkClickParams, + ListAvailableModelsResult, + ExecuteShellCommandParams, } from '@aws/language-server-runtimes-types' import { ChatItem, @@ -42,11 +49,14 @@ import { MynahUIProps, QuickActionCommand, ChatItemButton, + MynahIcons, + CustomQuickActionCommand, + ConfigTexts, } from '@aws/mynah-ui' import { VoteParams } from '../contracts/telemetry' import { Messager } from './messager' import { McpMynahUi } from './mcpMynahUi' -import { ExportTabBarButtonId, McpServerTabButtonId, TabFactory } from './tabs/tabFactory' +import { ExportTabBarButtonId, ShowLogsTabBarButtonId, McpServerTabButtonId, TabFactory } from './tabs/tabFactory' import { disclaimerAcknowledgeButtonId, disclaimerCard } from './texts/disclaimer' import { ChatClientAdapter, ChatEventHandler } from '../contracts/chatClientAdapter' import { withAdapter } from './withAdapter' @@ -60,7 +70,8 @@ import { } from './utils' import { ChatHistory, ChatHistoryList } from './features/history' import { pairProgrammingModeOff, pairProgrammingModeOn, programmerModeCard } from './texts/pairProgramming' -import { getModelSelectionChatItem } from './texts/modelSelection' +import { ContextRule, RulesList } from './features/rules' +import { getModelSelectionChatItem, modelUnavailableBanner, modelThrottledBanner } from './texts/modelSelection' import { freeTierLimitSticky, upgradeSuccessSticky, @@ -68,6 +79,7 @@ import { plansAndPricingTitle, freeTierLimitDirective, } from './texts/paidTier' +import { isSupportedImageExtension, MAX_IMAGE_CONTEXT, verifyClientImages } from './imageVerification' export interface InboundChatApi { addChatResponse(params: ChatResult, tabId: string, isPartialResult: boolean): void @@ -78,11 +90,17 @@ export interface InboundChatApi { openTab(requestId: string, params: OpenTabParams): void sendContextCommands(params: ContextCommandParams): void listConversations(params: ListConversationsResult): void + executeShellCommandShortCut(params: ExecuteShellCommandParams): void + listRules(params: ListRulesResult): void conversationClicked(params: ConversationClickResult): void + ruleClicked(params: RuleClickResult): void listMcpServers(params: ListMcpServersResult): void mcpServerClick(params: McpServerClickResult): void getSerializedChat(requestId: string, params: GetSerializedChatParams): void createTabId(openTab?: boolean): string | undefined + addSelectedFilesToContext(params: OpenFileDialogParams): void + sendPinnedContext(params: PinnedContextParams): void + listAvailableModels(params: ListAvailableModelsResult): void } type ContextCommandGroups = MynahUIDataModel['contextCommands'] @@ -116,16 +134,32 @@ export const handlePromptInputChange = (mynahUi: MynahUI, tabId: string, options const previousModelSelectionValue = getTabModelSelection(mynahUi, tabId) const currentModelSelectionValue = optionsValues['model-selection'] + const promptInputOptions = mynahUi.getTabData(tabId).getStore()?.promptInputOptions if (currentModelSelectionValue !== previousModelSelectionValue) { - mynahUi.addChatItem(tabId, getModelSelectionChatItem(currentModelSelectionValue)) + const modelSelectionPromptOption = promptInputOptions?.find(({ id }) => id === 'model-selection') + if (modelSelectionPromptOption && modelSelectionPromptOption.type === 'select') { + const selectedModelName = modelSelectionPromptOption.options?.find( + ({ value }) => value === currentModelSelectionValue + )?.label + + mynahUi.addChatItem(tabId, getModelSelectionChatItem(selectedModelName ?? currentModelSelectionValue)) + } } - const promptInputOptions = mynahUi.getTabData(tabId).getStore()?.promptInputOptions + const updatedPromptInputOptions = promptInputOptions?.map(option => { + option.value = optionsValues[option.id] + return option + }) + mynahUi.updateStore(tabId, { - promptInputOptions: promptInputOptions?.map(option => { - option.value = optionsValues[option.id] - return option - }), + promptInputOptions: updatedPromptInputOptions, + }) + + // Store the updated values in tab defaults for new tabs + mynahUi.updateTabDefaults({ + store: { + promptInputOptions: updatedPromptInputOptions, + }, }) } @@ -136,11 +170,44 @@ export const handleChatPrompt = ( messager: Messager, triggerType?: TriggerType, _eventId?: string, - agenticMode?: boolean + agenticMode?: boolean, + tabFactory?: TabFactory ) => { let userPrompt = prompt.escapedPrompt - messager.onStopChatResponse(tabId) - if (prompt.command) { + + // Check if there's an ongoing request + const isLoading = mynahUi.getTabData(tabId)?.getStore()?.loadingChat + + if (isLoading) { + // Stop the current response + messager.onStopChatResponse(tabId) + + // Add cancellation message BEFORE showing the new prompt + mynahUi.addChatItem(tabId, { + type: ChatItemType.DIRECTIVE, + messageId: 'stopped' + Date.now(), + body: 'You stopped your current work and asked me to work on the following task instead.', + }) + + // Reset loading state + mynahUi.updateStore(tabId, { + loadingChat: false, + cancelButtonWhenLoading: true, + promptInputDisabledState: false, + }) + } else { + // If no ongoing request, just send the stop signal + messager.onStopChatResponse(tabId) + } + + const commandsToReroute = ['/dev', '/test', '/doc', '/review'] + + const isReroutedCommand = + agenticMode && tabFactory?.isRerouteEnabled() && prompt.command && commandsToReroute.includes(prompt.command) + + if (prompt.command && !isReroutedCommand && prompt.command !== '/compact') { + // Send /compact quick action as normal regular chat prompt + // Handle non-rerouted commands (/clear, /help, /transform, /review) as quick actions // Temporary solution to handle clear quick actions on the client side if (prompt.command === '/clear') { mynahUi.updateStore(tabId, { @@ -161,12 +228,58 @@ export const handleChatPrompt = ( return } } else { - // Send chat prompt to server - const context = prompt.context?.map(c => (typeof c === 'string' ? { command: c } : c)) - messager.onChatPrompt({ prompt, tabId, context }, triggerType) + // Go agentic chat workflow when: + // 1. Regular prompts without commands + // 2. Rerouted commands (/dev, /test, /doc, /review) when feature flag: reroute is enabled + + // Special handling for /doc command - always send fixed prompt for fixed response + if (isReroutedCommand && prompt.command === '/doc') { + const context = prompt.context?.map(c => (typeof c === 'string' ? { command: c } : c)) + messager.onChatPrompt( + { + prompt: { ...prompt, escapedPrompt: DEFAULT_DOC_PROMPT, prompt: DEFAULT_DOC_PROMPT }, + tabId, + context, + }, + triggerType + ) + } else if (isReroutedCommand && (!userPrompt || userPrompt.trim() === '')) { + // For /dev and /test commands, provide meaningful defaults if no additional text + let defaultPrompt = userPrompt + switch (prompt.command) { + case '/dev': + defaultPrompt = DEFAULT_DEV_PROMPT + break + case '/test': + defaultPrompt = DEFAULT_TEST_PROMPT + break + case '/doc': + defaultPrompt = DEFAULT_DOC_PROMPT + break + case '/review': + defaultPrompt = DEFAULT_REVIEW_PROMPT + break + } + + // Send the updated prompt with default text to server + const context = prompt.context?.map(c => (typeof c === 'string' ? { command: c } : c)) + messager.onChatPrompt( + { + prompt: { ...prompt, escapedPrompt: defaultPrompt, prompt: defaultPrompt }, + tabId, + context, + }, + triggerType + ) + } else { + const context = prompt.context?.map(c => (typeof c === 'string' ? { command: c } : c)) + messager.onChatPrompt({ prompt, tabId, context }, triggerType) + } } - initializeChatResponse(mynahUi, tabId, userPrompt, agenticMode) + // For /doc command, don't show any prompt in UI + const displayPrompt = isReroutedCommand && prompt.command === '/doc' ? '' : userPrompt + initializeChatResponse(mynahUi, tabId, displayPrompt, agenticMode) } const initializeChatResponse = (mynahUi: MynahUI, tabId: string, userPrompt?: string, agenticMode?: boolean) => { @@ -202,7 +315,9 @@ export const createMynahUi = ( pairProgrammingCardAcknowledged: boolean, customChatClientAdapter?: ChatClientAdapter, featureConfig?: Map, - agenticMode?: boolean + agenticMode?: boolean, + stringOverrides?: Partial, + os?: string ): [MynahUI, InboundChatApi] => { let disclaimerCardActive = !disclaimerAcknowledged let programmingModeCardActive = !pairProgrammingCardAcknowledged @@ -252,7 +367,8 @@ export const createMynahUi = ( messager, 'click', eventId, - agenticMode + agenticMode, + tabFactory ) const payload: FollowUpClickParams = { @@ -264,11 +380,12 @@ export const createMynahUi = ( } }, onChatPrompt(tabId, prompt, eventId) { - handleChatPrompt(mynahUi, tabId, prompt, messager, 'click', eventId, agenticMode) + handleChatPrompt(mynahUi, tabId, prompt, messager, 'click', eventId, agenticMode, tabFactory) }, onReady: () => { messager.onUiReady() messager.onTabAdd(tabFactory.initialTabId) + messager.onListAvailableModels({ tabId: tabFactory.initialTabId }) }, onFileClick: (tabId, filePath, deleted, messageId, eventId, fileDetails) => { messager.onFileClick({ tabId, filePath, messageId, fullPath: fileDetails?.data?.['fullPath'] }) @@ -293,6 +410,12 @@ export const createMynahUi = ( } const tabStore = mynahUi.getTabData(tabId).getStore() + const storedPromptInputOptions = mynahUi.getTabDefaults().store?.promptInputOptions + + // Retrieve stored model selection and pair programming mode from defaults + if (storedPromptInputOptions) { + defaultTabConfig.promptInputOptions = storedPromptInputOptions + } // Tabs can be opened through different methods, including server-initiated 'openTab' requests. // The 'openTab' request is specifically used for loading historical chat sessions with pre-existing messages. @@ -302,7 +425,8 @@ export const createMynahUi = ( defaultTabConfig.chatItems = tabFactory.getChatItems(true, programmingModeCardActive, []) } mynahUi.updateStore(tabId, defaultTabConfig) - messager.onTabAdd(tabId) + messager.onTabAdd(tabId, undefined, tabStore?.tabMetadata?.openTabKey === true) + messager.onListAvailableModels({ tabId }) }, onTabRemove: (tabId: string) => { messager.onStopChatResponse(tabId) @@ -343,6 +467,18 @@ export const createMynahUi = ( } messager.onVote(payload) }, + onPromptTopBarItemAdded: (tabId, item, eventId) => { + messager.onAddPinnedContext({ tabId, contextCommandGroups: [{ commands: [item as ContextCommand] }] }) + }, + onPromptTopBarItemRemoved: (tabId, item, eventId) => { + messager.onRemovePinnedContext({ tabId, contextCommandGroups: [{ commands: [item as ContextCommand] }] }) + }, + onPromptTopBarButtonClick(tabId, button, eventId) { + if (button.id === 'Rules') { + rulesList.showLoading(tabId) + messager.onListRules({ tabId }) + } + }, onSendFeedback: (tabId, feedbackPayload, eventId) => { const payload: FeedbackParams = { tabId, @@ -422,6 +558,22 @@ export const createMynahUi = ( } }, onContextSelected: (contextItem, tabId) => { + if (contextItem.command === 'Image') { + const imageContext = getImageContextCount(tabId) + if (imageContext >= MAX_IMAGE_CONTEXT) { + mynahUi.notify({ + content: `A maximum of ${MAX_IMAGE_CONTEXT} images can be added to a single message.`, + type: NotificationType.WARNING, + }) + return false + } + const payload: OpenFileDialogParams = { + tabId, + fileType: contextItem.command.toLowerCase() as 'image' | '', + } + messager.onOpenFileDialogClick(payload) + return false + } if (contextItem.id === ContextPrompt.CreateItemId) { mynahUi.showCustomForm( tabId, @@ -442,6 +594,7 @@ export const createMynahUi = ( }, ], }, + validateOnChange: true, description: "Use this prompt by typing '@' followed by the prompt name.", }, ], @@ -467,7 +620,12 @@ export const createMynahUi = ( }, onCustomFormAction: (tabId, action) => { if (action.id === ContextPrompt.SubmitButtonId) { - messager.onCreatePrompt(action.formItemValues![ContextPrompt.PromptNameFieldId]) + messager.onCreatePrompt({ promptName: action.formItemValues![ContextPrompt.PromptNameFieldId] }) + } else if (action.id === ContextRule.SubmitButtonId) { + messager.onCreatePrompt({ + promptName: action.formItemValues![ContextRule.RuleNameFieldId], + isRule: true, + }) } }, onFormTextualItemKeyPress: ( @@ -477,10 +635,16 @@ export const createMynahUi = ( _tabId: string, _eventId?: string ) => { - if (itemId === ContextPrompt.PromptNameFieldId && event.key === 'Enter') { - event.preventDefault() - messager.onCreatePrompt(formData[ContextPrompt.PromptNameFieldId]) - return true + if (event.key === 'Enter') { + if (itemId === ContextPrompt.PromptNameFieldId) { + event.preventDefault() + messager.onCreatePrompt({ promptName: formData[ContextPrompt.PromptNameFieldId] }) + return true + } else if (itemId === ContextRule.RuleNameFieldId) { + event.preventDefault() + messager.onCreatePrompt({ promptName: formData[ContextRule.RuleNameFieldId], isRule: true }) + return true + } } return false }, @@ -495,6 +659,14 @@ export const createMynahUi = ( return } + if (buttonId === ShowLogsTabBarButtonId) { + messager.onTabBarAction({ + tabId, + action: 'show_logs', + }) + return + } + if (buttonId === ExportTabBarButtonId) { messager.onTabBarAction({ tabId, @@ -533,7 +705,95 @@ export const createMynahUi = ( } }, onStopChatResponse: tabId => { - messager.onStopChatResponse(tabId) + handleUIStopChatResponse(messager, mynahUi, tabId) + }, + onOpenFileDialogClick: (tabId, fileType, insertPosition) => { + const imageContext = getImageContextCount(tabId) + if (imageContext >= MAX_IMAGE_CONTEXT) { + mynahUi.notify({ + content: `A maximum of ${MAX_IMAGE_CONTEXT} images can be added to a single message.`, + type: NotificationType.WARNING, + }) + return + } + const payload: OpenFileDialogParams = { + tabId, + fileType: fileType as 'image' | '', + insertPosition, + } + messager.onOpenFileDialogClick(payload) + }, + onFilesDropped: async (tabId: string, files: FileList, insertPosition: number) => { + const imageContextCount = getImageContextCount(tabId) + if (imageContextCount >= MAX_IMAGE_CONTEXT) { + mynahUi.notify({ + content: `A maximum of ${MAX_IMAGE_CONTEXT} images can be added to a single message.`, + type: NotificationType.WARNING, + }) + return + } + // Verify dropped files and add valid ones to context + const { validFiles, errors } = await verifyClientImages(files) + if (validFiles.length > 0) { + // Calculate how many files we can actually add + const availableSlots = MAX_IMAGE_CONTEXT - imageContextCount + const filesToAdd = validFiles.slice(0, availableSlots) + const filesExceeded = validFiles.length - availableSlots + + // Add error message if we exceed the limit + if (filesExceeded > 0) { + errors.push(`A maximum of ${MAX_IMAGE_CONTEXT} images can be added to a single message.`) + } + + const commands: CustomQuickActionCommand[] = await Promise.all( + filesToAdd.map(async (file: File) => { + const fileName = file.name || 'Unknown file' + const filePath = file.name || '' + + // Determine file type and appropriate icon + const fileExtension = filePath.split('.').pop()?.toLowerCase() || '' + const isImage = isSupportedImageExtension(fileExtension) + + let icon = MynahIcons.FILE + if (isImage) { + icon = MynahIcons.IMAGE + } + + const arrayBuffer = await file.arrayBuffer() + const bytes = new Uint8Array(arrayBuffer) + + return { + command: fileName, + description: filePath, + route: [filePath], + label: 'image', + icon: icon, + content: bytes, + id: fileName, + } + }) + ) + + // Add valid files to context commands + mynahUi.addCustomContextToPrompt(tabId, commands, insertPosition) + } + + if (errors.length > 0) { + const imageVerificationBanner: Partial = { + messageId: 'image-verification-banner', + header: { + icon: 'warning', + iconStatus: 'warning', + body: '### Invalid Image', + }, + body: `${errors.join('\n')}`, + canBeDismissed: true, + } + + mynahUi.updateStore(tabId, { + promptInputStickyCard: imageVerificationBanner, + }) + } }, } @@ -552,11 +812,16 @@ export const createMynahUi = ( }, config: { maxTabs: 10, + test: true, + dragOverlayIcon: MynahIcons.IMAGE, texts: { ...uiComponentsTexts, + dragOverlayText: 'Add image to context', // Fallback to original texts in non-agentic chat mode stopGenerating: agenticMode ? uiComponentsTexts.stopGenerating : 'Stop generating', + stopGeneratingTooltip: getStopGeneratingToolTipText(os, agenticMode), spinnerText: agenticMode ? uiComponentsTexts.spinnerText : 'Generating your answer...', + ...stringOverrides, }, // Total model context window limit 600k. // 500k for user input, 100k for context, history, system prompt. @@ -564,13 +829,14 @@ export const createMynahUi = ( // if we want to max user input as 500000, need to configure the maxUserInput as 500096 maxUserInput: 500096, userInputLengthWarningThreshold: 450000, + disableTypewriterAnimation: true, }, } const mynahUiRef = { mynahUI: undefined as MynahUI | undefined } if (customChatClientAdapter) { // Attach routing to custom adapter top of default message handlers - chatEventHandlers = withAdapter(chatEventHandlers, mynahUiRef, customChatClientAdapter) + chatEventHandlers = withAdapter(chatEventHandlers, mynahUiRef, customChatClientAdapter, tabFactory) } const mynahUi = new MynahUI({ @@ -645,6 +911,20 @@ export const createMynahUi = ( } } + const getImageContextCount = (tabId: string) => { + const imageContextInPrompt = + mynahUi + .getTabData(tabId) + ?.getStore() + ?.customContextCommand?.filter(cm => cm.label === 'image').length || 0 + const imageContextInPin = + mynahUi + .getTabData(tabId) + ?.getStore() + ?.promptTopBarContextItems?.filter(cm => cm.label === 'image').length || 0 + return imageContextInPrompt + imageContextInPin + } + const addChatResponse = (chatResult: ChatResult, tabId: string, isPartialResult: boolean) => { if (agenticMode) { agenticAddChatResponse(chatResult, tabId, isPartialResult) @@ -877,7 +1157,7 @@ export const createMynahUi = ( return false // invalid mode } - tabId = !!tabId ? tabId : getOrCreateTabId()! + tabId = tabId ? tabId : getOrCreateTabId()! const store = mynahUi.getTabData(tabId).getStore() || {} // Detect if the tab is already showing the "Upgrade Q" UI. @@ -979,6 +1259,20 @@ export const createMynahUi = ( return } + if (updatedMessage.messageId === 'modelUnavailable') { + mynahUi.updateStore(tabId, { + promptInputStickyCard: modelUnavailableBanner, + }) + return + } + + if (updatedMessage.messageId === 'modelThrottled') { + mynahUi.updateStore(tabId, { + promptInputStickyCard: modelThrottledBanner, + }) + return + } + const oldMessage = chatItems.find(ci => ci.messageId === updatedMessage.messageId) if (!oldMessage) return @@ -995,11 +1289,9 @@ export const createMynahUi = ( * Creates a properly formatted chat item for MCP tool summary with accordion view */ const createMcpToolSummaryItem = (message: ChatMessage, isPartialResult?: boolean): Partial => { - const muted = message.summary?.content?.header?.status !== undefined return { type: ChatItemType.ANSWER, messageId: message.messageId, - muted, summary: { content: message.summary?.content ? { @@ -1028,7 +1320,7 @@ export const createMynahUi = ( : undefined, fullWidth: true, padding: false, - muted: true, + muted: false, wrapCodes: item.header?.body === 'Parameters' ? true : false, codeBlockActions: { copy: null, 'insert-to-cursor': null }, })) || [], @@ -1064,10 +1356,15 @@ export const createMynahUi = ( fileTreeTitle: '', hideFileCount: true, details: toDetailsWithoutIcon(header.fileList.details), + renderAsPills: + !header.fileList.details || + (Object.values(header.fileList.details).every(detail => !detail.changes) && + (!header.buttons || !header.buttons.some(button => button.id === 'undo-changes')) && + !header.status?.icon), } } if (!isPartialResult) { - if (processedHeader && processedHeader.status?.status !== 'error') { + if (processedHeader && !message.header?.status) { processedHeader.status = undefined } } @@ -1080,7 +1377,8 @@ export const createMynahUi = ( processedHeader.buttons !== null && processedHeader.buttons.length > 0) || processedHeader.status !== undefined || - processedHeader.icon !== undefined) + processedHeader.icon !== undefined || + processedHeader.fileList !== undefined) const padding = message.type === 'tool' ? (fileList ? true : message.messageId?.endsWith('_permission')) : undefined @@ -1091,8 +1389,10 @@ export const createMynahUi = ( // Adding this conditional check to show the stop message in the center. const contentHorizontalAlignment: ChatItem['contentHorizontalAlignment'] = undefined - // If message.header?.status?.text is Stopped or Rejected or Ignored or Completed etc.. card should be in disabled state. - const shouldMute = message.header?.status?.text !== undefined && message.header?.status?.text !== 'Completed' + // If message.header?.status?.text is Stopped or Rejected or Ignored etc.. card should be in disabled state. + const shouldMute = + message.header?.status?.text !== undefined && + ['Stopped', 'Rejected', 'Ignored', 'Failed', 'Error'].includes(message.header.status.text) return { body: message.body, @@ -1100,7 +1400,7 @@ export const createMynahUi = ( buttons: processedButtons, fileList, // file diffs in the header need space - fullWidth: message.type === 'tool' && message.header?.buttons ? true : undefined, + fullWidth: message.type === 'tool' && includeHeader ? true : undefined, padding, contentHorizontalAlignment, wrapCodes: message.type === 'tool', @@ -1117,7 +1417,8 @@ export const createMynahUi = ( const sendToPrompt = (params: SendToPromptParams) => { const tabId = getOrCreateTabId() if (!tabId) return - + chatHistoryList.close() + mcpMynahUi.close() if (params.autoSubmit && params.prompt) { messager.onChatPrompt({ prompt: params.prompt, tabId, context: undefined }, 'contextMenu') initializeChatResponse(mynahUi, tabId, params.prompt.prompt, agenticMode) @@ -1131,23 +1432,34 @@ export const createMynahUi = ( let tabId = getOrCreateTabId() if (!tabId) return - + chatHistoryList.close() + mcpMynahUi.close() // send to a new tab if the current tab is loading if (getTabStore(tabId)?.loadingChat) { tabId = createTabId() if (!tabId) return } + let body = '' + let chatPrompt: ChatPrompt + const genericCommandString = params.genericCommand as string + if (genericCommandString.includes('Review')) { + chatPrompt = { command: '/review' } + if (!tabFactory?.isCodeReviewInChatEnabled()) { + customChatClientAdapter?.handleQuickAction(chatPrompt, tabId, '') + return + } + } else { + body = [ + genericCommandString, + ' the following part of my code:', + '\n~~~~\n', + params.selection, + '\n~~~~\n', + ].join('') + chatPrompt = { prompt: body, escapedPrompt: body } + } - const body = [ - params.genericCommand, - ' the following part of my code:', - '\n~~~~\n', - params.selection, - '\n~~~~\n', - ].join('') - const chatPrompt: ChatPrompt = { prompt: body, escapedPrompt: body } - - handleChatPrompt(mynahUi, tabId, chatPrompt, messager, params.triggerType, undefined, agenticMode) + handleChatPrompt(mynahUi, tabId, chatPrompt, messager, params.triggerType, undefined, agenticMode, tabFactory) } const showError = (params: ErrorParams) => { @@ -1156,7 +1468,7 @@ export const createMynahUi = ( const answer: ChatItem = { type: ChatItemType.ANSWER, - body: `**${params.title}** + body: `**${params.title}** ${params.message}`, } @@ -1170,6 +1482,55 @@ ${params.message}`, messager.onError(params) } + const executeShellCommandShortCut = (params: ExecuteShellCommandParams) => { + const activeElement = document.activeElement as HTMLElement + + const tabId = mynahUi.getSelectedTabId() + if (!tabId) return + + const chatItems = mynahUi.getTabData(tabId)?.getStore()?.chatItems || [] + const buttonId = params.id + + let messageId + for (const item of chatItems) { + if (buttonId === 'stop-shell-command' && item.buttons && item.buttons.some(b => b.id === buttonId)) { + messageId = item.messageId + break + } + if (item.header?.buttons && item.header.buttons.some(b => b.id === buttonId)) { + messageId = item.messageId + break + } + } + + if (messageId) { + const payload: ButtonClickParams = { + tabId, + messageId, + buttonId, + } + messager.onButtonClick(payload) + if (buttonId === 'stop-shell-command') { + handleUIStopChatResponse(messager, mynahUi, tabId) + } + } else { + // handle global stop + const isLoading = mynahUi.getTabData(tabId)?.getStore()?.loadingChat + if (isLoading && buttonId === 'stop-shell-command') { + handleUIStopChatResponse(messager, mynahUi, tabId) + } + } + // this is a short-term solution to re-gain focus after executing a shortcut + // current behavior will emit exitFocus telemetry immediadately. + // use this to re-gain focus, so that user can use shortcut after shortcut + // without manually re-gain focus. + setTimeout(() => { + if (activeElement && activeElement.focus) { + activeElement.focus() + } + }, 100) + } + const openTab = (requestId: string, params: OpenTabParams) => { if (params.tabId) { if (params.tabId !== mynahUi.getSelectedTabId()) { @@ -1201,9 +1562,37 @@ ${params.message}`, commands: toContextCommands(child.commands), })), icon: toMynahIcon(command.icon), + disabled: command.disabledText != null, })) } + const sendPinnedContext = (params: PinnedContextParams) => { + const pinnedContext = toContextCommands(params.contextCommandGroups[0]?.commands || []) + let activeEditor = pinnedContext[0]?.id === ACTIVE_EDITOR_CONTEXT_ID + // Update Active File pill description with active editor URI passed from IDE + if (activeEditor) { + if (params.textDocument != null) { + pinnedContext[0].description = params.textDocument.uri + } else { + // IDE did not pass in active file, remove it from pinned context + pinnedContext.shift() + activeEditor = false + } + } + let promptTopBarTitle = '@' + // Show full `@Pin Context` title until user adds a pinned context item + if (pinnedContext.length == 0 || (activeEditor && pinnedContext.length === 1)) { + promptTopBarTitle = '@Pin Context' + } + mynahUi.updateStore(params.tabId, { + promptTopBarContextItems: pinnedContext, + promptTopBarTitle, + promptTopBarButton: params.showRules + ? { id: 'Rules', status: 'clear', text: 'Rules', icon: 'check-list' } + : null, + }) + } + const sendContextCommands = (params: ContextCommandParams) => { contextCommandGroups = params.contextCommandGroups.map(group => ({ ...group, @@ -1227,11 +1616,54 @@ ${params.message}`, }) } + const addSelectedFilesToContext = (params: OpenFileDialogResult) => { + if (params.errorMessage) { + mynahUi.notify({ + content: params.errorMessage, + type: NotificationType.ERROR, + }) + return + } + const commands: QuickActionCommand[] = [] + for (const filePath of params.filePaths) { + const fileName = filePath.split(/[\\/]/).pop() || filePath + if (params.fileType === 'image') { + commands.push({ + command: fileName, + description: filePath, + label: 'image', + route: [filePath], + icon: MynahIcons.IMAGE, + id: fileName, + }) + } + } + + mynahUi.addCustomContextToPrompt(params.tabId, commands, params.insertPosition) + } + const chatHistoryList = new ChatHistoryList(mynahUi, messager) const listConversations = (params: ListConversationsResult) => { chatHistoryList.show(params) } + const rulesList = new RulesList(mynahUi, messager) + + const listRules = (params: ListRulesResult) => { + rulesList.show(params) + } + + const ruleClicked = (params: RuleClickResult) => { + if (!params.success) { + mynahUi.notify({ + content: `Failed to toggle the workspace rule`, + type: NotificationType.ERROR, + }) + return + } + messager.onListRules({ tabId: params.tabId }) + } + const conversationClicked = (params: ConversationClickResult) => { if (!params.success) { mynahUi.notify({ @@ -1299,6 +1731,27 @@ ${params.message}`, } } + const listAvailableModels = (params: ListAvailableModelsResult) => { + const tabId = params.tabId + const promptInputOptions = mynahUi.getTabData(tabId).getStore()?.promptInputOptions + mynahUi.updateStore(tabId, { + promptInputOptions: promptInputOptions?.map(option => + option.id === 'model-selection' + ? { + ...option, + type: 'select', + options: params.models.map(model => ({ + value: model.id, + label: model.name, + description: model.description ?? '', + })), + value: params.selectedModelId, + } + : option + ), + }) + } + const api = { addChatResponse: addChatResponse, updateChat: updateChat, @@ -1307,19 +1760,36 @@ ${params.message}`, showError: showError, openTab: openTab, sendContextCommands: sendContextCommands, + sendPinnedContext: sendPinnedContext, + executeShellCommandShortCut: executeShellCommandShortCut, listConversations: listConversations, + listRules: listRules, conversationClicked: conversationClicked, listMcpServers: listMcpServers, mcpServerClick: mcpServerClick, getSerializedChat: getSerializedChat, createTabId: createTabId, + ruleClicked: ruleClicked, + listAvailableModels: listAvailableModels, + addSelectedFilesToContext: addSelectedFilesToContext, } return [mynahUi, api] } +const ACTIVE_EDITOR_CONTEXT_ID = 'active-editor' + export const DEFAULT_HELP_PROMPT = 'What can Amazon Q help me with?' -const uiComponentsTexts = { + +const DEFAULT_DOC_PROMPT = `You are Amazon Q. Start with a warm greeting, then ask the user to specify what kind of documentation they need. Present common documentation types (like API docs, README, user guides, developer guides, or configuration guides) as clear options. Keep the question brief and friendly. Don't make assumptions about existing content or context. Wait for their response before providing specific guidance.` + +const DEFAULT_TEST_PROMPT = `You are Amazon Q. Start with a warm greeting, then help me generate unit tests` + +const DEFAULT_DEV_PROMPT = `You are Amazon Q. Start with a warm greeting, then ask the user to specify what kind of help they need in code development. Present common questions asked (like Creating a new project, Adding a new feature, Modifying your files). Keep the question brief and friendly. Don't make assumptions about existing content or context. Wait for their response before providing specific guidance.` + +const DEFAULT_REVIEW_PROMPT = `You are Amazon Q. Start with a warm greeting, then use code review tool to perform a diff review code analysis of the open file. If there is no open file, ask what the user would like to review. Please tell the user that the scan is a diff scan.` + +export const uiComponentsTexts = { mainTitle: 'Amazon Q (Preview)', copy: 'Copy', insertAtCursorLabel: 'Insert at cursor', @@ -1339,5 +1809,38 @@ const uiComponentsTexts = { copyToClipboard: 'Copied to clipboard', noMoreTabsTooltip: 'You can only open ten conversation tabs at a time.', codeSuggestionWithReferenceTitle: 'Some suggestions contain code with references.', - spinnerText: 'Thinking...', + spinnerText: 'Working...', + macStopButtonShortcut: '⇧ ⌘ ⌫', + windowStopButtonShortcut: 'Ctrl + ⇧ + ⌫', +} + +const getStopGeneratingToolTipText = (os: string | undefined, agenticMode: boolean | undefined): string => { + if (agenticMode && os) { + return os === 'darwin' + ? `Stop: ${uiComponentsTexts.macStopButtonShortcut}` + : `Stop: ${uiComponentsTexts.windowStopButtonShortcut}` + } + + return agenticMode ? uiComponentsTexts.stopGenerating : 'Stop generating' +} + +const handleUIStopChatResponse = (messenger: Messager, mynahUi: MynahUI, tabId: string) => { + messenger.onStopChatResponse(tabId) + + // Reset loading state + mynahUi.updateStore(tabId, { + loadingChat: false, + cancelButtonWhenLoading: true, + promptInputDisabledState: false, + }) + + // Add a small delay before adding the chat item + setTimeout(() => { + // Add cancellation message when stop button is clicked + mynahUi.addChatItem(tabId, { + type: ChatItemType.DIRECTIVE, + messageId: 'stopped' + Date.now(), + body: 'You stopped your current work, please provide additional examples or ask another question.', + }) + }, 500) // 500ms delay } diff --git a/chat-client/src/client/tabs/tabFactory.ts b/chat-client/src/client/tabs/tabFactory.ts index e45ad00e0f..bfb3091911 100644 --- a/chat-client/src/client/tabs/tabFactory.ts +++ b/chat-client/src/client/tabs/tabFactory.ts @@ -18,12 +18,17 @@ export const ExportTabBarButtonId = 'export' export const McpServerTabButtonId = 'mcp_init' +export const ShowLogsTabBarButtonId = 'show_logs' + export class TabFactory { private history: boolean = false private export: boolean = false private agenticMode: boolean = false private mcp: boolean = false private modelSelectionEnabled: boolean = false + private reroute: boolean = false + private codeReviewInChat: boolean = false + private showLogs: boolean = false initialTabId: string public static generateUniqueId() { @@ -65,18 +70,19 @@ export class TabFactory { ...(this.agenticMode && pairProgrammingCardActive ? [programmerModeCard] : []), { type: ChatItemType.ANSWER, - body: `Hi, I'm Amazon Q. I can answer your software development questions. - Ask me to explain, debug, or optimize your code. - You can enter \`/\` to see a list of quick actions.`, + body: `
+ +
Amazon Q
+
+
Did you know?
+
${this.getRandomTip()}
+
+ +Select code & ask me to explain, debug or optimize it, or type \`/\` for quick actions + +
`, + canBeVoted: false, }, - ...(!this.agenticMode - ? [ - { - type: ChatItemType.ANSWER, - followUp: this.getWelcomeBlock(), - }, - ] - : []), ] : chatMessages ? (chatMessages as ChatItem[]) @@ -96,6 +102,10 @@ export class TabFactory { this.export = true } + public enableShowLogs() { + this.showLogs = true + } + public enableAgenticMode() { this.agenticMode = true } @@ -108,10 +118,30 @@ export class TabFactory { this.modelSelectionEnabled = true } + public enableReroute() { + this.reroute = true + } + + public enableCodeReviewInChat() { + this.codeReviewInChat = true + } + + public isRerouteEnabled(): boolean { + return this.reroute + } + + public isCodeReviewInChatEnabled(): boolean { + return this.codeReviewInChat + } + public getDefaultTabData(): DefaultTabData { const tabData = { ...this.defaultTabData, - ...(this.quickActionCommands ? { quickActionCommands: this.quickActionCommands } : {}), + ...(this.quickActionCommands + ? { + quickActionCommands: this.quickActionCommands, + } + : {}), } tabData.tabBarButtons = this.getTabBarButtons() @@ -136,6 +166,20 @@ export class TabFactory { return undefined } + private getRandomTip(): string { + const hints = [ + 'You can now see logs with 1-Click!', + 'MCP is available in Amazon Q!', + 'Pinned context is always included in future chat messages', + 'Create and add Saved Prompts using the @ context menu', + 'Compact your conversation with /compact', + 'Ask Q to review your code and see results in the code issues panel!', + ] + + const randomIndex = Math.floor(Math.random() * hints.length) + return hints[randomIndex] + } + private getTabBarButtons(): TabBarMainAction[] | undefined { const tabBarButtons = [...(this.defaultTabData.tabBarButtons ?? [])] @@ -163,24 +207,14 @@ export class TabFactory { }) } - return tabBarButtons.length ? tabBarButtons : undefined - } - - // Legacy welcome messages block - private getWelcomeBlock() { - return { - text: 'Try Examples:', - options: [ - { - pillText: 'Explain selected code', - prompt: 'Explain selected code', - type: 'init-prompt', - }, - { - pillText: 'How can Amazon Q help me?', - type: 'help', - }, - ], + if (this.showLogs) { + tabBarButtons.push({ + id: ShowLogsTabBarButtonId, + icon: MynahIcons.FILE, + description: 'Show logs', + }) } + + return tabBarButtons.length ? tabBarButtons : undefined } } diff --git a/chat-client/src/client/texts/modelSelection.test.ts b/chat-client/src/client/texts/modelSelection.test.ts new file mode 100644 index 0000000000..abd010436e --- /dev/null +++ b/chat-client/src/client/texts/modelSelection.test.ts @@ -0,0 +1,44 @@ +import * as assert from 'assert' +import { getModelSelectionChatItem, modelUnavailableBanner, modelThrottledBanner } from './modelSelection' +import { ChatItemType } from '@aws/mynah-ui' + +/** + * Tests for modelSelection functionality + */ +describe('modelSelection', () => { + describe('getModelSelectionChatItem', () => { + it('should return a chat item with the correct model name', () => { + const modelName = 'Claude Sonnet 4' + const chatItem = getModelSelectionChatItem(modelName) + + assert.strictEqual(chatItem.type, ChatItemType.DIRECTIVE) + assert.strictEqual(chatItem.contentHorizontalAlignment, 'center') + assert.strictEqual(chatItem.fullWidth, true) + assert.strictEqual(chatItem.body, `Switched model to ${modelName}`) + }) + }) + + describe('modelUnavailableBanner', () => { + it('should have the correct properties', () => { + assert.strictEqual(modelUnavailableBanner.messageId, 'model-unavailable-banner') + assert.ok(modelUnavailableBanner.header, 'header should exist') + assert.strictEqual(modelUnavailableBanner.header?.icon, 'warning') + assert.strictEqual(modelUnavailableBanner.header?.iconStatus, 'warning') + assert.strictEqual(modelUnavailableBanner.header?.body, '### Model Unavailable') + assert.ok(modelUnavailableBanner.body?.includes("The model you've selected is experiencing high load")) + assert.strictEqual(modelUnavailableBanner.canBeDismissed, true) + }) + }) + + describe('modelThrottledBanner', () => { + it('should have the correct properties', () => { + assert.strictEqual(modelThrottledBanner.messageId, 'model-throttled-banner') + assert.ok(modelThrottledBanner.header, 'header should exist') + assert.strictEqual(modelThrottledBanner.header?.icon, 'warning') + assert.strictEqual(modelThrottledBanner.header?.iconStatus, 'warning') + assert.strictEqual(modelThrottledBanner.header?.body, '### Model Unavailable') + assert.ok(modelThrottledBanner.body?.includes('I am experiencing high traffic')) + assert.strictEqual(modelThrottledBanner.canBeDismissed, true) + }) + }) +}) diff --git a/chat-client/src/client/texts/modelSelection.ts b/chat-client/src/client/texts/modelSelection.ts index 2e7c747759..6cfd25b7fe 100644 --- a/chat-client/src/client/texts/modelSelection.ts +++ b/chat-client/src/client/texts/modelSelection.ts @@ -1,37 +1,65 @@ import { ChatItem, ChatItemFormItem, ChatItemType } from '@aws/mynah-ui' +/** + * @deprecated use aws/chat/listAvailableModels server request instead + */ export enum BedrockModel { - CLAUDE_3_7_SONNET_20250219_V1_0 = 'CLAUDE_3_7_SONNET_20250219_V1_0', - CLAUDE_3_5_SONNET_20241022_V2_0 = 'CLAUDE_3_5_SONNET_20241022_V2_0', + CLAUDE_SONNET_4_20250514_V1_0 = 'CLAUDE_SONNET_4_20250514_V1_0', } type ModelDetails = { label: string + description: string } const modelRecord: Record = { - [BedrockModel.CLAUDE_3_5_SONNET_20241022_V2_0]: { label: 'Claude Sonnet 3.5' }, - [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude Sonnet 3.7' }, + [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { + label: 'Claude Sonnet 4', + description: 'Hybrid reasoning and coding for regular use', + }, } -const modelOptions = Object.entries(modelRecord).map(([value, { label }]) => ({ +const modelOptions = Object.entries(modelRecord).map(([value, { label, description }]) => ({ value, label, + description, })) export const modelSelection: ChatItemFormItem = { type: 'select', id: 'model-selection', - options: modelOptions, mandatory: true, hideMandatoryIcon: true, + options: modelOptions, border: false, autoWidth: true, } -export const getModelSelectionChatItem = (modelId: string): ChatItem => ({ +export const getModelSelectionChatItem = (modelName: string): ChatItem => ({ type: ChatItemType.DIRECTIVE, contentHorizontalAlignment: 'center', fullWidth: true, - body: `Switched model to ${modelRecord[modelId as BedrockModel].label}`, + body: `Switched model to ${modelName}`, }) + +export const modelUnavailableBanner: Partial = { + messageId: 'model-unavailable-banner', + header: { + icon: 'warning', + iconStatus: 'warning', + body: '### Model Unavailable', + }, + body: `The model you've selected is experiencing high load. Please switch to another model and try again.`, + canBeDismissed: true, +} + +export const modelThrottledBanner: Partial = { + messageId: 'model-throttled-banner', + header: { + icon: 'warning', + iconStatus: 'warning', + body: '### Model Unavailable', + }, + body: `I am experiencing high traffic, please try again shortly.`, + canBeDismissed: true, +} diff --git a/chat-client/src/client/texts/pairProgramming.test.ts b/chat-client/src/client/texts/pairProgramming.test.ts new file mode 100644 index 0000000000..8181f50c8b --- /dev/null +++ b/chat-client/src/client/texts/pairProgramming.test.ts @@ -0,0 +1,55 @@ +import * as assert from 'assert' +import { ChatItemType } from '@aws/mynah-ui' +import { + programmerModeCard, + pairProgrammingPromptInput, + pairProgrammingModeOn, + pairProgrammingModeOff, +} from './pairProgramming' + +describe('pairProgramming', () => { + describe('programmerModeCard', () => { + it('has correct properties', () => { + assert.equal(programmerModeCard.type, ChatItemType.ANSWER) + assert.equal(programmerModeCard.title, 'NEW FEATURE') + assert.equal(programmerModeCard.messageId, 'programmerModeCardId') + assert.equal(programmerModeCard.fullWidth, true) + assert.equal(programmerModeCard.canBeDismissed, true) + assert.ok(programmerModeCard.body?.includes('Amazon Q can now help')) + assert.equal(programmerModeCard.header?.icon, 'code-block') + assert.equal(programmerModeCard.header?.iconStatus, 'primary') + }) + }) + + describe('pairProgrammingPromptInput', () => { + it('has correct properties', () => { + assert.equal(pairProgrammingPromptInput.type, 'switch') + assert.equal(pairProgrammingPromptInput.id, 'pair-programmer-mode') + assert.equal(pairProgrammingPromptInput.tooltip, 'Turn OFF agentic coding') + if (pairProgrammingPromptInput.type === 'switch') { + // Type guard for switch type + assert.equal(pairProgrammingPromptInput.alternateTooltip, 'Turn ON agentic coding') + } + assert.equal(pairProgrammingPromptInput.value, 'true') + assert.equal(pairProgrammingPromptInput.icon, 'code-block') + }) + }) + + describe('pairProgrammingModeOn', () => { + it('has correct properties', () => { + assert.equal(pairProgrammingModeOn.type, ChatItemType.DIRECTIVE) + assert.equal(pairProgrammingModeOn.contentHorizontalAlignment, 'center') + assert.equal(pairProgrammingModeOn.fullWidth, true) + assert.equal(pairProgrammingModeOn.body, 'Agentic coding - ON') + }) + }) + + describe('pairProgrammingModeOff', () => { + it('has correct properties', () => { + assert.equal(pairProgrammingModeOff.type, ChatItemType.DIRECTIVE) + assert.equal(pairProgrammingModeOff.contentHorizontalAlignment, 'center') + assert.equal(pairProgrammingModeOff.fullWidth, true) + assert.equal(pairProgrammingModeOff.body, 'Agentic coding - OFF') + }) + }) +}) diff --git a/chat-client/src/client/utils.test.ts b/chat-client/src/client/utils.test.ts new file mode 100644 index 0000000000..38e64404a0 --- /dev/null +++ b/chat-client/src/client/utils.test.ts @@ -0,0 +1,251 @@ +import * as assert from 'assert' +import { MynahIcons } from '@aws/mynah-ui' +import { Button, ChatMessage } from '@aws/language-server-runtimes-types' +import { FeatureContext } from '@aws/chat-client-ui-types' +import { + toMynahIcon, + toMynahButtons, + toMynahHeader, + toMynahFileList, + toDetailsWithoutIcon, + toMynahContextCommand, +} from './utils' + +describe('utils', () => { + describe('toMynahIcon', () => { + it('returns valid MynahIcon when icon exists', () => { + const result = toMynahIcon(MynahIcons.CHAT) + assert.equal(result, MynahIcons.CHAT) + }) + + it('returns undefined for invalid icon', () => { + const result = toMynahIcon('invalid-icon') + assert.equal(result, undefined) + }) + + it('returns undefined for undefined input', () => { + const result = toMynahIcon(undefined) + assert.equal(result, undefined) + }) + }) + + describe('toMynahButtons', () => { + it('converts buttons with valid icons', () => { + const buttons: Button[] = [ + { id: 'btn1', text: 'Button 1', icon: MynahIcons.CHAT }, + { id: 'btn2', text: 'Button 2', icon: 'invalid-icon' }, + ] + + const result = toMynahButtons(buttons) + assert.equal(result?.length, 2) + assert.equal(result?.[0].icon, MynahIcons.CHAT) + assert.equal(result?.[1].icon, undefined) + }) + + it('returns undefined for undefined input', () => { + const result = toMynahButtons(undefined) + assert.equal(result, undefined) + }) + + it('handles empty array', () => { + const result = toMynahButtons([]) + assert.deepEqual(result, []) + }) + }) + + describe('toMynahHeader', () => { + it('converts header with all properties', () => { + const header: ChatMessage['header'] = { + icon: MynahIcons.CHAT, + buttons: [{ id: 'btn1', text: 'Button', icon: MynahIcons.OK }], + status: { text: 'Status', icon: MynahIcons.WARNING }, + summary: { + content: { + body: 'Test summary', + }, + }, + } + + const result = toMynahHeader(header) + assert.equal(result?.icon, MynahIcons.CHAT) + assert.equal(result?.buttons?.length, 1) + assert.equal(result?.status?.text, 'Status') + assert.equal(result?.status?.icon, MynahIcons.WARNING) + assert.equal(result?.summary?.content?.body, 'Test summary') + }) + + it('handles header without status', () => { + const header: ChatMessage['header'] = { + icon: MynahIcons.CHAT, + } + + const result = toMynahHeader(header) + assert.equal(result?.status, undefined) + }) + + it('returns undefined for undefined header', () => { + const result = toMynahHeader(undefined) + assert.equal(result, undefined) + }) + + it('handles header with invalid icons', () => { + const header: ChatMessage['header'] = { + icon: 'invalid-icon', + status: { text: 'Status', icon: 'invalid-status-icon' }, + } + + const result = toMynahHeader(header) + assert.equal(result?.icon, undefined) + assert.equal(result?.status?.icon, undefined) + }) + }) + + describe('toMynahFileList', () => { + it('converts file list with all properties', () => { + const fileList: ChatMessage['fileList'] = { + filePaths: ['src/file1.ts', 'src/file2.ts'], + rootFolderTitle: 'Project Root', + details: { + 'src/file1.ts': { + lineRanges: [{ first: 1, second: 10 }], + description: 'First file', + fullPath: '/full/path/src/file1.ts', + }, + 'src/file2.ts': { + lineRanges: [{ first: -1, second: -1 }], + description: 'Second file', + }, + }, + } + + const result = toMynahFileList(fileList) + assert.equal(result?.rootFolderTitle, 'Project Root') + assert.equal(result?.filePaths?.length, 2) + assert.equal(result?.flatList, true) + assert.equal(result?.hideFileCount, true) + assert.equal(result?.collapsed, true) + assert.equal(result?.details?.['src/file1.ts']?.label, 'line 1 - 10') + assert.equal(result?.details?.['src/file1.ts']?.description, 'First file') + assert.equal(result?.details?.['src/file1.ts']?.visibleName, 'file1.ts') + assert.equal(result?.details?.['src/file2.ts']?.label, '') + }) + + it('uses default root folder title when not provided', () => { + const fileList: ChatMessage['fileList'] = { + filePaths: ['file.ts'], + } + + const result = toMynahFileList(fileList) + assert.equal(result?.rootFolderTitle, 'Context') + }) + + it('returns undefined for undefined input', () => { + const result = toMynahFileList(undefined) + assert.equal(result, undefined) + }) + + it('handles file paths with different structures', () => { + const fileList: ChatMessage['fileList'] = { + filePaths: ['simple.ts', 'folder/nested.ts', 'deep/nested/path/file.ts'], + details: { + 'simple.ts': {}, + 'folder/nested.ts': {}, + 'deep/nested/path/file.ts': {}, + }, + } + + const result = toMynahFileList(fileList) + assert.equal(result?.details?.['simple.ts']?.visibleName, 'simple.ts') + assert.equal(result?.details?.['folder/nested.ts']?.visibleName, 'nested.ts') + assert.equal(result?.details?.['deep/nested/path/file.ts']?.visibleName, 'file.ts') + }) + + it('handles multiple line ranges', () => { + const fileList: ChatMessage['fileList'] = { + filePaths: ['file.ts'], + details: { + 'file.ts': { + lineRanges: [ + { first: 1, second: 5 }, + { first: 10, second: 15 }, + ], + }, + }, + } + + const result = toMynahFileList(fileList) + assert.equal(result?.details?.['file.ts']?.label, 'line 1 - 5, line 10 - 15') + }) + }) + + describe('toDetailsWithoutIcon', () => { + it('removes icons from details', () => { + const details = { + 'file1.ts': { + label: 'File 1', + icon: MynahIcons.FILE, + description: 'First file', + }, + 'file2.ts': { + label: 'File 2', + description: 'Second file', + }, + } + + const result = toDetailsWithoutIcon(details) + assert.equal(result['file1.ts'].icon, null) + assert.equal(result['file1.ts'].label, 'File 1') + assert.equal(result['file2.ts'].icon, null) + assert.equal(result['file2.ts'].label, 'File 2') + }) + + it('handles undefined input', () => { + const result = toDetailsWithoutIcon(undefined) + assert.deepEqual(result, {}) + }) + + it('handles empty object', () => { + const result = toDetailsWithoutIcon({}) + assert.deepEqual(result, {}) + }) + }) + + describe('toMynahContextCommand', () => { + it('converts feature context with string value', () => { + const feature: FeatureContext = { + value: { stringValue: 'test-command' }, + variation: 'Test Command Description', + } + + const result = toMynahContextCommand(feature) + assert.equal(result.command, 'test-command') + assert.equal(result.id, 'test-command') + assert.equal(result.description, 'Test Command Description') + }) + + it('returns empty object for undefined feature', () => { + const result = toMynahContextCommand(undefined) + assert.deepEqual(result, {}) + }) + + it('returns empty object for feature without string value', () => { + const feature: FeatureContext = { + value: {}, + variation: 'Description', + } + + const result = toMynahContextCommand(feature) + assert.deepEqual(result, {}) + }) + + it('returns empty object for feature with empty string value', () => { + const feature: FeatureContext = { + value: { stringValue: '' }, + variation: 'Description', + } + + const result = toMynahContextCommand(feature) + assert.deepEqual(result, {}) + }) + }) +}) diff --git a/chat-client/src/client/utils.ts b/chat-client/src/client/utils.ts index 2a6d29aca3..c951f7ffe8 100644 --- a/chat-client/src/client/utils.ts +++ b/chat-client/src/client/utils.ts @@ -74,6 +74,7 @@ export function toMynahContextCommand(feature?: FeatureContext): any { return { command: feature.value.stringValue, + id: feature.value.stringValue, description: feature.variation, } } diff --git a/chat-client/src/client/withAdapter.test.ts b/chat-client/src/client/withAdapter.test.ts index 89fca154d9..66564b4874 100644 --- a/chat-client/src/client/withAdapter.test.ts +++ b/chat-client/src/client/withAdapter.test.ts @@ -5,6 +5,7 @@ import { withAdapter } from './withAdapter' import { ChatClientAdapter, ChatEventHandler } from '../contracts/chatClientAdapter' import { MynahUI, MynahUIProps, RelevancyVoteType } from '@aws/mynah-ui' import { disclaimerAcknowledgeButtonId } from './texts/disclaimer' +import { TabFactory } from './tabs/tabFactory' describe('withAdapter', () => { let defaultEventHandlers: ChatEventHandler @@ -12,6 +13,7 @@ describe('withAdapter', () => { let chatClientAdapter: ChatClientAdapter let customEventHandlers: ChatEventHandler let mynahUiPropsWithAdapter: MynahUIProps + let tabFactory: TabFactory beforeEach(() => { // Set up base MynahUIProps with stub methods @@ -102,8 +104,14 @@ describe('withAdapter', () => { handleQuickAction: sinon.stub(), } + // Set up tab factory + tabFactory = { + isRerouteEnabled: sinon.stub().returns(false), + isCodeReviewInChatEnabled: sinon.stub().returns(false), + } as unknown as TabFactory + // Create the enhanced props - mynahUiPropsWithAdapter = withAdapter(defaultEventHandlers, mynahUIRef, chatClientAdapter) + mynahUiPropsWithAdapter = withAdapter(defaultEventHandlers, mynahUIRef, chatClientAdapter, tabFactory) }) afterEach(() => { @@ -159,7 +167,7 @@ describe('withAdapter', () => { } assert.throws(() => { - withAdapter(defaultEventHandlers, mynahUIRef, invalidAdapter) + withAdapter(defaultEventHandlers, mynahUIRef, invalidAdapter, tabFactory) }, new Error('Custom ChatEventHandler is not defined')) }) @@ -564,7 +572,8 @@ describe('withAdapter', () => { // @ts-ignore { createChatEventHandler: () => ({}), - } + }, + tabFactory ) const customOnFormLinkClickHandler = customEventHandlers.onFileActionClick diff --git a/chat-client/src/client/withAdapter.ts b/chat-client/src/client/withAdapter.ts index 51b35dafa9..3f10e11752 100644 --- a/chat-client/src/client/withAdapter.ts +++ b/chat-client/src/client/withAdapter.ts @@ -1,6 +1,7 @@ import { MynahUI, MynahUIProps } from '@aws/mynah-ui' import { ChatClientAdapter, ChatEventHandler } from '../contracts/chatClientAdapter' import { disclaimerAcknowledgeButtonId } from './texts/disclaimer' +import { TabFactory } from './tabs/tabFactory' type HandlerMethodName = keyof ChatEventHandler type HandlerParameters = Parameters> @@ -8,7 +9,8 @@ type HandlerParameters = Parameters { // Inject reference to MynahUI object into external event handler. // This allows custom controllers to maintain drive Chat UI with custom, feature-specific logic. @@ -59,6 +61,9 @@ export const withAdapter = ( onPromptInputOptionChange: addDefaultRouting('onPromptInputOptionChange'), onPromptInputButtonClick: addDefaultRouting('onPromptInputButtonClick'), onMessageDismiss: addDefaultRouting('onMessageDismiss'), + onPromptTopBarItemAdded: addDefaultRouting('onPromptTopBarItemAdded'), + onPromptTopBarItemRemoved: addDefaultRouting('onPromptTopBarItemRemoved'), + onPromptTopBarButtonClick: addDefaultRouting('onPromptTopBarButtonClick'), /** * Handler with special routing logic @@ -70,9 +75,23 @@ export const withAdapter = ( return } - if (prompt.command && chatClientAdapter.isSupportedQuickAction(prompt.command)) { - chatClientAdapter.handleQuickAction(prompt, tabId, eventId) - return + // Only /transform commands for chatClientAdapter handling + // Let /dev, /test, /doc, /review use default event handler routing(agentic chat) + if (prompt.command) { + const quickActionCommands = ['/transform'] + + if (!tabFactory?.isCodeReviewInChatEnabled()) { + quickActionCommands.push('/review') + } + + const shouldHandleQuickAction = !tabFactory.isRerouteEnabled() + ? chatClientAdapter.isSupportedQuickAction(prompt.command) + : quickActionCommands.includes(prompt.command) + + if (shouldHandleQuickAction) { + chatClientAdapter.handleQuickAction(prompt, tabId, eventId) + return + } } defaultEventHandler.onChatPrompt?.(tabId, prompt, eventId) @@ -121,6 +140,22 @@ export const withAdapter = ( return defaultEventHandler.onContextSelected?.(contextItem, tabId, eventId) ?? false }, + onOpenFileDialogClick(tabId, fileType, insertPosition) { + if (chatClientAdapter.isSupportedTab(tabId)) { + return customEventHandler.onOpenFileDialogClick?.(tabId, fileType, insertPosition) ?? false + } + + return defaultEventHandler.onOpenFileDialogClick?.(tabId, fileType, insertPosition) ?? false + }, + + onFilesDropped(tabId, fileList, insertPosition) { + if (chatClientAdapter.isSupportedTab(tabId)) { + return customEventHandler.onFilesDropped?.(tabId, fileList, insertPosition) ?? false + } + + return defaultEventHandler.onFilesDropped?.(tabId, fileList, insertPosition) ?? false + }, + onFormLinkClick(link, mouseEvent, eventId) { // Always delegate onFormLinkClick to adapter, if handled exists, since it's not tied to specific tabId if (customEventHandler.onFormLinkClick) { diff --git a/chat-client/src/contracts/chatClientAdapter.ts b/chat-client/src/contracts/chatClientAdapter.ts index ee0b41683e..01d9d19c22 100644 --- a/chat-client/src/contracts/chatClientAdapter.ts +++ b/chat-client/src/contracts/chatClientAdapter.ts @@ -38,6 +38,11 @@ export interface ChatEventHandler | 'onPromptInputOptionChange' | 'onPromptInputButtonClick' | 'onMessageDismiss' + | 'onOpenFileDialogClick' + | 'onFilesDropped' + | 'onPromptTopBarItemAdded' + | 'onPromptTopBarItemRemoved' + | 'onPromptTopBarButtonClick' > {} /** diff --git a/chat-client/src/contracts/serverContracts.ts b/chat-client/src/contracts/serverContracts.ts index a47ee0716a..af4675706b 100644 --- a/chat-client/src/contracts/serverContracts.ts +++ b/chat-client/src/contracts/serverContracts.ts @@ -39,6 +39,16 @@ import { GetSerializedChatResult, PROMPT_INPUT_OPTION_CHANGE_METHOD, BUTTON_CLICK_REQUEST_METHOD, + OPEN_FILE_DIALOG_METHOD, + OpenFileDialogParams, + RULE_CLICK_REQUEST_METHOD, + RuleClickParams, + ListRulesParams, + LIST_RULES_REQUEST_METHOD, + PINNED_CONTEXT_ADD_NOTIFICATION_METHOD, + PINNED_CONTEXT_REMOVE_NOTIFICATION_METHOD, + PinnedContextParams, + LIST_AVAILABLE_MODELS_REQUEST_METHOD, } from '@aws/language-server-runtimes-types' export const TELEMETRY = 'telemetry/event' @@ -60,6 +70,7 @@ export type ServerMessageCommand = | typeof CREATE_PROMPT_NOTIFICATION_METHOD | typeof FILE_CLICK_NOTIFICATION_METHOD | typeof LIST_CONVERSATIONS_REQUEST_METHOD + | typeof LIST_RULES_REQUEST_METHOD | typeof CONVERSATION_CLICK_REQUEST_METHOD | typeof LIST_MCP_SERVERS_REQUEST_METHOD | typeof MCP_SERVER_CLICK_REQUEST_METHOD @@ -67,6 +78,11 @@ export type ServerMessageCommand = | typeof GET_SERIALIZED_CHAT_REQUEST_METHOD | typeof PROMPT_INPUT_OPTION_CHANGE_METHOD | typeof BUTTON_CLICK_REQUEST_METHOD + | typeof RULE_CLICK_REQUEST_METHOD + | typeof PINNED_CONTEXT_ADD_NOTIFICATION_METHOD + | typeof PINNED_CONTEXT_REMOVE_NOTIFICATION_METHOD + | typeof LIST_AVAILABLE_MODELS_REQUEST_METHOD + | typeof OPEN_FILE_DIALOG_METHOD export interface ServerMessage { command: ServerMessageCommand @@ -99,3 +115,7 @@ export type ServerMessageParams = | McpServerClickParams | TabBarActionParams | GetSerializedChatResult + | RuleClickParams + | ListRulesParams + | PinnedContextParams + | OpenFileDialogParams diff --git a/chat-client/src/test/jsDomInjector.ts b/chat-client/src/test/jsDomInjector.ts index 2d80344ef1..b73ad67484 100644 --- a/chat-client/src/test/jsDomInjector.ts +++ b/chat-client/src/test/jsDomInjector.ts @@ -15,6 +15,8 @@ export function injectJSDOM() { global.HTMLElement = dom.window.HTMLElement global.CustomEvent = dom.window.CustomEvent global.MutationObserver = dom.window.MutationObserver + global.Image = dom.window.Image + global.FileReader = dom.window.FileReader // jsdom doesn't have support for innerText: https://github.com/jsdom/jsdom/issues/1245 which mynah ui uses Object.defineProperty(global.Element.prototype, 'innerText', { diff --git a/client/vscode/package.json b/client/vscode/package.json index 4762b2af59..7a2e502264 100644 --- a/client/vscode/package.json +++ b/client/vscode/package.json @@ -240,6 +240,11 @@ "type": "string", "description": "Extra context for Q Inline Suggestions" }, + "aws.q.inlineChat.extraContext": { + "scope": "resource", + "type": "string", + "description": "Extra context for Q Inline Chat" + }, "aws.q.optOutTelemetry": { "scope": "resource", "type": "boolean", @@ -346,8 +351,8 @@ "devDependencies": { "@aws-sdk/credential-providers": "^3.731.1", "@aws-sdk/types": "^3.734.0", - "@aws/chat-client-ui-types": "^0.1.40", - "@aws/language-server-runtimes": "^0.2.96", + "@aws/chat-client-ui-types": "^0.1.63", + "@aws/language-server-runtimes": "^0.3.1", "@types/uuid": "^9.0.8", "@types/vscode": "^1.98.0", "jose": "^5.2.4", diff --git a/client/vscode/src/activation.ts b/client/vscode/src/activation.ts index 7bf696f1eb..5d496015de 100644 --- a/client/vscode/src/activation.ts +++ b/client/vscode/src/activation.ts @@ -5,6 +5,7 @@ import * as cp from 'child_process' import * as path from 'path' +import * as os from 'os' import { ExtensionContext, env, version } from 'vscode' @@ -147,13 +148,15 @@ export async function activateDocumentsLanguageServer(extensionContext: Extensio const enableChat = process.env.ENABLE_CHAT === 'true' const agenticMode = process.env.ENABLE_AGENTIC_UI_MODE === 'true' const modelSelectionEnabled = process.env.ENABLE_MODEL_SELECTION === 'true' + const osPlatform = os.platform() if (enableChat) { registerChat( client, extensionContext.extensionUri, enableEncryptionInit ? encryptionKey : undefined, agenticMode, - modelSelectionEnabled + modelSelectionEnabled, + osPlatform ) } diff --git a/client/vscode/src/chatActivation.ts b/client/vscode/src/chatActivation.ts index 449a63db0d..1c21cbfcd5 100644 --- a/client/vscode/src/chatActivation.ts +++ b/client/vscode/src/chatActivation.ts @@ -28,10 +28,14 @@ import { getSerializedChatRequestType, ShowSaveFileDialogRequestType, ShowSaveFileDialogParams, + ShowOpenDialogRequestType, + ShowOpenDialogParams, tabBarActionRequestType, chatOptionsUpdateType, buttonClickRequestType, chatUpdateNotificationType, + listRulesRequestType, + ruleClickRequestType, } from '@aws/language-server-runtimes/protocol' import { v4 as uuidv4 } from 'uuid' import { Uri, Webview, WebviewView, commands, window } from 'vscode' @@ -51,7 +55,8 @@ export function registerChat( extensionUri: Uri, encryptionKey?: Buffer, agenticMode?: boolean, - modelSelectionEnabled?: boolean + modelSelectionEnabled?: boolean, + os?: string ) { const webviewInitialized: Promise = new Promise(resolveWebview => { const provider = { @@ -64,8 +69,7 @@ export function registerChat( resolveWebview(webviewView.webview) webviewView.webview.onDidReceiveMessage(async message => { - languageClient.info(`[VSCode Client] Received ${JSON.stringify(message)} from chat`) - + languageClient.info(`[VSCode Client] Received ${JSON.stringify(message)}`) switch (message.command) { case COPY_TO_CLIPBOARD: languageClient.info('[VSCode Client] Copy to clipboard event received') @@ -164,6 +168,22 @@ export function registerChat( listConversationsRequestType.method ) break + case ruleClickRequestType.method: + await handleRequest( + languageClient, + message.params, + webviewView, + ruleClickRequestType.method + ) + break + case listRulesRequestType.method: + await handleRequest( + languageClient, + message.params, + webviewView, + listRulesRequestType.method + ) + break case conversationClickRequestType.method: await handleRequest( languageClient, @@ -278,7 +298,8 @@ export function registerChat( webviewView.webview, extensionUri, !!agenticMode, - !!modelSelectionEnabled + !!modelSelectionEnabled, + os! ) registerGenericCommand('aws.sample-vscode-ext-amazonq.explainCode', 'Explain', webviewView.webview) @@ -335,6 +356,19 @@ export function registerChat( languageClient.info(`[VSCode Client] Received telemetry event from server ${JSON.stringify(e)}`) }) + languageClient.onRequest(ShowOpenDialogRequestType.method, async (params: ShowOpenDialogParams) => { + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: params.canSelectFiles ?? true, + canSelectFolders: params.canSelectFolders ?? false, + canSelectMany: params.canSelectMany ?? false, + filters: params.filters, + defaultUri: params.defaultUri ? Uri.parse(params.defaultUri) : undefined, + title: params.title, + }) + const urisString = uris?.map(uri => uri.toString()) + return { uris: urisString || [] } + }) + languageClient.onRequest(ShowSaveFileDialogRequestType.method, async (params: ShowSaveFileDialogParams) => { // Show native Save File dialog const filters: Record = {} @@ -399,7 +433,13 @@ async function handleRequest( }) } -function getWebviewContent(webView: Webview, extensionUri: Uri, agenticMode: boolean, modelSelectionEnabled: boolean) { +function getWebviewContent( + webView: Webview, + extensionUri: Uri, + agenticMode: boolean, + modelSelectionEnabled: boolean, + os: string +) { return ` @@ -410,7 +450,7 @@ function getWebviewContent(webView: Webview, extensionUri: Uri, agenticMode: boo ${generateCss()} - ${generateJS(webView, extensionUri, agenticMode, modelSelectionEnabled)} + ${generateJS(webView, extensionUri, agenticMode, modelSelectionEnabled, os)} ` } @@ -431,7 +471,13 @@ function generateCss() { ` } -function generateJS(webView: Webview, extensionUri: Uri, agenticMode: boolean, modelSelectionEnabled: boolean): string { +function generateJS( + webView: Webview, + extensionUri: Uri, + agenticMode: boolean, + modelSelectionEnabled: boolean, + os: string +): string { const assetsPath = Uri.joinPath(extensionUri) const chatUri = Uri.joinPath(assetsPath, 'build', 'amazonq-ui.js') @@ -450,7 +496,7 @@ function generateJS(webView: Webview, extensionUri: Uri, agenticMode: boolean, m