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/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 f0ccd5622b..8f3af70b45 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ build **/*.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/** @@ -26,3 +28,6 @@ app/aws-lsp-partiql-* # Mynah !mynah-ui/dist + +# Coverage (C8) +**/coverage/ 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 73301094b0..caae52434b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,9 +1,9 @@ { - "chat-client": "0.1.13", - "core/aws-lsp-core": "0.0.9", - "server/aws-lsp-antlr4": "0.1.10", - "server/aws-lsp-codewhisperer": "0.0.43", - "server/aws-lsp-json": "0.1.10", - "server/aws-lsp-partiql": "0.0.11", - "server/aws-lsp-yaml": "0.1.10" + "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 724c9c865f..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.90", + "@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 0c56611f9b..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.90", + "@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 e683811ca0..49600a91b8 100644 --- a/app/aws-lsp-codewhisperer-runtimes/src/agent-standalone.ts +++ b/app/aws-lsp-codewhisperer-runtimes/src/agent-standalone.ts @@ -1,35 +1,47 @@ 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' import { IdentityServer } from '@aws/lsp-identity' -import { BashToolsServer, FsToolsServer } from '@aws/lsp-codewhisperer/out/language-server/agenticChat/tools/toolServer' -import { createTokenRuntimeProps } from './standalone-common' +import { + BashToolsServer, + FsToolsServer, + QCodeAnalysisServer, + McpToolsServer, +} from '@aws/lsp-codewhisperer/out/language-server/agenticChat/tools/toolServer' +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 49c92ecdf6..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.90", + "@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 8981cd0827..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.90", + "@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 0e06c6a953..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.90", + "@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 e010534059..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.90", + "@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 3e160e4f10..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.90", + "@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 866c616e66..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.90" + "@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 70c0c2cda8..3a243a84c8 100644 --- a/chat-client/CHANGELOG.md +++ b/chat-client/CHANGELOG.md @@ -1,5 +1,299 @@ # 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) + + +### Bug Fixes + +* mcp server list highlighting ([#1627](https://github.com/aws/language-servers/issues/1627)) ([e3c7f2c](https://github.com/aws/language-servers/commit/e3c7f2c529726b88a811c701e7ad8514a3abe4b2)) + +## [0.1.15](https://github.com/aws/language-servers/compare/chat-client/v0.1.14...chat-client/v0.1.15) (2025-06-11) + + +### Bug Fixes + +* add more detailed log when mcp server initialize failed and tooltip change ([#1594](https://github.com/aws/language-servers/issues/1594)) ([cdab4d6](https://github.com/aws/language-servers/commit/cdab4d6b59c4ded425822063cb568c4b831402e8)) +* correct icon for mcp button ([#1605](https://github.com/aws/language-servers/issues/1605)) ([a2e7d57](https://github.com/aws/language-servers/commit/a2e7d571eafb3767471b401242ac8a25b41961cd)) +* **paidtier:** Upgrade success message is unreliable ([#1602](https://github.com/aws/language-servers/issues/1602)) ([e0b274f](https://github.com/aws/language-servers/commit/e0b274ffee2e091e09574de03fe38e0a234e2f6e)) +* show server name when deleting ([#1593](https://github.com/aws/language-servers/issues/1593)) ([a2d495a](https://github.com/aws/language-servers/commit/a2d495a5799f078b455869058bb3a546974302ec)) +* updating sticky card css [#1586](https://github.com/aws/language-servers/issues/1586) ([1c92249](https://github.com/aws/language-servers/commit/1c92249635b19e0b0a88b271a200ffd56ea65e9d)) + +## [0.1.14](https://github.com/aws/language-servers/compare/chat-client/v0.1.13...chat-client/v0.1.14) (2025-06-10) + + +### Features + +* adding mcp servers feature to the language-server ([#1544](https://github.com/aws/language-servers/issues/1544)) ([f37bf5f](https://github.com/aws/language-servers/commit/f37bf5f91921d7611c124de6d54dd6ec653038c6)) +* **q:** builderid "paid tier" [#1197](https://github.com/aws/language-servers/issues/1197) ([d25bcb6](https://github.com/aws/language-servers/commit/d25bcb696572dd52938253bd15d838b1a0f57d68)) +* remove auto model selection option ([#1548](https://github.com/aws/language-servers/issues/1548)) ([71fc801](https://github.com/aws/language-servers/commit/71fc80165a7e987ca4d103f40aa93980bcd015da)) + + +### Bug Fixes + +* prevent muting messages with completed status ([#1557](https://github.com/aws/language-servers/issues/1557)) ([527a373](https://github.com/aws/language-servers/commit/527a373cc0b7c2c253d700af002d4e6bc7fdb887)) + ## [0.1.13](https://github.com/aws/language-servers/compare/chat-client/v0.1.12...chat-client/v0.1.13) (2025-06-02) @@ -216,8 +510,8 @@ ### Changed -- Update `@aws/chat-client-ui-types` to 0.1.0 -- Update `@aws/language-server-runtimes-types` to 0.1.0 +- 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 @@ -241,8 +535,8 @@ ### Changed - Changed legal text in the footer -- Update `@aws/chat-client-ui-types` to 0.0.8 -- Update `@aws/language-server-runtimes-types` to to 0.0.7 +- 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 4f8a148a6b..3a87cd8755 100644 --- a/chat-client/package.json +++ b/chat-client/package.json @@ -1,6 +1,6 @@ { "name": "@aws/chat-client", - "version": "0.1.13", + "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.1" + "@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 f059f4aac8..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', () => { @@ -336,6 +341,201 @@ describe('Chat', () => { // @ts-expect-error: accessing prototype method assert.notCalled(TabFactory.prototype.enableExport) }).timeout(20000) + + it('enables MCP when params.mcpServers is true and config.agenticMode is true', function () { + // Create a separate sandbox for this test + const testSandbox = sinon.createSandbox() + + // Save original window functions + const originalAddEventListener = window.addEventListener + const originalDispatchEvent = window.dispatchEvent + + try { + // Create a clean stub for this test + const enableMcpStub = testSandbox.stub(TabFactory.prototype, 'enableMcp') + const localClientApi = { postMessage: testSandbox.stub() } + + // Mock the event handling to isolate this test + let messageHandler: any + window.addEventListener = (type: string, handler: any) => { + if (type === 'message') { + messageHandler = handler + } + return undefined as any + } + + // Create a new chat instance specifically for this test + const localMynahUi = createChat(localClientApi, { agenticMode: true }) + + // Create a new event + const chatOptionsRequest = createInboundEvent({ + command: CHAT_OPTIONS, + params: { + mcpServers: true, + chatNotifications: [], + }, + }) + + // Manually call the handler with our event + if (messageHandler) { + messageHandler(chatOptionsRequest) + } + + // Verify enableMcp was called exactly once + assert.calledOnce(enableMcpStub) + } finally { + // Restore window functions + window.addEventListener = originalAddEventListener + window.dispatchEvent = originalDispatchEvent + testSandbox.restore() + } + }) + + it('does not enable MCP when params.mcpServers is true but config.agenticMode is false', function () { + // Create a separate sandbox for this test + const testSandbox = sinon.createSandbox() + + // Save original window functions + const originalAddEventListener = window.addEventListener + const originalDispatchEvent = window.dispatchEvent + + try { + // Create a clean stub for this test + const enableMcpStub = testSandbox.stub(TabFactory.prototype, 'enableMcp') + const localClientApi = { postMessage: testSandbox.stub() } + + // Mock the event handling to isolate this test + let messageHandler: any + window.addEventListener = (type: string, handler: any) => { + if (type === 'message') { + messageHandler = handler + } + return undefined as any + } + + // Create a new chat instance specifically for this test + const localMynahUi = createChat(localClientApi, { agenticMode: false }) + + // Create a new event + const chatOptionsRequest = createInboundEvent({ + command: CHAT_OPTIONS, + params: { + mcpServers: true, + chatNotifications: [], + }, + }) + + // Manually call the handler with our event + if (messageHandler) { + messageHandler(chatOptionsRequest) + } + + // Verify enableMcp was not called + assert.notCalled(enableMcpStub) + } finally { + // Restore window functions + window.addEventListener = originalAddEventListener + window.dispatchEvent = originalDispatchEvent + testSandbox.restore() + } + }) + + it('does not enable MCP when params.mcpServers is false and config.agenticMode is true', function () { + // Create a separate sandbox for this test + const testSandbox = sinon.createSandbox() + + // Save original window functions + const originalAddEventListener = window.addEventListener + const originalDispatchEvent = window.dispatchEvent + + try { + // Create a clean stub for this test + const enableMcpStub = testSandbox.stub(TabFactory.prototype, 'enableMcp') + const localClientApi = { postMessage: testSandbox.stub() } + + // Mock the event handling to isolate this test + let messageHandler: any + window.addEventListener = (type: string, handler: any) => { + if (type === 'message') { + messageHandler = handler + } + return undefined as any + } + + // Create a new chat instance specifically for this test + const localMynahUi = createChat(localClientApi, { agenticMode: true }) + + // Create a new event + const chatOptionsRequest = createInboundEvent({ + command: CHAT_OPTIONS, + params: { + mcpServers: false, + chatNotifications: [], + }, + }) + + // Manually call the handler with our event + if (messageHandler) { + messageHandler(chatOptionsRequest) + } + + // Verify enableMcp was not called + assert.notCalled(enableMcpStub) + } finally { + // Restore window functions + window.addEventListener = originalAddEventListener + window.dispatchEvent = originalDispatchEvent + testSandbox.restore() + } + }) + + it('does not enable MCP when params.mcpServers is undefined and config.agenticMode is true', function () { + // Create a separate sandbox for this test + const testSandbox = sinon.createSandbox() + + // Save original window functions + const originalAddEventListener = window.addEventListener + const originalDispatchEvent = window.dispatchEvent + + try { + // Create a clean stub for this test + const enableMcpStub = testSandbox.stub(TabFactory.prototype, 'enableMcp') + const localClientApi = { postMessage: testSandbox.stub() } + + // Mock the event handling to isolate this test + let messageHandler: any + window.addEventListener = (type: string, handler: any) => { + if (type === 'message') { + messageHandler = handler + } + return undefined as any + } + + // Create a new chat instance specifically for this test + const localMynahUi = createChat(localClientApi, { agenticMode: true }) + + // Create a new event + const chatOptionsRequest = createInboundEvent({ + command: CHAT_OPTIONS, + params: { + chatNotifications: [], + }, + }) + + // Manually call the handler with our event + if (messageHandler) { + messageHandler(chatOptionsRequest) + } + + // Verify enableMcp was not called + assert.notCalled(enableMcpStub) + } finally { + // Restore window functions + window.addEventListener = originalAddEventListener + window.dispatchEvent = originalDispatchEvent + testSandbox.restore() + } + }) }) describe('onGetSerializedChat', () => { diff --git a/chat-client/src/client/chat.ts b/chat-client/src/client/chat.ts index b11990e1ab..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,17 +64,33 @@ 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, + McpServerClickParams, + McpServerClickResult, 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, @@ -83,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' @@ -95,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`, } } @@ -106,6 +129,8 @@ type ChatClientConfig = Pick & { pairProgrammingAcknowledged?: boolean agenticMode?: boolean modelSelectionEnabled?: boolean + stringOverrides?: Partial + os?: string } export const createChat = ( @@ -159,12 +184,22 @@ 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 - case CHAT_UPDATE_NOTIFICATION_METHOD: - mynahApi.updateChat(message.params as ChatUpdateParams) - break + case CHAT_UPDATE_NOTIFICATION_METHOD: { + const messageParams = message.params as ChatUpdateParams + if (messageParams?.tabId === 'mcpserver') { + mynahApi.mcpServerClick({ id: 'update-mcp-list' }) + break + } else { + mynahApi.updateChat(message.params as ChatUpdateParams) + break + } + } case OPEN_TAB_REQUEST_METHOD: mynahApi.openTab(message.requestId, message.params as OpenTabParams) break @@ -180,23 +215,53 @@ 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 + case LIST_MCP_SERVERS_REQUEST_METHOD: + mynahApi.listMcpServers(message.params as ListMcpServersResult) + break + 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) @@ -209,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, @@ -220,6 +294,10 @@ export const createChat = ( tabFactory.updateQuickActionCommands(quickActionCommandGroups) } + if (params?.mcpServers && config?.agenticMode) { + tabFactory.enableMcp() + } + if (params?.history) { tabFactory.enableHistory() } @@ -228,6 +306,10 @@ export const createChat = ( tabFactory.enableExport() } + if (params?.showLogs) { + tabFactory.enableShowLogs() + } + const allExistingTabs: MynahUITabStoreModel = mynahUi.getAllTabs() const highlightCommand = featureConfig.get('highlightCommand') @@ -363,6 +445,12 @@ export const createChat = ( conversationClick: (params: ConversationClickParams) => { sendMessageToClient({ command: CONVERSATION_CLICK_REQUEST_METHOD, params }) }, + listMcpServers: (params: ListMcpServersParams) => { + sendMessageToClient({ command: LIST_MCP_SERVERS_REQUEST_METHOD, params }) + }, + mcpServerClick: function (params: McpServerClickParams): void { + sendMessageToClient({ command: MCP_SERVER_CLICK_REQUEST_METHOD, params }) + }, tabBarAction: (params: TabBarActionParams) => { sendMessageToClient({ command: TAB_BAR_ACTION_REQUEST_METHOD, params }) }, @@ -390,6 +478,10 @@ export const createChat = ( promptInputOptionChange: (params: PromptInputOptionChangeParams) => { sendMessageToClient({ command: PROMPT_INPUT_OPTION_CHANGE_METHOD, params }) }, + promptInputButtonClick: params => { + // TODO + sendMessageToClient({ command: BUTTON_CLICK_REQUEST_METHOD, params }) + }, stopChatResponse: (tabId: string) => { sendMessageToClient({ command: STOP_CHAT_RESPONSE, params: { tabId } }) }, @@ -399,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) @@ -421,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 new file mode 100644 index 0000000000..947e5bc604 --- /dev/null +++ b/chat-client/src/client/mcpMynahUi.test.ts @@ -0,0 +1,595 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as assert from 'assert' +import sinon from 'ts-sinon' +import { McpMynahUi } from './mcpMynahUi' +import { ListMcpServersResult, McpServerClickResult } from '@aws/language-server-runtimes-types' +import { ChatItemButton, DetailedListItem, MynahUI } from '@aws/mynah-ui' +import { Messager } from './messager' +import * as utils from './utils' + +describe('McpMynahUi', () => { + let mynahUi: MynahUI + let messager: Messager + let mcpMynahUi: McpMynahUi + let toMynahIconStub: sinon.SinonStub + + beforeEach(() => { + // Mock MynahUI + mynahUi = { + openDetailedList: sinon.stub().returns({ + close: sinon.stub(), + }), + toggleSplashLoader: sinon.stub(), + } as unknown as MynahUI + + // Mock Messager + messager = { + onListMcpServers: sinon.stub(), + onMcpServerClick: sinon.stub(), + } as unknown as Messager + + // Mock toMynahIcon utility function + toMynahIconStub = sinon.stub(utils, 'toMynahIcon').returns('mocked-icon' as any) + + // Create instance of McpMynahUi + mcpMynahUi = new McpMynahUi(mynahUi, messager) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('listMcpServers', () => { + it('should set isMcpServersListActive to true', () => { + // Create mock params + const params: ListMcpServersResult = { + list: [], + } + + // Call the method + mcpMynahUi.listMcpServers(params) + + // Verify isMcpServersListActive is set to true + // We can't directly access private properties, but we can test the behavior + // by calling mcpServerClick with update-mcp-list + const updateParams: McpServerClickResult = { + id: 'update-mcp-list', + } + mcpMynahUi.mcpServerClick(updateParams) + + // If isMcpServersListActive is true, onListMcpServers should be called + sinon.assert.calledOnce(messager.onListMcpServers as sinon.SinonStub) + }) + + it('should call mynahUi.openDetailedList with correct parameters', () => { + // Create mock params with header + const params: ListMcpServersResult = { + header: { + title: 'Test Title', + description: 'Test Description', + status: { status: 'success' }, + }, + list: [ + { + groupName: 'Active', + children: [ + { + title: 'Server 1', + children: [ + { + groupName: 'serverInformation', + children: [ + { title: 'status', description: 'ENABLED' }, + { title: 'toolcount', description: '5' }, + ], + }, + ], + }, + ], + }, + ], + } + + // Call the method + mcpMynahUi.listMcpServers(params) + + // Verify openDetailedList was called + sinon.assert.calledOnce(mynahUi.openDetailedList as sinon.SinonStub) + + // Verify the parameters + const callArgs = (mynahUi.openDetailedList as sinon.SinonStub).firstCall.args[0] + assert.strictEqual(callArgs.detailedList.selectable, 'clickable') + assert.strictEqual(callArgs.detailedList.textDirection, 'row') + assert.strictEqual(callArgs.detailedList.header.title, 'Test Title') + assert.strictEqual(callArgs.detailedList.header.description, 'Test Description') + assert.deepStrictEqual(callArgs.detailedList.header.status, { status: 'success' }) + + // 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) + assert.strictEqual(callArgs.detailedList.list[0].groupName, 'Active') + assert.strictEqual(callArgs.detailedList.list[0].children.length, 1) + assert.strictEqual(callArgs.detailedList.list[0].children[0].title, 'Server 1') + + // Verify the icon and status are set correctly for ENABLED server + assert.strictEqual(callArgs.detailedList.list[0].children[0].iconForegroundStatus, 'success') + + // Verify the actions for the server + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions.length, 2) + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions[0].id, 'open-mcp-server') + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions[0].text, '5') + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions[1].id, 'open-mcp-server') + }) + + it('should handle disabled servers correctly', () => { + // Create mock params with a disabled server + const params: ListMcpServersResult = { + list: [ + { + groupName: 'Disabled', + children: [ + { + title: 'Server 2', + children: [], + }, + ], + }, + ], + } + + // Call the method + mcpMynahUi.listMcpServers(params) + + // Verify openDetailedList was called + sinon.assert.calledOnce(mynahUi.openDetailedList as sinon.SinonStub) + + // Verify the parameters + const callArgs = (mynahUi.openDetailedList as sinon.SinonStub).firstCall.args[0] + + // Verify the list structure + assert.strictEqual(callArgs.detailedList.list.length, 1) + assert.strictEqual(callArgs.detailedList.list[0].groupName, 'Disabled') + assert.strictEqual(callArgs.detailedList.list[0].children.length, 1) + assert.strictEqual(callArgs.detailedList.list[0].children[0].title, 'Server 2') + + // Verify the icon and status are set correctly for disabled server + assert.strictEqual(callArgs.detailedList.list[0].children[0].iconForegroundStatus, 'info') + + // Verify the actions for the disabled server + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions.length, 3) + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions[0].id, 'mcp-enable-server') + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions[1].id, 'mcp-delete-server') + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions[2].id, 'open-mcp-server') + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions[2].disabled, true) + }) + + it('should handle failed servers correctly', () => { + // Create mock params with a failed server + const params: ListMcpServersResult = { + list: [ + { + groupName: 'Active', + children: [ + { + title: 'Server 3', + children: [ + { + groupName: 'serverInformation', + children: [{ title: 'status', description: 'FAILED' }], + }, + ], + }, + ], + }, + ], + } + + // Call the method + mcpMynahUi.listMcpServers(params) + + // Verify openDetailedList was called + sinon.assert.calledOnce(mynahUi.openDetailedList as sinon.SinonStub) + + // Verify the parameters + const callArgs = (mynahUi.openDetailedList as sinon.SinonStub).firstCall.args[0] + + // Verify the list structure + assert.strictEqual(callArgs.detailedList.list.length, 1) + assert.strictEqual(callArgs.detailedList.list[0].groupName, 'Active') + assert.strictEqual(callArgs.detailedList.list[0].children.length, 1) + assert.strictEqual(callArgs.detailedList.list[0].children[0].title, 'Server 3') + + // Verify the icon and status are set correctly for failed server + assert.strictEqual(callArgs.detailedList.list[0].children[0].iconForegroundStatus, 'error') + + // Verify the actions for the failed server + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions.length, 2) + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions[0].id, 'mcp-fix-server') + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions[1].id, 'open-mcp-server') + assert.strictEqual(callArgs.detailedList.list[0].children[0].actions[1].disabled, true) + }) + + it('should handle events correctly', () => { + // Create mock params + const params: ListMcpServersResult = { + list: [], + } + + // Create mock sheet with close method + const mockSheet = { + close: sinon.stub(), + } + ;(mynahUi.openDetailedList as sinon.SinonStub).returns(mockSheet) + + // Call the method + mcpMynahUi.listMcpServers(params) + + // Get the events object + const callArgs = (mynahUi.openDetailedList as sinon.SinonStub).firstCall.args[0] + const events = callArgs.events + + // Test onFilterValueChange event + const filterValues = { filter1: 'value1' } + events.onFilterValueChange(filterValues) + sinon.assert.calledWith(messager.onListMcpServers as sinon.SinonStub, filterValues) + + // Test onKeyPress event with Escape key + const escapeEvent = { key: 'Escape' } as KeyboardEvent + events.onKeyPress(escapeEvent) + sinon.assert.calledOnce(mockSheet.close) + + // Test onItemSelect event + const mockSelectItem = { + id: 'mcp-server-click', + title: 'Server 1', + actions: [{ id: 'open-mcp-server' }], + } as DetailedListItem + events.onItemSelect(mockSelectItem) + sinon.assert.calledWith(messager.onMcpServerClick as sinon.SinonStub, 'open-mcp-server', 'Server 1') + + // Test onItemClick event + const mockClickItem = { + id: 'mcp-server-click', + title: 'Server 1', + actions: [{ id: 'open-mcp-server' }], + } as DetailedListItem + events.onItemClick(mockClickItem) + sinon.assert.calledWith(messager.onMcpServerClick as sinon.SinonStub, 'open-mcp-server', 'Server 1') + + // Test onActionClick event + const mockAction = { id: 'add-new-mcp' } as ChatItemButton + const mockActionItem = { title: 'Server 1' } as DetailedListItem + events.onActionClick(mockAction, mockActionItem) + sinon.assert.calledWith(messager.onMcpServerClick as sinon.SinonStub, 'add-new-mcp', 'Server 1') + + // Test onClose event + events.onClose() + // We can't directly verify isMcpServersListActive is set to false, + // but we can test the behavior by calling mcpServerClick with update-mcp-list again + ;(messager.onListMcpServers as sinon.SinonStub).resetHistory() + const updateParams: McpServerClickResult = { + id: 'update-mcp-list', + } + mcpMynahUi.mcpServerClick(updateParams) + // If isMcpServersListActive is false, onListMcpServers should not be called + sinon.assert.notCalled(messager.onListMcpServers as sinon.SinonStub) + + // Test onTitleActionClick event + const mockButton = { id: 'refresh-mcp-list' } + events.onTitleActionClick(mockButton) + sinon.assert.calledWith(messager.onMcpServerClick as sinon.SinonStub, 'refresh-mcp-list') + }) + }) + + describe('mcpServerClick', () => { + // This test is skipped until the implementation is fixed + it.skip('should handle open-mcp-server action correctly', () => { + // Create mock params + const params: McpServerClickResult = { + id: 'open-mcp-server', + header: { + title: 'Server Details', + }, + list: [], + } + + // Call the method + mcpMynahUi.mcpServerClick(params) + + // Verify toggleSplashLoader was called + sinon.assert.calledWith(mynahUi.toggleSplashLoader as sinon.SinonStub, false) + + // Verify openDetailedList was called + sinon.assert.calledOnce(mynahUi.openDetailedList as sinon.SinonStub) + + // Verify the second parameter (replace) is true + assert.strictEqual((mynahUi.openDetailedList as sinon.SinonStub).firstCall.args[1], true) + + // Get the events object + const callArgs = (mynahUi.openDetailedList as sinon.SinonStub).firstCall.args[0] + const events = callArgs.events + + // Test onFilterValueChange event + const filterValues = { permission: 'read' } + events.onFilterValueChange(filterValues) + sinon.assert.calledWith( + messager.onMcpServerClick as sinon.SinonStub, + 'mcp-permission-change', + 'Server Details', + filterValues + ) + + // Test onTitleActionClick event + const mockAction = { id: 'mcp-details-menu' } + events.onTitleActionClick(mockAction) + sinon.assert.calledWith(messager.onMcpServerClick as sinon.SinonStub, 'mcp-details-menu', 'Server Details') + + // Test onKeyPress event with Escape key + const mockSheet = { + close: sinon.stub(), + } + ;(mynahUi.openDetailedList as sinon.SinonStub).returns(mockSheet) + const escapeEvent = { key: 'Escape' } as KeyboardEvent + events.onKeyPress(escapeEvent) + sinon.assert.calledOnce(mockSheet.close) + + // Test onActionClick event + const mockActionButton = { id: 'save-permission' } + events.onActionClick(mockActionButton) + sinon.assert.calledWith(messager.onMcpServerClick as sinon.SinonStub, 'save-permission') + + // Test onClose event + events.onClose() + sinon.assert.calledWith(messager.onMcpServerClick as sinon.SinonStub, 'save-permission-change') + + // Test onBackClick event + events.onBackClick() + sinon.assert.calledWith(messager.onMcpServerClick as sinon.SinonStub, 'save-permission-change') + sinon.assert.calledOnce(messager.onListMcpServers as sinon.SinonStub) + }) + + it('should handle server management actions correctly', () => { + // Test mcp-disable-server + const disableParams: McpServerClickResult = { + id: 'mcp-disable-server', + } + mcpMynahUi.mcpServerClick(disableParams) + sinon.assert.calledOnce(messager.onListMcpServers as sinon.SinonStub) + + // Reset call history + ;(messager.onListMcpServers as sinon.SinonStub).resetHistory() + + // Test mcp-delete-server + const deleteParams: McpServerClickResult = { + id: 'mcp-delete-server', + } + mcpMynahUi.mcpServerClick(deleteParams) + sinon.assert.calledOnce(messager.onListMcpServers as sinon.SinonStub) + + // Reset call history + ;(messager.onListMcpServers as sinon.SinonStub).resetHistory() + + // Test mcp-enable-server + const enableParams: McpServerClickResult = { + id: 'mcp-enable-server', + } + mcpMynahUi.mcpServerClick(enableParams) + sinon.assert.calledOnce(messager.onListMcpServers as sinon.SinonStub) + }) + + it('should handle update-mcp-list action correctly', () => { + // First set isMcpServersListActive to true + const listParams: ListMcpServersResult = { + list: [], + } + mcpMynahUi.listMcpServers(listParams) + + // Reset call history + ;(messager.onListMcpServers as sinon.SinonStub).resetHistory() + + // Test update-mcp-list when isMcpServersListActive is true + const updateParams: McpServerClickResult = { + id: 'update-mcp-list', + } + mcpMynahUi.mcpServerClick(updateParams) + sinon.assert.calledOnce(messager.onListMcpServers as sinon.SinonStub) + + // Reset call history + ;(messager.onListMcpServers as sinon.SinonStub).resetHistory() + + // Set isMcpServersListActive to false + const mockSheet = { + close: sinon.stub(), + } + ;(mynahUi.openDetailedList as sinon.SinonStub).returns(mockSheet) + const callArgs = (mynahUi.openDetailedList as sinon.SinonStub).firstCall.args[0] + callArgs.events.onClose() + + // Test update-mcp-list when isMcpServersListActive is false + mcpMynahUi.mcpServerClick(updateParams) + sinon.assert.notCalled(messager.onListMcpServers as sinon.SinonStub) + }) + }) + + describe('private helper methods', () => { + it('should process filter options correctly', () => { + // Create mock params with filter options + const params: ListMcpServersResult = { + filterOptions: [ + { + id: 'filter1', + title: 'Filter 1', + type: 'textinput', + icon: 'search', + }, + ], + list: [], + } + + // Call the method + mcpMynahUi.listMcpServers(params) + + // Verify toMynahIcon was called for the filter icon + sinon.assert.calledWith(toMynahIconStub, 'search') + + // Verify the filter options in the detailed list + const callArgs = (mynahUi.openDetailedList as sinon.SinonStub).firstCall.args[0] + assert.strictEqual(callArgs.detailedList.filterOptions.length, 1) + assert.strictEqual(callArgs.detailedList.filterOptions[0].id, 'filter1') + assert.strictEqual(callArgs.detailedList.filterOptions[0].title, 'Filter 1') + assert.strictEqual(callArgs.detailedList.filterOptions[0].type, 'textinput') + assert.strictEqual(callArgs.detailedList.filterOptions[0].icon, 'mocked-icon') + }) + + it('should create detailed list for adding MCP server correctly', () => { + // Create mock params for add-new-mcp + const params: McpServerClickResult = { + id: 'add-new-mcp', + header: { + title: 'Add MCP Server', + description: 'Add a new MCP server', + status: { status: 'info' }, + actions: [ + { + id: 'action1', + text: 'Action 1', + icon: 'plus', + }, + ], + }, + filterOptions: [ + { + id: 'filter1', + title: 'Filter 1', + type: 'textinput', + }, + ], + filterActions: [ + { + id: 'save-mcp', + text: 'Save', + }, + ], + list: [ + { + groupName: 'Group 1', + children: [ + { + title: 'Item 1', + description: 'Description 1', + }, + ], + }, + ], + } + + // Call the method + mcpMynahUi.mcpServerClick(params) + + // Verify the detailed list structure + const callArgs = (mynahUi.openDetailedList as sinon.SinonStub).firstCall.args[0] + const detailedList = callArgs.detailedList + + assert.strictEqual(detailedList.selectable, false) + assert.strictEqual(detailedList.textDirection, 'row') + assert.strictEqual(detailedList.header.title, 'Add MCP Server') + assert.strictEqual(detailedList.header.description, 'Add a new MCP server') + assert.deepStrictEqual(detailedList.header.status, { status: 'info' }) + assert.strictEqual(detailedList.header.actions.length, 1) + assert.strictEqual(detailedList.header.actions[0].id, 'action1') + assert.strictEqual(detailedList.filterOptions.length, 1) + assert.strictEqual(detailedList.filterOptions[0].id, 'filter1') + assert.strictEqual(detailedList.filterActions.length, 1) + assert.strictEqual(detailedList.filterActions[0].id, 'save-mcp') + assert.strictEqual(detailedList.list.length, 1) + assert.strictEqual(detailedList.list[0].groupName, 'Group 1') + assert.strictEqual(detailedList.list[0].children.length, 1) + assert.strictEqual(detailedList.list[0].children[0].title, 'Item 1') + assert.strictEqual(detailedList.list[0].children[0].description, 'Description 1') + }) + + it('should create detailed list for viewing MCP server correctly', () => { + // Create mock params for open-mcp-server + const params: McpServerClickResult = { + id: 'open-mcp-server', + header: { + title: 'Server Details', + description: 'MCP server details', + status: { status: 'success' }, + actions: [ + { + id: 'mcp-details-menu', + text: 'Menu', + icon: 'ellipsis', + }, + ], + }, + filterOptions: [ + { + id: 'permission', + title: 'Permission', + type: 'select', + options: [ + { label: 'Read', value: 'read' }, + { label: 'Write', value: 'write' }, + ], + }, + ], + filterActions: [ + { + id: 'save-permission', + text: 'Save', + }, + ], + list: [ + { + groupName: 'Tools', + children: [ + { + title: 'Tool 1', + description: 'Description 1', + }, + ], + }, + ], + } + + // Call the method + mcpMynahUi.mcpServerClick(params) + + // Verify the detailed list structure + const callArgs = (mynahUi.openDetailedList as sinon.SinonStub).firstCall.args[0] + const detailedList = callArgs.detailedList + + assert.strictEqual(detailedList.selectable, false) + assert.strictEqual(detailedList.textDirection, 'row') + assert.strictEqual(detailedList.header.title, 'Server Details') + assert.strictEqual(detailedList.header.description, 'MCP server details') + assert.deepStrictEqual(detailedList.header.status, { status: 'success' }) + assert.strictEqual(detailedList.header.actions.length, 1) + assert.strictEqual(detailedList.header.actions[0].id, 'mcp-details-menu') + + // Verify the mcp-details-menu items + assert.strictEqual(detailedList.header.actions[0].items.length, 2) + assert.strictEqual(detailedList.header.actions[0].items[0].id, 'mcp-disable-server') + assert.strictEqual(detailedList.header.actions[0].items[1].id, 'mcp-delete-server') + + assert.strictEqual(detailedList.filterOptions.length, 1) + assert.strictEqual(detailedList.filterOptions[0].id, 'permission') + assert.strictEqual(detailedList.filterActions.length, 1) + assert.strictEqual(detailedList.filterActions[0].id, 'save-permission') + assert.strictEqual(detailedList.list.length, 1) + assert.strictEqual(detailedList.list[0].groupName, 'Tools') + assert.strictEqual(detailedList.list[0].children.length, 1) + assert.strictEqual(detailedList.list[0].children[0].id, 'Tool 1') + assert.strictEqual(detailedList.list[0].children[0].title, 'Tool 1') + assert.strictEqual(detailedList.list[0].children[0].description, 'Description 1') + assert.strictEqual(detailedList.list[0].children[0].icon, 'mocked-icon') + }) + }) +}) diff --git a/chat-client/src/client/mcpMynahUi.ts b/chat-client/src/client/mcpMynahUi.ts new file mode 100644 index 0000000000..0ee418e925 --- /dev/null +++ b/chat-client/src/client/mcpMynahUi.ts @@ -0,0 +1,542 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { ChatItemButton, DetailedListItem, ListItemEntry, MynahUI, SingularFormItem } from '@aws/mynah-ui' +import { Button, ListMcpServersResult, McpServerClickResult } from '@aws/language-server-runtimes-types' +import { Messager } from './messager' +import { toMynahIcon } from './utils' + +// MCP action and element IDs +export const MCP_IDS = { + // Server actions + DISABLE_SERVER: 'mcp-disable-server', + DELETE_SERVER: 'mcp-delete-server', + ENABLE_SERVER: 'mcp-enable-server', + FIX_SERVER: 'mcp-fix-server', + OPEN_SERVER: 'open-mcp-server', + + // Menu items + DETAILS_MENU: 'mcp-details-menu', + SERVER_CLICK: 'mcp-server-click', + + // List actions + ADD_NEW: 'add-new-mcp', + REFRESH_LIST: 'refresh-mcp-list', + UPDATE_LIST: 'update-mcp-list', + + // Form actions + EDIT: 'edit-mcp', + SAVE: 'save-mcp', + CANCEL: 'cancel-mcp', + CHANGE_TRANSPORT: 'change-transport', + + // Permission actions + PERMISSION_CHANGE: 'mcp-permission-change', + SAVE_PERMISSION_CHANGE: 'save-permission-change', +} + +// MCP UI display constants +export const MCP_UI_CONSTANTS = { + MAX_SERVER_NAME_LENGTH: 25, // Maximum length for server name display before truncation when deleting a server +} + +// Type definitions for MCP server parameters +export type McpFilterOption = { + type: 'textarea' | 'textinput' | 'select' | 'numericinput' | 'radiogroup' | 'list' + id: string + title: string + description?: string + icon?: string + options?: Array<{ label: string; value: string }> + mandatory?: boolean + value?: ListItemEntry[] + items?: SingularFormItem[] +} + +export type McpListItem = { + title: string + description?: string + groupActions?: any +} + +export type McpListGroup = { + groupName?: string + children?: McpListItem[] +} + +export type McpServerParams = McpServerClickResult & { + header?: { + title?: string + description?: string + status?: any + actions?: Button[] + } + filterOptions?: McpFilterOption[] + filterActions?: Button[] + list?: McpListGroup[] +} + +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 + */ + private processFilterOptions(filterOptions?: McpFilterOption[]) { + return filterOptions?.map(filter => ({ + ...filter, + icon: filter.icon ? toMynahIcon(filter.icon) : undefined, + mandatory: filter.mandatory ?? false, + value: filter.value ?? undefined, + items: filter.items ?? undefined, + })) + } + + /** + * Processes filter actions by converting icons to Mynah icons + */ + private processFilterActions(filterActions?: Button[]) { + return filterActions?.map(action => ({ + ...action, + icon: action.icon ? toMynahIcon(action.icon) : undefined, + })) + } + + /** + * Processes a list group for the detailed list UI + */ + private processListGroup(group: McpListGroup, isServerView = false) { + const children = group.children?.map(item => { + if (isServerView) { + return { + id: item.title, + title: item.title, + description: item.description, + icon: toMynahIcon('tools'), + groupActions: item.groupActions, + } + } + return { + title: item.title, + description: item.description, + } + }) + + return { + groupName: group.groupName, + children, + } + } + + /** + * Creates a detailed list configuration for adding a new MCP server + */ + private createAddMcpServerDetailedList(params: McpServerParams) { + const detailedList = { + selectable: false, + textDirection: 'row', + header: { + title: params.header?.title || 'Add MCP Server', + description: params.header?.description || '', + status: params.header?.status || {}, + actions: params.header?.actions?.map(action => ({ + ...action, + icon: action.icon ? toMynahIcon(action.icon) : undefined, + })), + }, + filterOptions: this.processFilterOptions(params.filterOptions), + filterActions: params.filterActions, + } as any + + const isEditMode = params.header?.title === 'Edit MCP Server' + const hasError = params.header?.status?.status === 'error' + + const serverName = (params.filterOptions?.[1] as any)?.value + + if (isEditMode && hasError) { + detailedList.header.actions = [ + { + id: MCP_IDS.DETAILS_MENU, + icon: toMynahIcon('ellipsis'), + items: [ + { + id: MCP_IDS.DISABLE_SERVER, + text: `Disable MCP server`, + data: { serverName }, + }, + { + id: MCP_IDS.DELETE_SERVER, + confirmation: { + cancelButtonText: 'Cancel', + confirmButtonText: 'Delete', + title: `Delete ${serverName.length > MCP_UI_CONSTANTS.MAX_SERVER_NAME_LENGTH ? serverName.slice(0, MCP_UI_CONSTANTS.MAX_SERVER_NAME_LENGTH) + '...' : serverName} MCP server`, + description: + 'This configuration will be deleted and no longer available in Q. \n\n This cannot be undone.', + }, + text: `Delete MCP server`, + data: { serverName }, + }, + ], + }, + ] + } + + // Process list if present + if (params.list && params.list.length > 0) { + detailedList.list = params.list.map(group => this.processListGroup(group)) + } + + return detailedList + } + + /** + * Creates a detailed list configuration for viewing an MCP server + */ + private createViewMcpServerDetailedList(params: McpServerParams) { + const detailedList = { + selectable: false, + textDirection: 'row', + list: params.list?.map(group => this.processListGroup(group, true)), + filterOptions: this.processFilterOptions(params.filterOptions), + } as any + + // Process header if present + if (params.header) { + detailedList.header = { + title: params.header.title, + description: params.header.description, + status: params.header.status, + actions: params.header.actions?.map(action => ({ + ...action, + icon: action.icon ? toMynahIcon(action.icon) : undefined, + ...(action.id === MCP_IDS.DETAILS_MENU + ? { + items: [ + { + id: MCP_IDS.DISABLE_SERVER, + text: `Disable MCP server`, + }, + { + id: MCP_IDS.DELETE_SERVER, + confirmation: { + cancelButtonText: 'Cancel', + confirmButtonText: 'Delete', + title: `Delete ${ + params.header?.title && + params.header.title.length > MCP_UI_CONSTANTS.MAX_SERVER_NAME_LENGTH + ? params.header.title.slice( + 0, + MCP_UI_CONSTANTS.MAX_SERVER_NAME_LENGTH + ) + '...' + : params.header?.title + } MCP server`, + description: + 'This configuration will be deleted and no longer available in Q. \n\n This cannot be undone.', + }, + text: `Delete MCP server`, + }, + ], + } + : {}), + })), + } + } + + // Add filter actions if present + if (params.filterActions && params.filterActions.length > 0) { + detailedList.filterActions = this.processFilterActions(params.filterActions) + } + + return detailedList + } + + /** + * Displays the list of MCP servers + */ + public listMcpServers(params: ListMcpServersResult) { + this.isMcpServersListActive = true + // Convert the ListMcpServersResult to the format expected by mynahUi.openDetailedList + const detailedList: any = { + selectable: 'clickable', + textDirection: 'row', + header: params.header + ? { + title: params.header.title, + description: params.header.description, + status: params.header.status, + actions: + params.header.actions?.map(action => ({ + ...action, + icon: action.icon ? toMynahIcon(action.icon) : undefined, + text: undefined, + })) || [], + } + : undefined, + filterOptions: params.filterOptions?.map(filter => ({ + ...filter, + icon: toMynahIcon(filter.icon), + })), + list: params.list.map(group => ({ + groupName: group.groupName, + children: group.children?.map(item => { + // Determine icon based on group name and status + let icon + let iconForegroundStatus + + // Extract status from serverInformation if available + const serverInfoGroup = item.children?.find(child => child.groupName === 'serverInformation') + const statusChild = serverInfoGroup?.children?.find(child => child.title === 'status') + const status = statusChild?.description || 'DISABLED' + + if (status === 'ENABLED') { + icon = 'ok-circled' + iconForegroundStatus = 'success' + } else if (status === 'FAILED') { + icon = 'cancel-circle' + iconForegroundStatus = 'error' + } else if (status === 'INITIALIZING') { + icon = 'progress' + iconForegroundStatus = 'info' + } else if (group.groupName === 'Disabled') { + icon = 'block' + iconForegroundStatus = 'info' + } + + // Create actions based on group name + const actions = [] + if (group.groupName === 'Active') { + if (status !== 'FAILED') { + const getToolCount = () => { + const serverInfoGroup = item.children?.find( + child => child.groupName === 'serverInformation' + ) + if (serverInfoGroup) { + const toolCountChild = serverInfoGroup.children?.find( + child => child.title === 'toolcount' + ) + if (toolCountChild) { + return toolCountChild.description ?? '0' + } + } + return '0' + } + + const toolCount = getToolCount() + actions.push({ + id: MCP_IDS.OPEN_SERVER, + icon: toMynahIcon('tools'), + description: `${toolCount} available tools`, + text: toolCount, + }) + actions.push({ + id: MCP_IDS.OPEN_SERVER, + icon: toMynahIcon('right-open'), + }) + } else { + actions.push({ + id: MCP_IDS.FIX_SERVER, + icon: toMynahIcon('pencil'), + text: 'Fix Configuration', + description: 'Fix Configuration', + }) + actions.push({ + id: MCP_IDS.OPEN_SERVER, + icon: toMynahIcon('right-open'), + disabled: true, + }) + } + } else if (group.groupName === 'Disabled') { + actions.push({ + id: MCP_IDS.ENABLE_SERVER, + icon: toMynahIcon('ok'), + text: 'Enable', + description: 'Enable', + }) + actions.push({ + id: MCP_IDS.DELETE_SERVER, + icon: toMynahIcon('trash'), + text: 'Delete', + description: 'Delete', + confirmation: { + cancelButtonText: 'Cancel', + confirmButtonText: 'Delete', + title: `Delete ${item.title.length > MCP_UI_CONSTANTS.MAX_SERVER_NAME_LENGTH ? item.title.slice(0, MCP_UI_CONSTANTS.MAX_SERVER_NAME_LENGTH) + '...' : item.title} MCP server`, + description: + 'This configuration will be deleted and no longer available in Q. \n\n This cannot be undone.', + }, + }) + actions.push({ + id: MCP_IDS.OPEN_SERVER, + icon: toMynahIcon('right-open'), + disabled: true, + }) + } + + return { + id: MCP_IDS.SERVER_CLICK, + title: item.title, + icon: toMynahIcon(icon), + iconForegroundStatus: iconForegroundStatus, + groupActions: false, + actions: actions, + } + }), + })), + } + + if (detailedList.filterOptions && detailedList.filterOptions.length > 0) { + // eslint-disable-next-line no-extra-semi + ;(detailedList.filterOptions[0] as any).autoFocus = true + } + + this.mcpDetailedList = this.mynahUi.openDetailedList({ + detailedList: detailedList, + events: { + onFilterValueChange: (filterValues: Record) => { + this.messager.onListMcpServers(filterValues) + }, + onKeyPress: (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.mcpDetailedList?.close() + } + }, + onItemSelect: (item: DetailedListItem) => { + const actionId = item.actions?.[0].id + if (actionId) { + this.messager.onMcpServerClick(actionId, item.title) + } + }, + onItemClick: (item: DetailedListItem) => { + // actionId: open-mcp-server if valid server or mcp-fix-server if server needs to be fixed + const actionId = item.actions?.[0].id + if (actionId) { + this.messager.onMcpServerClick(actionId, item.title) + } + }, + onActionClick: (action: ChatItemButton, item?: DetailedListItem) => { + this.messager.onMcpServerClick(action.id, item?.title) + }, + onClose: () => { + this.isMcpServersListActive = false + this.mcpDetailedList = undefined + }, + onTitleActionClick: button => { + this.messager.onMcpServerClick(button.id) + }, + }, + }) + } + + /** + * Handles MCP server click events + */ + public mcpServerClick(params: McpServerClickResult) { + 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, + isValid?: boolean + ) => { + if (actionParams.id === MCP_IDS.CANCEL) { + this.messager.onListMcpServers() + return + } + + // new and update will share the same save-mcp + if (actionParams.id === MCP_IDS.SAVE) { + this.mynahUi.toggleSplashLoader(true, '**Activating MCP Server**') + this.messager.onMcpServerClick(actionParams.id, 'Save configuration', filterValues) + } + }, + onTitleActionClick: (action: ChatItemButton) => { + const serverName = (action as any).data?.serverName + this.messager.onMcpServerClick(action.id, serverName) + }, + } + this.mynahUi.openDetailedList({ detailedList, events }, true) + } else if (params.id === MCP_IDS.OPEN_SERVER) { + //turning off splash loader in case of being on when new server is added + this.mynahUi.toggleSplashLoader(false) + const detailedList = this.createViewMcpServerDetailedList(typedParams) + + const mcpServerSheet = this.mynahUi.openDetailedList( + { + detailedList: detailedList, + events: { + onFilterValueChange: (filterValues: Record) => { + // Handle filter value changes for tool permissions + this.messager.onMcpServerClick( + MCP_IDS.PERMISSION_CHANGE, + detailedList.header?.title, + filterValues + ) + }, + onFilterActionClick: () => {}, + onTitleActionClick: (action: ChatItemButton) => { + this.messager.onMcpServerClick(action.id, detailedList.header?.title) + }, + onKeyPress: (e: KeyboardEvent) => { + if (e.key === 'Escape') { + mcpServerSheet.close() + } + }, + onActionClick: (action: ChatItemButton) => { + // Handle action clicks (save, cancel, etc.) + this.messager.onMcpServerClick(action.id) + }, + onClose: () => { + this.messager.onMcpServerClick(MCP_IDS.SAVE_PERMISSION_CHANGE) + this.isMcpServersListActive = false + }, + onBackClick: () => { + this.messager.onMcpServerClick(MCP_IDS.SAVE_PERMISSION_CHANGE) + this.messager.onListMcpServers() + }, + }, + }, + true + ) + } else if ([MCP_IDS.DISABLE_SERVER, MCP_IDS.DELETE_SERVER, MCP_IDS.ENABLE_SERVER].includes(params.id)) { + this.messager.onListMcpServers() + } else if (params.id === MCP_IDS.UPDATE_LIST) { + if (this.isMcpServersListActive) { + this.messager.onListMcpServers() + } + } + } +} diff --git a/chat-client/src/client/messager.ts b/chat-client/src/client/messager.ts index c089c644ed..9472881b87 100644 --- a/chat-client/src/client/messager.ts +++ b/chat-client/src/client/messager.ts @@ -34,14 +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 { @@ -89,22 +96,36 @@ export interface OutboundChatApi { fileClick(params: FileClickParams): void listConversations(params: ListConversationsParams): void conversationClick(params: ConversationClickParams): void + mcpServerClick(params: McpServerClickParams): void + listMcpServers(params: ListMcpServersParams): void tabBarAction(params: TabBarActionParams): void onGetSerializedChat(requestId: string, result: GetSerializedChatResult | ErrorResult): void promptInputOptionChange(params: PromptInputOptionChangeParams): void + promptInputButtonClick(params: ButtonClickParams): void 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 }) } @@ -198,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 => { @@ -213,10 +234,22 @@ export class Messager { } } + onListRules = (params: ListRulesParams): void => { + this.chatApi.listRules(params) + } + onConversationClick = (conversationId: string, action?: ConversationAction): void => { this.chatApi.conversationClick({ id: conversationId, action }) } + onListMcpServers = (filter?: Record): void => { + this.chatApi.listMcpServers({ filter }) + } + + onMcpServerClick = (id: string, title?: string, options?: Record): void => { + this.chatApi.mcpServerClick({ id: id, title: title, optionsValues: options }) + } + onTabBarAction = (params: TabBarActionParams): void => { this.chatApi.tabBarAction(params) } @@ -229,6 +262,10 @@ export class Messager { this.chatApi.promptInputOptionChange(params) } + onPromptInputButtonClick = (params: ButtonClickParams): void => { + this.chatApi.promptInputButtonClick(params) + } + onStopChatResponse = (tabId: string): void => { this.chatApi.stopChatResponse(tabId) } @@ -240,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 88cf2f1599..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 @@ -61,12 +62,22 @@ describe('MynahUI', () => { fileClick: sinon.stub(), listConversations: sinon.stub(), conversationClick: sinon.stub(), + listMcpServers: sinon.stub(), + mcpServerClick: sinon.stub(), tabBarAction: sinon.stub(), onGetSerializedChat: sinon.stub(), promptInputOptionChange: sinon.stub(), + promptInputButtonClick: sinon.stub(), 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) @@ -235,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 = '' @@ -247,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' @@ -263,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' @@ -276,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' @@ -308,6 +345,7 @@ describe('MynahUI', () => { loadingChat: true, promptInputDisabledState: false, }) + setTimeoutStub.restore() }) }) @@ -424,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', () => { @@ -473,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', () => { @@ -499,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 e198f9b134..90b0e1a8d2 100644 --- a/chat-client/src/client/mynahUi.ts +++ b/chat-client/src/client/mynahUi.ts @@ -26,9 +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, @@ -40,10 +49,14 @@ import { MynahUIProps, QuickActionCommand, ChatItemButton, + MynahIcons, + CustomQuickActionCommand, + ConfigTexts, } from '@aws/mynah-ui' import { VoteParams } from '../contracts/telemetry' import { Messager } from './messager' -import { ExportTabBarButtonId, TabFactory } from './tabs/tabFactory' +import { McpMynahUi } from './mcpMynahUi' +import { ExportTabBarButtonId, ShowLogsTabBarButtonId, McpServerTabButtonId, TabFactory } from './tabs/tabFactory' import { disclaimerAcknowledgeButtonId, disclaimerCard } from './texts/disclaimer' import { ChatClientAdapter, ChatEventHandler } from '../contracts/chatClientAdapter' import { withAdapter } from './withAdapter' @@ -57,7 +70,16 @@ 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, + upgradePendingSticky, + plansAndPricingTitle, + freeTierLimitDirective, +} from './texts/paidTier' +import { isSupportedImageExtension, MAX_IMAGE_CONTEXT, verifyClientImages } from './imageVerification' export interface InboundChatApi { addChatResponse(params: ChatResult, tabId: string, isPartialResult: boolean): void @@ -68,9 +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'] @@ -104,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, + }, }) } @@ -124,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, { @@ -149,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) => { @@ -190,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 @@ -240,7 +367,8 @@ export const createMynahUi = ( messager, 'click', eventId, - agenticMode + agenticMode, + tabFactory ) const payload: FollowUpClickParams = { @@ -252,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'] }) @@ -281,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. @@ -290,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) @@ -331,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, @@ -410,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, @@ -430,6 +594,7 @@ export const createMynahUi = ( }, ], }, + validateOnChange: true, description: "Use this prompt by typing '@' followed by the prompt name.", }, ], @@ -455,23 +620,53 @@ 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: (event: KeyboardEvent, formData: Record, itemId: string) => { - if (itemId === ContextPrompt.PromptNameFieldId && event.key === 'Enter') { - event.preventDefault() - messager.onCreatePrompt(formData[ContextPrompt.PromptNameFieldId]) - return true + onFormTextualItemKeyPress: ( + event: KeyboardEvent, + formData: Record, + itemId: string, + _tabId: string, + _eventId?: string + ) => { + 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 }, onTabBarButtonClick: (tabId: string, buttonId: string) => { + if (buttonId === McpServerTabButtonId) { + messager.onListMcpServers() + return + } + if (buttonId === ChatHistory.TabBarButtonId) { messager.onListConversations(undefined, true) return } + if (buttonId === ShowLogsTabBarButtonId) { + messager.onTabBarAction({ + tabId, + action: 'show_logs', + }) + return + } + if (buttonId === ExportTabBarButtonId) { messager.onTabBarAction({ tabId, @@ -488,6 +683,14 @@ export const createMynahUi = ( } messager.onPromptInputOptionChange({ tabId, optionsValues }) }, + onPromptInputButtonClick: (tabId, buttonId, eventId) => { + const payload: ButtonClickParams = { + tabId, + messageId: 'not-a-message', + buttonId: buttonId, + } + messager.onPromptInputButtonClick(payload) + }, onMessageDismiss: (tabId, messageId) => { if (messageId === programmerModeCard.messageId) { programmingModeCardActive = false @@ -502,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, + }) + } }, } @@ -521,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. @@ -533,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({ @@ -614,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) @@ -624,7 +935,7 @@ export const createMynahUi = ( // addChatResponse handler to support Agentic chat UX changes for handling responses streaming. const agenticAddChatResponse = (chatResult: ChatResult, tabId: string, isPartialResult: boolean) => { - const { type, ...chatResultWithoutType } = chatResult + const { type, summary, ...chatResultWithoutTypeSummary } = chatResult let header = toMynahHeader(chatResult.header) const fileList = toMynahFileList(chatResult.fileList) const buttons = toMynahButtons(chatResult.buttons) @@ -667,9 +978,9 @@ export const createMynahUi = ( loadingChat: true, cancelButtonWhenLoading: true, }) - const chatItem: ChatItem = { - ...chatResult, - summary: chatResult.summary as ChatItem['summary'], + const chatItem = { + ...chatResultWithoutTypeSummary, + body: chatResult.body, type: ChatItemType.ANSWER_STREAM, header: header, buttons: buttons, @@ -698,7 +1009,7 @@ export const createMynahUi = ( isValidAuthFollowUpType(followUpOptions[0].type) if (chatResult.body === '' && isValidAuthFollowUp) { mynahUi.addChatItem(tabId, { - ...(chatResultWithoutType as ChatItem), + ...chatResultWithoutTypeSummary, header: header, buttons: buttons, type: ChatItemType.SYSTEM_PROMPT, @@ -716,8 +1027,9 @@ export const createMynahUi = ( } : {} - const chatItem: ChatItem = { - ...(chatResult as ChatItem), + const chatItem = { + ...chatResultWithoutTypeSummary, + body: chatResult.body, type: ChatItemType.ANSWER_STREAM, header: header, buttons: buttons, @@ -749,7 +1061,7 @@ export const createMynahUi = ( // addChatResponse handler to support extensions that haven't migrated to agentic chat yet const legacyAddChatResponse = (chatResult: ChatResult, tabId: string, isPartialResult: boolean) => { - const { type, ...chatResultWithoutType } = chatResult + const { type, summary, ...chatResultWithoutTypeSummary } = chatResult let header = undefined if (chatResult.contextList !== undefined) { @@ -783,10 +1095,8 @@ export const createMynahUi = ( } if (isPartialResult) { - mynahUi.updateLastChatAnswer(tabId, { - ...(chatResultWithoutType as ChatItem), - header: header, - }) + // @ts-expect-error - type for MynahUI differs from ChatResult types so we ignore it + mynahUi.updateLastChatAnswer(tabId, { ...chatResultWithoutTypeSummary, header: header }) return } @@ -802,9 +1112,10 @@ export const createMynahUi = ( followUpOptions[0].type && isValidAuthFollowUpType(followUpOptions[0].type) if (chatResult.body === '' && isValidAuthFollowUp) { + // @ts-expect-error - type for MynahUI differs from ChatResult types so we ignore it mynahUi.addChatItem(tabId, { - ...(chatResultWithoutType as ChatItem), type: ChatItemType.SYSTEM_PROMPT, + ...chatResultWithoutTypeSummary, }) // TODO, prompt should be disabled until user is authenticated @@ -836,7 +1147,102 @@ export const createMynahUi = ( }) } + /** + * Adjusts the UI when the user changes to/from free-tier/paid-tier. + * Shows a message if the user reaches free-tier limit. + * Shows a message if the user just upgraded to paid-tier. + */ + const onPaidTierModeChange = (tabId: string, mode: string | undefined) => { + if (!mode || !['freetier', 'freetier-limit', 'upgrade-pending', 'paidtier'].includes(mode)) { + return false // invalid mode + } + + tabId = tabId ? tabId : getOrCreateTabId()! + const store = mynahUi.getTabData(tabId).getStore() || {} + + // Detect if the tab is already showing the "Upgrade Q" UI. + const isFreeTierLimitUi = store.promptInputStickyCard?.messageId === freeTierLimitSticky.messageId + const isUpgradePendingUi = store.promptInputStickyCard?.messageId === upgradePendingSticky.messageId + const isPlansAndPricingTab = plansAndPricingTitle === store.tabTitle + + if (mode === 'freetier-limit') { + mynahUi.updateStore(tabId, { + promptInputStickyCard: freeTierLimitSticky, + }) + + if (!isFreeTierLimitUi) { + // TODO: how to set a warning icon on the user's failed prompt? + // + // const chatItems = store.chatItems ?? [] + // const lastPrompt = chatItems.filter(ci => ci.type === ChatItemType.PROMPT).at(-1) + // for (const c of chatItems) { + // c.body = 'xxx / ' + c.type + // c.icon = 'warning' + // c.iconStatus = 'warning' + // c.status = 'warning' + // } + // + // if (lastPrompt && lastPrompt.messageId) { + // lastPrompt.icon = 'warning' + // lastPrompt.iconStatus = 'warning' + // lastPrompt.status = 'warning' + // + // // Decorate the failed prompt with a warning icon. + // // mynahUi.updateChatAnswerWithMessageId(tabId, lastPrompt.messageId, lastPrompt) + // } + // + // mynahUi.updateStore(tabId, { + // chatItems: chatItems, + // }) + } else { + // Show directive only on 2nd chat attempt, not the initial attempt. + mynahUi.addChatItem(tabId, freeTierLimitDirective) + } + } else if (mode === 'upgrade-pending') { + // Change the sticky banner to show a progress spinner. + const card: typeof freeTierLimitSticky = { + ...(isFreeTierLimitUi ? freeTierLimitSticky : upgradePendingSticky), + } + card.header = { + ...card.header, + icon: upgradePendingSticky.header?.icon, + iconStatus: upgradePendingSticky.header?.iconStatus, + } + mynahUi.updateStore(tabId, { + promptInputVisible: true, + promptInputStickyCard: card, + }) + } else if (mode === 'paidtier') { + mynahUi.updateStore(tabId, { + promptInputStickyCard: null, + promptInputVisible: !isPlansAndPricingTab, + }) + if (isFreeTierLimitUi || isUpgradePendingUi || isPlansAndPricingTab) { + // Transitioning from 'upgrade-pending' to upgrade success. + const card: typeof upgradeSuccessSticky = { + ...upgradeSuccessSticky, + canBeDismissed: !isPlansAndPricingTab, + } + mynahUi.updateStore(tabId, { + promptInputStickyCard: card, + }) + } + } + + mynahUi.updateStore(tabId, { + // promptInputButtons: mode === 'freetier-limit' ? [upgradeQButton] : [], + // promptInputDisabledState: mode === 'freetier-limit', + }) + + return true + } + const updateChat = (params: ChatUpdateParams) => { + // HACK: Special field sent by `agenticChatController.ts:setPaidTierMode()`. + if (onPaidTierModeChange(params.tabId, (params as any).paidTierMode as string)) { + return + } + const isChatLoading = params.state?.inProgress mynahUi.updateStore(params.tabId, { loadingChat: isChatLoading, @@ -853,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 @@ -865,19 +1285,47 @@ export const createMynahUi = ( } } - const updateFinalItemTypes = (tabId: string) => { - const store = mynahUi.getTabData(tabId)?.getStore() || {} - const chatItems = store.chatItems || [] - const updatedItems = chatItems.map(item => ({ - ...item, - type: item.type === ChatItemType.ANSWER_STREAM && !item.body ? ChatItemType.ANSWER : item.type, - })) - mynahUi.updateStore(tabId, { - loadingChat: false, - cancelButtonWhenLoading: agenticMode, - chatItems: updatedItems, - promptInputDisabledState: false, - }) + /** + * Creates a properly formatted chat item for MCP tool summary with accordion view + */ + const createMcpToolSummaryItem = (message: ChatMessage, isPartialResult?: boolean): Partial => { + return { + type: ChatItemType.ANSWER, + messageId: message.messageId, + summary: { + content: message.summary?.content + ? { + padding: false, + wrapCodes: true, + header: message.summary.content.header + ? { + icon: message.summary.content.header.icon as any, + body: message.summary.content.header.body, + buttons: message.summary.content?.header?.buttons as any, + status: isPartialResult + ? (message.summary.content?.header?.status as any) + : undefined, + fileList: undefined, + } + : undefined, + } + : undefined, + collapsedContent: + message.summary?.collapsedContent?.map(item => ({ + body: item.body, + header: item.header + ? { + body: item.header.body, + } + : undefined, + fullWidth: true, + padding: false, + muted: false, + wrapCodes: item.header?.body === 'Parameters' ? true : false, + codeBlockActions: { copy: null, 'insert-to-cursor': null }, + })) || [], + }, + } } const prepareChatItemFromMessage = ( @@ -891,6 +1339,10 @@ export const createMynahUi = ( let processedHeader = header if (message.type === 'tool') { + // Handle MCP tool summary with accordion view + if (message.summary) { + return createMcpToolSummaryItem(message, isPartialResult) + } processedHeader = { ...header } if (header?.buttons) { processedHeader.buttons = header.buttons.map(button => ({ @@ -904,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 } } @@ -920,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 @@ -931,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 + // 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, @@ -940,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', @@ -957,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) @@ -971,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) => { @@ -996,7 +1468,7 @@ export const createMynahUi = ( const answer: ChatItem = { type: ChatItemType.ANSWER, - body: `**${params.title}** + body: `**${params.title}** ${params.message}`, } @@ -1010,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()) { @@ -1041,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, @@ -1067,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({ @@ -1092,6 +1684,22 @@ ${params.message}`, } } + // Create an instance of McpMynahUi to handle MCP server functionality + const mcpMynahUi = new McpMynahUi(mynahUi, messager) + + const listMcpServers = (params: ListMcpServersResult) => { + mcpMynahUi.listMcpServers(params) + } + + // MCP server functionality is now handled by the McpMynahUi class + + /** + * Handles MCP server click events + */ + const mcpServerClick = (params: McpServerClickResult) => { + mcpMynahUi.mcpServerClick(params) + } + const getSerializedChat = (requestId: string, params: GetSerializedChatParams) => { const supportedFormats = ['markdown', 'html'] @@ -1123,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, @@ -1131,17 +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', @@ -1161,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 0ddde595b7..bfb3091911 100644 --- a/chat-client/src/client/tabs/tabFactory.ts +++ b/chat-client/src/client/tabs/tabFactory.ts @@ -16,11 +16,19 @@ export type DefaultTabData = MynahUIDataModel 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() { @@ -62,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[]) @@ -93,18 +102,46 @@ export class TabFactory { this.export = true } + public enableShowLogs() { + this.showLogs = true + } + public enableAgenticMode() { this.agenticMode = true } + public enableMcp() { + this.mcp = true + } + public enableModelSelection() { 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() @@ -129,9 +166,31 @@ 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 ?? [])] + if (this.mcp) { + tabBarButtons.push({ + id: McpServerTabButtonId, + icon: MynahIcons.TOOLS, + description: 'Configure MCP servers', + }) + } + if (this.history) { tabBarButtons.push({ id: ChatHistory.TabBarButtonId, @@ -148,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 097fd33c9c..6cfd25b7fe 100644 --- a/chat-client/src/client/texts/modelSelection.ts +++ b/chat-client/src/client/texts/modelSelection.ts @@ -1,36 +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', + mandatory: true, + hideMandatoryIcon: true, options: modelOptions, - placeholder: 'Auto', 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 ${modelId === '' ? 'Auto' : 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/paidTier.ts b/chat-client/src/client/texts/paidTier.ts new file mode 100644 index 0000000000..4d847eccc1 --- /dev/null +++ b/chat-client/src/client/texts/paidTier.ts @@ -0,0 +1,184 @@ +import { ChatItem, ChatItemButton, ChatItemType, TextBasedFormItem } from '@aws/mynah-ui' + +export const plansAndPricingTitle = 'Plans & Pricing' +export const paidTierLearnMoreUrl = 'https://aws.amazon.com/q/pricing/' +export const qProName = 'Q Developer Pro' + +export const upgradeQButton: ChatItemButton = { + id: 'paidtier-upgrade-q', + flash: 'once', + fillState: 'always', + position: 'inside', + icon: 'external', + // https://github.com/aws/mynah-ui/blob/main/src/components/icon/icons/q.svg + // https://github.com/aws/mynah-ui/blob/main/src/components/icon/icons/rocket.svg + // icon: MynahIcons.Q, + text: `Subscribe to ${qProName}`, + // description: `Upgrade to ${qProName}`, + status: 'primary', + disabled: false, +} + +export const learnMoreButton: ChatItemButton = { + id: 'paidtier-upgrade-q-learnmore', + fillState: 'hover', + // position: 'inside', + icon: 'external', + description: `Learn about ${qProName}`, + text: 'Learn more', + status: 'info', + disabled: false, +} + +export const continueUpgradeQButton: ChatItemButton = { + id: 'paidtier-upgrade-q-continue', + icon: 'rocket', + flash: 'once', + fillState: 'hover', + position: 'inside', + // description: `Link an AWS account to upgrade ${qProName}`, + text: 'Continue', + disabled: false, +} + +export const freeTierLimitDirective: ChatItem = { + type: ChatItemType.DIRECTIVE, + messageId: 'freetier-limit-directive', + fullWidth: true, + contentHorizontalAlignment: 'center', + canBeDismissed: false, + body: 'Unable to send. Monthly invocation limit met for this month.', +} + +/** "Banner" (sticky card) shown above the chat prompt. */ +export const freeTierLimitSticky: Partial = { + messageId: 'freetier-limit-banner', + body: `To increase your limit, subscribe to ${qProName}. During the upgrade, you'll be asked to link your Builder ID to the AWS account that will be billed the monthly subscription fee. Learn more about [pricing >](${paidTierLearnMoreUrl})`, + buttons: [upgradeQButton], + header: { + icon: 'warning', + iconStatus: 'warning', + body: '### Monthly request limit reached', + }, + canBeDismissed: false, +} + +export const upgradePendingSticky: Partial = { + messageId: 'upgrade-pending-banner', + body: freeTierLimitSticky.body, + buttons: [upgradeQButton], + header: { + icon: 'progress', + iconStatus: undefined, + body: '### Waiting for subscription status...', + }, + canBeDismissed: true, +} + +export const upgradeSuccessSticky: Partial = { + messageId: 'upgrade-success-banner', + // body: `Successfully upgraded to ${qProName}.`, + status: 'success', + buttons: [], + // icon: 'q', + // iconStatus: 'success', + header: { + icon: 'ok-circled', + iconStatus: 'success', + body: `Successfully upgraded to ${qProName}.`, + // status: { + // status: 'success', + // position: 'right', + // text: `Successfully upgraded to ${qProName}.`, + // }, + }, + canBeDismissed: true, +} + +export const paidTierInfoCard: ChatItem = { + type: ChatItemType.ANSWER, + title: 'UPGRADE TO AMAZON Q PRO', + buttons: [upgradeQButton], + header: { + icon: 'q', + iconStatus: 'primary', + body: `This feature requires a subscription to ${qProName}.`, + status: { + status: 'info', + icon: 'q', + }, + }, + body: `Upgrade to ${qProName}. [Learn More...](${paidTierLearnMoreUrl})`, + messageId: 'paidtier-info', + fullWidth: true, + canBeDismissed: true, + snapToTop: true, +} + +export const paidTierSuccessCard: ChatItem = { + type: ChatItemType.ANSWER, + title: 'UPGRADED TO AMAZON Q PRO', + header: { + icon: 'q', + iconStatus: 'primary', + body: `Welcome to ${qProName}`, + status: { + status: 'success', + icon: 'q', + text: 'Success', + }, + }, + messageId: 'paidtier-success', + fullWidth: true, + canBeDismissed: true, + body: `Upgraded to ${qProName}\n\n[Learn More...](${paidTierLearnMoreUrl})`, + snapToTop: true, +} + +export const paidTierPromptInput: TextBasedFormItem = { + placeholder: '111111111111', + type: 'textinput', + id: 'paid-tier', + // tooltip: `Upgrade to ${qProName}`, + value: 'true', + icon: 'magic', +} + +export const paidTierStep0: ChatItem = { + type: ChatItemType.DIRECTIVE, + body: `You have upgraded to ${qProName}`, +} + +export const paidTierStep1: ChatItem = { + type: ChatItemType.DIRECTIVE, + body: `You have upgraded to ${qProName}`, +} + +/** "Upgrade Q" form with a "AWS account id" user-input textbox. */ +export const paidTierUpgradeForm: ChatItem = { + type: ChatItemType.ANSWER, + status: 'info', + fullWidth: true, + // title: 'Connect AWS account and upgrade', + body: ` +# Connect AWS account and upgrade + +Provide your AWS account number to enable your ${qProName} subscription. Upon confirming the subscription, your AWS account will begin to be charged. + +[Learn More...](${paidTierLearnMoreUrl}) +`, + formItems: [ + { + id: 'awsAccountId', + type: 'textinput', + title: 'AWS account ID', + description: '12-digit AWS account ID', + // tooltip: `Link an AWS account to upgrade to ${qProName}`, + validationPatterns: { + patterns: [{ pattern: '[0-9]{12}', errorMessage: 'Must be a valid 12-digit AWS account ID' }], + }, + }, + ], + buttons: [continueUpgradeQButton], + snapToTop: 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 ea8049694e..c951f7ffe8 100644 --- a/chat-client/src/client/utils.ts +++ b/chat-client/src/client/utils.ts @@ -12,8 +12,12 @@ export function toMynahButtons(buttons: Button[] | undefined): ChatItemButton[] export function toMynahHeader(header: ChatMessage['header']): ChatItemContent['header'] { if (!header) return undefined + + // Create a new object with only the properties that are compatible with ChatItemContent['header'] + const { summary, ...headerWithoutSummary } = header + return { - ...header, + ...headerWithoutSummary, icon: toMynahIcon(header.icon), buttons: toMynahButtons(header.buttons), status: header.status ? { ...header.status, icon: toMynahIcon(header.status.icon) } : undefined, @@ -70,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 b52723157f..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. @@ -57,7 +59,11 @@ export const withAdapter = ( onChatPromptProgressActionButtonClicked: addDefaultRouting('onChatPromptProgressActionButtonClicked'), onTabbedContentTabChange: addDefaultRouting('onTabbedContentTabChange'), onPromptInputOptionChange: addDefaultRouting('onPromptInputOptionChange'), + onPromptInputButtonClick: addDefaultRouting('onPromptInputButtonClick'), onMessageDismiss: addDefaultRouting('onMessageDismiss'), + onPromptTopBarItemAdded: addDefaultRouting('onPromptTopBarItemAdded'), + onPromptTopBarItemRemoved: addDefaultRouting('onPromptTopBarItemRemoved'), + onPromptTopBarButtonClick: addDefaultRouting('onPromptTopBarButtonClick'), /** * Handler with special routing logic @@ -69,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) @@ -120,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 82cc542126..01d9d19c22 100644 --- a/chat-client/src/contracts/chatClientAdapter.ts +++ b/chat-client/src/contracts/chatClientAdapter.ts @@ -36,7 +36,13 @@ export interface ChatEventHandler | 'onResetStore' | 'onReady' | '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 c44669ed09..af4675706b 100644 --- a/chat-client/src/contracts/serverContracts.ts +++ b/chat-client/src/contracts/serverContracts.ts @@ -29,12 +29,26 @@ import { ListConversationsParams, CONVERSATION_CLICK_REQUEST_METHOD, ConversationClickParams, + McpServerClickParams, + ListMcpServersParams, + LIST_MCP_SERVERS_REQUEST_METHOD, + MCP_SERVER_CLICK_REQUEST_METHOD, GET_SERIALIZED_CHAT_REQUEST_METHOD, TAB_BAR_ACTION_REQUEST_METHOD, TabBarActionParams, 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' @@ -56,11 +70,19 @@ 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 | typeof TAB_BAR_ACTION_REQUEST_METHOD | 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 @@ -89,5 +111,11 @@ export type ServerMessageParams = | FileClickParams | ListConversationsParams | ConversationClickParams + | ListMcpServersParams + | 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 e45416095d..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.90", + "@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