diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9be205f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# Dependabot configuration: +# https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + # Maintain dependencies for Gradle dependencies + - package-ecosystem: "gradle" + directory: "/" + target-branch: "next" + schedule: + interval: "daily" + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "next" + schedule: + interval: "daily" diff --git a/.github/readme/demo-chat-hello-world-01.png b/.github/readme/demo-chat-hello-world-01.png new file mode 100644 index 0000000..c6e82d3 Binary files /dev/null and b/.github/readme/demo-chat-hello-world-01.png differ diff --git a/.github/readme/demo-chat-hello-world-02.png b/.github/readme/demo-chat-hello-world-02.png new file mode 100644 index 0000000..e0ea341 Binary files /dev/null and b/.github/readme/demo-chat-hello-world-02.png differ diff --git a/.github/readme/demo-chat-merge.png b/.github/readme/demo-chat-merge.png new file mode 100644 index 0000000..6db178e Binary files /dev/null and b/.github/readme/demo-chat-merge.png differ diff --git a/.github/readme/demo-chat.png b/.github/readme/demo-chat.png new file mode 100644 index 0000000..8bd3c22 Binary files /dev/null and b/.github/readme/demo-chat.png differ diff --git a/.github/readme/demo-inline.png b/.github/readme/demo-inline.png new file mode 100644 index 0000000..529318a Binary files /dev/null and b/.github/readme/demo-inline.png differ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7ea7376 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,257 @@ +# GitHub Actions Workflow is created for testing and preparing the plugin release in the following steps: +# - Validate Gradle Wrapper. +# - Run 'test' and 'verifyPlugin' tasks. +# - Run Qodana inspections. +# - Run the 'buildPlugin' task and prepare artifact for further tests. +# - Run the 'runPluginVerifier' task. +# - Create a draft release. +# +# The workflow is triggered on push and pull_request events. +# +# GitHub Actions reference: https://help.github.com/en/actions +# +## JBIJPPTPL + +name: Build +on: + # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g., for dependabot pull requests) + push: + branches: [ main ] + # Trigger the workflow on any pull request + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + + # Prepare environment and build the plugin + build: + name: Build + runs-on: ubuntu-latest + outputs: + version: ${{ steps.properties.outputs.version }} + changelog: ${{ steps.properties.outputs.changelog }} + pluginVerifierHomeDir: ${{ steps.properties.outputs.pluginVerifierHomeDir }} + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Validate wrapper + - name: Gradle Wrapper Validation + uses: gradle/actions/wrapper-validation@v3 + + # Set up Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + # Set environment variables + - name: Export Properties + id: properties + shell: bash + run: | + PROPERTIES="$(./gradlew properties --console=plain -q)" + VERSION="$(echo "$PROPERTIES" | grep "^version:" | cut -f2- -d ' ')" + CHANGELOG="$(./gradlew getChangelog --unreleased --no-header --console=plain -q)" + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "pluginVerifierHomeDir=~/.pluginVerifier" >> $GITHUB_OUTPUT + + echo "changelog<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # Build plugin + - name: Build plugin + run: ./gradlew buildPlugin + + # Prepare plugin archive content for creating artifact + - name: Prepare Plugin Artifact + id: artifact + shell: bash + run: | + cd ${{ github.workspace }}/build/distributions + FILENAME=`ls *.zip` + unzip "$FILENAME" -d content + + echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT + + # Store already-built plugin as an artifact for downloading + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact.outputs.filename }} + path: ./build/distributions/content/*/* + + # Run tests and upload a code coverage report + test: + name: Test + needs: [ build ] + runs-on: ubuntu-latest + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Set up Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + # Run tests + - name: Run Tests + run: ./gradlew check + + # Collect Tests Result of failed tests + - name: Collect Tests Result + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: tests-result + path: ${{ github.workspace }}/build/reports/tests + + # Upload the Kover report to CodeCov + - name: Upload Code Coverage Report + uses: codecov/codecov-action@v4 + with: + files: ${{ github.workspace }}/build/reports/kover/report.xml + + # Run Qodana inspections and provide report + inspectCode: + name: Inspect code + needs: [ build ] + runs-on: ubuntu-latest + permissions: + contents: write + checks: write + pull-requests: write + steps: + + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + large-packages: false + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit + fetch-depth: 0 # a full history is required for pull request analysis + + # Set up Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + # Run Qodana inspections + - name: Qodana - Code Inspection + uses: JetBrains/qodana-action@v2024.2 + with: + cache-default-branch-only: true + + # Run plugin structure verification along with IntelliJ Plugin Verifier + verify: + name: Verify plugin + needs: [ build ] + runs-on: ubuntu-latest + steps: + + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + large-packages: false + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Set up Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + # Cache Plugin Verifier IDEs + - name: Setup Plugin Verifier IDEs Cache + uses: actions/cache@v4 + with: + path: ${{ needs.build.outputs.pluginVerifierHomeDir }}/ides + key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} + + # Run Verify Plugin task and IntelliJ Plugin Verifier tool + - name: Run Plugin Verification tasks + run: ./gradlew verifyPlugin -Dplugin.verifier.home.dir=${{ needs.build.outputs.pluginVerifierHomeDir }} + + # Collect Plugin Verifier Result + - name: Collect Plugin Verifier Result + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: pluginVerifier-result + path: ${{ github.workspace }}/build/reports/pluginVerifier + + # Prepare a draft release for GitHub Releases page for the manual verification + # If accepted and published, release workflow would be triggered + releaseDraft: + name: Release draft + if: github.event_name != 'pull_request' + needs: [ build, test, inspectCode, verify ] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Remove old release drafts by using the curl request for the available releases with a draft flag + - name: Remove Old Release Drafts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api repos/{owner}/{repo}/releases \ + --jq '.[] | select(.draft == true) | .id' \ + | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} + + # Create a new release draft which is not publicly visible and requires manual acceptance + - name: Create Release Draft + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ needs.build.outputs.version }}" \ + --draft \ + --title "v${{ needs.build.outputs.version }}" \ + --notes "$(cat << 'EOM' + ${{ needs.build.outputs.changelog }} + EOM + )" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5174ab9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,101 @@ +# GitHub Actions Workflow created for handling the release process based on the draft release prepared with the Build workflow. +# Running the publishPlugin task requires all following secrets to be provided: PUBLISH_TOKEN, PRIVATE_KEY, PRIVATE_KEY_PASSWORD, CERTIFICATE_CHAIN. +# See https://plugins.jetbrains.com/docs/intellij/plugin-signing.html for more information. + +name: Release +on: + release: + types: [prereleased, released] + +jobs: + + # Prepare and publish the plugin to JetBrains Marketplace repository + release: + name: Publish Plugin + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + # Set up Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + # Set environment variables + - name: Export Properties + id: properties + shell: bash + run: | + CHANGELOG="$(cat << 'EOM' | sed -e 's/^[[:space:]]*$//g' -e '/./,$!d' + ${{ github.event.release.body }} + EOM + )" + + echo "changelog<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # Update the Unreleased section with the current release note + - name: Patch Changelog + if: ${{ steps.properties.outputs.changelog != '' }} + env: + CHANGELOG: ${{ steps.properties.outputs.changelog }} + run: | + ./gradlew patchChangelog --release-note="$CHANGELOG" + + # Publish the plugin to JetBrains Marketplace + - name: Publish Plugin + env: + PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} + run: ./gradlew publishPlugin + + # Upload artifact as a release asset + - name: Upload Release Asset + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* + + # Create a pull request + - name: Create Pull Request + if: ${{ steps.properties.outputs.changelog != '' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ github.event.release.tag_name }}" + BRANCH="changelog-update-$VERSION" + LABEL="release changelog" + + git config user.email "action@github.com" + git config user.name "GitHub Action" + + git checkout -b $BRANCH + git commit -am "Changelog update - $VERSION" + git push --set-upstream origin $BRANCH + + gh label create "$LABEL" \ + --description "Pull requests with release changelog update" \ + --force \ + || true + + gh pr create \ + --title "Changelog update - \`$VERSION\`" \ + --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ + --label "$LABEL" \ + --head $BRANCH diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48b8746 --- /dev/null +++ b/.gitignore @@ -0,0 +1,109 @@ +# From https://github.com/github/gitignore/blob/master/Gradle.gitignore +.gradle +/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties + + + +# From https://github.com/github/gitignore/blob/master/Java.gitignore +*.class + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + + +# From https://github.com/github/gitignore/blob/master/Global/JetBrains.gitignore +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/workspace.xml +.idea/tasks.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml + +# Sensitive or high-churn files: +.idea/dataSources.ids +.idea/dataSources.xml +.idea/dataSources.local.xml +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + + +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +/.idea/ +/.intellijPlatform/ diff --git a/.run/Run Plugin.run.xml b/.run/Run Plugin.run.xml new file mode 100644 index 0000000..00a760e --- /dev/null +++ b/.run/Run Plugin.run.xml @@ -0,0 +1,25 @@ + + + + + + + + true + true + false + false + + + diff --git a/.run/Run Tests.run.xml b/.run/Run Tests.run.xml new file mode 100644 index 0000000..f281bdc --- /dev/null +++ b/.run/Run Tests.run.xml @@ -0,0 +1,25 @@ + + + + + + + + true + true + false + true + + + diff --git a/.run/Run Verifications.run.xml b/.run/Run Verifications.run.xml new file mode 100644 index 0000000..32783f5 --- /dev/null +++ b/.run/Run Verifications.run.xml @@ -0,0 +1,25 @@ + + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4489cf1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ + + +# InCoder Changelog + +## [Unreleased] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..672c240 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 - Damiano Derin + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a7938f --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# InCoder: An LLM-Powered Plugin for JetBrains IDEs + + +**InCoder** is a powerful plugin designed for JetBrains IDEs, including IntelliJ IDEA, PyCharm, and others in the JetBrains ecosystem. +It seamlessly integrates advanced Large Language Models (LLMs) into your development workflow, providing you with code generation, understanding, and completion capabilities, all directly within your favorite IDE. + + +

+ Demo Chat Hello World 01 + Demo Chat Hello World 02 +

+ + +--- + +## Key Features + +### 1. **Interactive Chat for Code Assistance** + +

+ Demo Chat + Demo Chat Merge +

+ + +- A dedicated **tool window** appears on the right panel of the IDE after installing the plugin. +- Interact with an LLM to: + - **Generate code snippets** or solve coding challenges. + - **Understand code** by analyzing the file or specific lines you're viewing. + - Get contextual suggestions and explanations based on the active file you're working on, as the LLM has access to the current code. + +### 2. **Inline Code Completion** + +![Demo Inline](.github/readme/demo-inline.png) + +- **Real-time suggestions** while you type in the editor. +- Press **Tab** to accept the suggested code and insert it directly into your file. +- Accelerate your coding workflow with intelligent autocompletion that understands the context of your project. + +### 3. **Support for Multiple LLM Providers** +- InCoder supports **multiple LLM providers**, giving you flexibility and choice: + - **Ollama**: Utilize models running locally on your machine to preserve privacy. + - **OpenAI**: Access advanced cloud-based LLMs for high-quality suggestions and assistance. + - **Anthropic**: Integrate with this leading LLM provider for ethical and powerful AI capabilities. +- All providers can be easily **configured** through the plugin's settings, allowing you to choose the one that best fits your needs. + +### 4. **Privacy-Focused Local LLM Support** +- By using **Ollama**, InCoder enables local LLM inference directly on your computer. +- Keeps sensitive project data private and ensures compliance with internal security policies. +- Ideal for developers who value privacy and want to avoid sending data to external servers. + +## Settings +The plugin is highly customizable through the **InCoder Settings** panel in your IDE. Access it via the settings/preferences menu under the section **InCoder**. +Configuration is divided into three main subsections: + +- **Chat**: Configure settings for the interactive chat window, such as history retention and UI preferences. +- **Inline**: Enable or disable inline code completion and customize the behavior (e.g., auto-suggestions, Tab behavior). +- **Server**: Set up your preferred LLM provider. + +--- + +## Benefits of Using InCoder +- **Boost productivity**: Generate boilerplate code, debug faster, and understand unfamiliar codebases more quickly. +- **Enhanced contextual understanding**: The LLM has access to your active file, ensuring accurate and relevant suggestions tailored to your project. +- **Customizable and flexible**: Easily switch between LLM providers to adapt to your workflow or organizational requirements. +- **Seamless integration**: Designed specifically for JetBrains IDEs, ensuring an intuitive and native user experience. + +## Experimental Features +- **Inline code completion** is currently experimental but highly promising for real-time code assistance and efficiency. Feedback is welcome to improve this feature further. + +## Supported JetBrains IDEs +While InCoder is primarily tested on **IntelliJ IDEA** and **PyCharm**, it is compatible with most JetBrains IDEs, making it a versatile choice for developers working in diverse environments. + +--- + +## Contribution +Contributions are welcome! If you'd like to help improve InCoder, follow these steps: +1. Fork the repository. +2. Create a new branch for your feature or bug fix. +3. Commit your changes and submit a pull request. +4. Open **issues** for suggestions, bug reports, or enhancements. + +We follow a standard GitHub workflow, so feel free to contribute as you'd like! + +--- + +## License +This project is licensed under the **MIT License**. +You are free to use, modify, and distribute the code. See the [LICENSE](LICENSE) file for more details. + +--- + +Empower your coding experience with **InCoder** and unlock the full potential of AI-driven development assistance. +Whether you're generating code, understanding complex algorithms, or seeking intelligent completions, InCoder is here to make your workflow smarter, faster, and more secure. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..dffd633 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,181 @@ +import org.jetbrains.changelog.Changelog +import org.jetbrains.changelog.markdownToHTML +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + +plugins { + id("java") + alias(libs.plugins.kotlin) // Kotlin support + alias(libs.plugins.intelliJPlatform) // IntelliJ Platform Gradle Plugin + alias(libs.plugins.changelog) // Gradle Changelog Plugin + alias(libs.plugins.qodana) // Gradle Qodana Plugin + alias(libs.plugins.kover) // Gradle Kover Plugin + alias(libs.plugins.lombokKotlin) // Kotlin Lombok Plugin + alias(libs.plugins.lombok) // Lombok Plugin + alias(libs.plugins.spotless) // Spotless Plugin +} + +group = providers.gradleProperty("pluginGroup").get() +version = providers.gradleProperty("pluginVersion").get() + +// Set the JVM language level used to build the project. +kotlin { + jvmToolchain(17) +} + +// Configure project's dependencies +repositories { + mavenCentral() + + // IntelliJ Platform Gradle Plugin Repositories Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-repositories-extension.html + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + testImplementation(libs.junit) + + implementation("com.google.code.findbugs:jsr305:3.0.2") + implementation("ch.qos.logback:logback-classic:1.5.6") + implementation("com.vladsch.flexmark:flexmark-all:0.64.8") + implementation("org.reflections:reflections:0.10.2") + + implementation("dev.langchain4j:langchain4j:0.36.2") + implementation("dev.langchain4j:langchain4j-ollama:0.36.2") + implementation("dev.langchain4j:langchain4j-open-ai:0.36.2") + implementation("dev.langchain4j:langchain4j-anthropic:0.36.2") + + // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html + intellijPlatform { + create(providers.gradleProperty("platformType"), providers.gradleProperty("platformVersion")) + + // Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins. + bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') }) + + // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file for plugin from JetBrains Marketplace. + plugins(providers.gradleProperty("platformPlugins").map { it.split(',') }) + + pluginVerifier() + zipSigner() + testFramework(TestFrameworkType.Platform) + } +} + +spotless { + kotlin { + ktlint() + } + kotlinGradle { + ktlint() + } + java { + googleJavaFormat().aosp().reflowLongStrings() + } +} + +// Configure IntelliJ Platform Gradle Plugin - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-extension.html +intellijPlatform { + pluginConfiguration { + version = providers.gradleProperty("pluginVersion") + + // Extract the section from README.md and provide for the plugin's manifest + description = providers.fileContents(layout.projectDirectory.file("README.md")).asText.map { + val start = "" + val end = "" + + with(it.lines()) { + if (!containsAll(listOf(start, end))) { + throw GradleException("Plugin description section not found in README.md:\n$start ... $end") + } + subList(indexOf(start) + 1, indexOf(end)).joinToString("\n").let(::markdownToHTML) + } + } + + val changelog = project.changelog // local variable for configuration cache compatibility + // Get the latest available change notes from the changelog file + changeNotes = providers.gradleProperty("pluginVersion").map { pluginVersion -> + with(changelog) { + renderItem( + (getOrNull(pluginVersion) ?: getUnreleased()) + .withHeader(false) + .withEmptySections(false), + Changelog.OutputType.HTML, + ) + } + } + + ideaVersion { + sinceBuild = providers.gradleProperty("pluginSinceBuild") + untilBuild = providers.gradleProperty("pluginUntilBuild") + } + } + + signing { + certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN") + privateKey = providers.environmentVariable("PRIVATE_KEY") + password = providers.environmentVariable("PRIVATE_KEY_PASSWORD") + } + + publishing { + token = providers.environmentVariable("PUBLISH_TOKEN") + // The pluginVersion is based on the SemVer (https://semver.org) and supports pre-release labels, like 2.1.7-alpha.3 + // Specify pre-release label to publish the plugin in a custom Release Channel automatically. Read more: + // https://plugins.jetbrains.com/docs/intellij/deployment.html#specifying-a-release-channel + channels = providers.gradleProperty("pluginVersion") + .map { listOf(it.substringAfter('-', "").substringBefore('.').ifEmpty { "default" }) } + } + + pluginVerification { + ides { + recommended() + } + } +} + +// Configure Gradle Changelog Plugin - read more: https://github.com/JetBrains/gradle-changelog-plugin +changelog { + groups.empty() + repositoryUrl = providers.gradleProperty("pluginRepositoryUrl") +} + +// Configure Gradle Kover Plugin - read more: https://github.com/Kotlin/kotlinx-kover#configuration +kover { + reports { + total { + xml { + onCheck = true + } + } + } +} + +tasks { + wrapper { + gradleVersion = providers.gradleProperty("gradleVersion").get() + } + + publishPlugin { + dependsOn(patchChangelog) + } +} + +intellijPlatformTesting { + runIde { + register("runIdeForUiTests") { + task { + jvmArgumentProviders += CommandLineArgumentProvider { + listOf( + "-Drobot-server.port=8082", + "-Dide.mac.message.dialogs.as.sheets=false", + "-Djb.privacy.policy.text=", + "-Djb.consents.confirmation.enabled=false", + ) + } + } + + plugins { + robotServerPlugin() + } + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..05d03cb --- /dev/null +++ b/gradle.properties @@ -0,0 +1,25 @@ +# IntelliJ Platform Artifacts Repositories -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html +pluginGroup=com.github.damiano1996.intellijplugin.incoder +pluginName=InCoder +pluginRepositoryUrl=https://github.com/damiano1996/incoder-plugin +# SemVer format -> https://semver.org +pluginVersion=0.0.0 +# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html +pluginSinceBuild=233 +pluginUntilBuild=242.* +# IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension +platformType=IC +platformVersion=2023.3.8 +# Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html +# Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP +platformPlugins= +# Example: platformBundledPlugins = com.intellij.java +platformBundledPlugins=com.intellij.java +# Gradle Releases -> https://github.com/gradle/gradle/releases +gradleVersion=8.10.2 +# Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib +kotlin.stdlib.default.dependency=false +# Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html +org.gradle.configuration-cache=true +# Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html +org.gradle.caching=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..9f74908 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,26 @@ +[versions] +# libraries +junit = "4.13.2" + +# plugins +changelog = "2.2.1" +intelliJPlatform = "2.2.0" +kotlin = "1.9.25" +kover = "0.8.3" +qodana = "2024.2.3" +lombokKotlin = "2.0.0" +lombok = "8.1.0" +spotless = "6.21.0" + +[libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } + +[plugins] +changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } +intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } +kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } +qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } +lombokKotlin = { id = "org.jetbrains.kotlin.plugin.lombok", version.ref = "lombokKotlin" } +lombok = { id = "io.freefair.lombok", version.ref = "lombok" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/qodana.yml b/qodana.yml new file mode 100644 index 0000000..a502427 --- /dev/null +++ b/qodana.yml @@ -0,0 +1,12 @@ +# Qodana configuration: +# https://www.jetbrains.com/help/qodana/qodana-yaml.html + +version: 1.0 +linter: jetbrains/qodana-jvm-community:2024.2 +projectJDK: "17" +profile: + name: qodana.recommended +exclude: + - name: All + paths: + - .qodana diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..9a8049e --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +rootProject.name = "InCoder" + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/InCoderActivity.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/InCoderActivity.java new file mode 100644 index 0000000..5751dcb --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/InCoderActivity.java @@ -0,0 +1,60 @@ +package com.github.damiano1996.jetbrains.incoder; + +import com.github.damiano1996.jetbrains.incoder.completion.CodeCompletionService; +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelException; +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelService; +import com.github.damiano1996.jetbrains.incoder.notification.NotificationService; +import com.github.damiano1996.jetbrains.incoder.settings.PluginSettings; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.ProjectActivity; +import java.awt.*; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class InCoderActivity implements ProjectActivity { + + @Override + public Object execute( + @NotNull Project project, @NotNull Continuation continuation) { + log.debug("New project opened."); + + if (PluginSettings.getInstance().getState().isFirstPluginRun) { + NotificationService.getInstance(project).notifyWelcome(); + PluginSettings.getInstance().getState().isFirstPluginRun = false; + } + + initServices(project); + return null; + } + + private static void initServices(@NotNull Project project) { + EventQueue.invokeLater( + () -> { + try { + log.debug("Initializing services..."); + LanguageModelService.getInstance(project).init(); + CodeCompletionService.getInstance(project).init(); + log.debug("Services initialized."); + } catch (LanguageModelException e) { + log.warn("Unable to init services.", e); + + if (PluginSettings.getInstance().getState().isPluginConfigured) { + log.debug("Plugin is configured, notifying with error."); + NotificationService.getInstance(project) + .notifyWithSettingsActionButton( + e.getMessage(), NotificationType.ERROR); + } else { + log.debug( + "Plugin is not configured. " + + "Showing default message with settings button."); + NotificationService.getInstance(project) + .notifyWithSettingsActionButton(); + } + } + }); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/InCoderBundle.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/InCoderBundle.java new file mode 100644 index 0000000..4b405eb --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/InCoderBundle.java @@ -0,0 +1,23 @@ +package com.github.damiano1996.jetbrains.incoder; + +import com.intellij.DynamicBundle; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.PropertyKey; + +public class InCoderBundle extends DynamicBundle { + + private static final String PATH_TO_BUNDLE = "messages.bundle"; + private static final InCoderBundle instance = new InCoderBundle(); + + @Contract(pure = true) + public static @Nls @NotNull String message( + @NotNull @PropertyKey(resourceBundle = PATH_TO_BUNDLE) String key, Object... params) { + return instance.getMessage(key, params); + } + + private InCoderBundle() { + super(PATH_TO_BUNDLE); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/InCoderIcons.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/InCoderIcons.java new file mode 100644 index 0000000..af107fa --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/InCoderIcons.java @@ -0,0 +1,14 @@ +package com.github.damiano1996.jetbrains.incoder; + +import com.intellij.openapi.util.IconLoader; +import javax.swing.*; + +/** This class provides access to icons used in the InCoder plugin. */ +public class InCoderIcons { + /** + * The icon representing the InCoder plugin. It is loaded from the resource file located at + * /META-INF/pluginIcon.svg. + */ + public static Icon PLUGIN_ICON = + IconLoader.getIcon("/META-INF/pluginIcon.svg", InCoderIcons.class); +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionAction.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionAction.java new file mode 100644 index 0000000..52b1357 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionAction.java @@ -0,0 +1,30 @@ +package com.github.damiano1996.jetbrains.incoder.completion; + +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.ex.AnActionListener; +import com.intellij.openapi.project.Project; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class CodeCompletionAction extends AnAction implements AnActionListener { + + @Override + public void actionPerformed(@NotNull AnActionEvent anActionEvent) { + Project project = anActionEvent.getProject(); + if (project == null) return; + + log.debug("Performing code completion action for project: {}", project.getName()); + CodeCompletionService.getInstance(project).actionPerformed(anActionEvent); + } + + @Override + public void beforeActionPerformed(@NotNull AnAction action, @NotNull AnActionEvent event) { + Project project = event.getProject(); + if (project == null) return; + + log.debug("Performing code completion action for project: {}", project.getName()); + CodeCompletionService.getInstance(project).beforeActionPerformed(action, event); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionContext.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionContext.java new file mode 100644 index 0000000..1d79192 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionContext.java @@ -0,0 +1,3 @@ +package com.github.damiano1996.jetbrains.incoder.completion; + +public record CodeCompletionContext(String leftContext, String rightContext) {} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionListener.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionListener.java new file mode 100644 index 0000000..0adf802 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionListener.java @@ -0,0 +1,19 @@ +package com.github.damiano1996.jetbrains.incoder.completion; + +/** Interface for handling code completion events in the InCoder plugin. */ +public interface CodeCompletionListener { + + /** + * Called when a code completion prediction is available. + * + * @param prediction The predicted code snippet or suggestion. + */ + void onCodeCompletionPrediction(String prediction); + + /** + * Called when an error occurs during code completion processing. + * + * @param throwable The exception that caused the error. + */ + void onCodeCompletionError(Throwable throwable); +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionQueue.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionQueue.java new file mode 100644 index 0000000..b7c7f53 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionQueue.java @@ -0,0 +1,80 @@ +package com.github.damiano1996.jetbrains.incoder.completion; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelService; +import com.intellij.openapi.project.Project; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CodeCompletionQueue implements Runnable { + + public static final String MARKDOWN_CODE_BLOCK_DELIMITER = "```"; + + private final Project project; + private final CodeCompletionListener listener; + + private final BlockingQueue queue = new ArrayBlockingQueue<>(1); + + public CodeCompletionQueue(Project project, CodeCompletionListener listener) { + this.project = project; + this.listener = listener; + } + + public void enqueue(CodeCompletionContext codeCompletionContext) { + log.debug("Adding new code completion context to queue"); + try { + if (!queue.offer(codeCompletionContext)) { + queue.poll(); + queue.put(codeCompletionContext); + } + } catch (InterruptedException e) { + log.warn("Unable to put element in queue. {}", e.getMessage()); + } + } + + @Override + public void run() { + try { + //noinspection InfiniteLoopStatement + while (true) { + + log.debug("Taking request"); + CodeCompletionContext codeCompletionContext = queue.take(); + + log.debug("Consuming code completion request"); + + try { + String completion = + LanguageModelService.getInstance(project) + .complete(codeCompletionContext) + .split("\n")[0] + .trim(); + + log.debug( + "{}\nContinues with:\n{}", + codeCompletionContext.leftContext(), + completion); + + if (completion.startsWith(MARKDOWN_CODE_BLOCK_DELIMITER)) continue; + + if (queue.isEmpty()) { + log.debug("Queue is empty, therefore this prediction is still useful"); + + listener.onCodeCompletionPrediction(completion); + } else { + log.debug("Queue is not empty. Prediction is obsolete"); + } + + } catch (Exception e) { + log.warn("Something went wrong while completing code", e); + listener.onCodeCompletionError(e); + } + } + + } catch (InterruptedException e) { + log.error("Error while processing queued request.", e); + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionService.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionService.java new file mode 100644 index 0000000..e816808 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/CodeCompletionService.java @@ -0,0 +1,114 @@ +package com.github.damiano1996.jetbrains.incoder.completion; + +import com.github.damiano1996.jetbrains.incoder.completion.states.State; +import com.github.damiano1996.jetbrains.incoder.completion.states.idle.IdleState; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.ex.AnActionListener; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.EditorFactory; +import com.intellij.openapi.editor.event.DocumentEvent; +import com.intellij.openapi.editor.event.DocumentListener; +import com.intellij.openapi.editor.event.EditorMouseEvent; +import com.intellij.openapi.editor.event.EditorMouseListener; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.project.Project; +import java.util.concurrent.CompletableFuture; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Service(Service.Level.PROJECT) +@Slf4j +public final class CodeCompletionService + implements AnActionListener, + CodeCompletionListener, + DocumentListener, + EditorMouseListener, + Disposable { + + private final Project project; + private final CodeCompletionQueue codeCompletionQueue; + private State state; + + public CodeCompletionService(Project project) { + this.project = project; + this.state = new IdleState(this); + + log.debug("Staring async code completion queue..."); + codeCompletionQueue = new CodeCompletionQueue(project, this); + CompletableFuture.runAsync(codeCompletionQueue); + } + + public static CodeCompletionService getInstance(@NotNull Project project) { + return project.getService(CodeCompletionService.class); + } + + public void init() { + log.debug("Initializing {}...", CodeCompletionService.class.getSimpleName()); + log.debug("Adding listener for document"); + EditorFactory.getInstance().getEventMulticaster().addDocumentListener(this, this); + log.debug("Adding listeners for mouse"); + EditorFactory.getInstance().getEventMulticaster().addEditorMouseListener(this, this); + } + + public void next(@NotNull State state) { + this.state = state; + } + + public void enqueue(CodeCompletionContext codeCompletionContext) { + codeCompletionQueue.enqueue(codeCompletionContext); + } + + public @Nullable Editor getEditor() { + return FileEditorManager.getInstance(project).getSelectedTextEditor(); + } + + @Override + public void onCodeCompletionPrediction(String prediction) { + log.debug( + "Prediction received: {}. Going to execute state {}", + prediction, + getStateSimpleName()); + this.state.onCodeCompletionPrediction(prediction); + } + + @Override + public void onCodeCompletionError(Throwable throwable) { + this.state.onCodeCompletionError(throwable); + } + + public void actionPerformed(@NotNull AnActionEvent anActionEvent) { + log.debug("Action performed. Going to execute state {}", getStateSimpleName()); + this.state.actionPerformed(anActionEvent); + } + + @Override + public void beforeActionPerformed(@NotNull AnAction action, @NotNull AnActionEvent event) { + log.debug("Before action performed. Going to execute state {}", getStateSimpleName()); + this.state.beforeActionPerformed(action, event); + } + + @Override + public void documentChanged(@NotNull DocumentEvent event) { + log.debug("Document has changed. Going to execute state {}", getStateSimpleName()); + this.state.documentChanged(event); + } + + @Override + public void mouseClicked(@NotNull EditorMouseEvent event) { + EditorMouseListener.super.mouseClicked(event); + log.debug("Mouse clicked. Going to execute state {}", getStateSimpleName()); + + this.state.mouseClicked(event); + } + + private @NotNull String getStateSimpleName() { + return state.getClass().getSimpleName(); + } + + @Override + public void dispose() {} +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/BaseState.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/BaseState.java new file mode 100644 index 0000000..7787df0 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/BaseState.java @@ -0,0 +1,56 @@ +package com.github.damiano1996.jetbrains.incoder.completion.states; + +import com.github.damiano1996.jetbrains.incoder.completion.CodeCompletionService; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.editor.event.DocumentEvent; +import com.intellij.openapi.editor.event.EditorMouseEvent; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +@AllArgsConstructor +public abstract class BaseState implements State { + + protected final CodeCompletionService codeCompletionService; + + @Override + public void onCodeCompletionPrediction(String prediction) { + log.debug("Prediction received: '{}'. Executing state: {}", prediction, getClassName()); + } + + @Override + public void onCodeCompletionError(@NotNull Throwable throwable) { + log.debug( + "Prediction error received: '{}'. Executing state: {}", + throwable.getMessage(), + getClassName()); + } + + @Override + public void documentChanged(@NotNull DocumentEvent event) { + log.debug("Document changed. Executing state: {}", getClassName()); + } + + @Override + public void actionPerformed(AnActionEvent anActionEvent) { + log.debug("Action performed. Executing state: {}", getClassName()); + } + + @Override + public void beforeActionPerformed(@NotNull AnAction action, @NotNull AnActionEvent event) { + State.super.beforeActionPerformed(action, event); + log.debug("Before action performed. Executing state: {}", getClassName()); + } + + @Override + public void mouseClicked(@NotNull EditorMouseEvent event) { + State.super.mouseClicked(event); + log.debug("Mouse clicked. Executing state: {}", getClassName()); + } + + private @NotNull String getClassName() { + return this.getClass().getSimpleName(); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/State.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/State.java new file mode 100644 index 0000000..e83cf6e --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/State.java @@ -0,0 +1,13 @@ +package com.github.damiano1996.jetbrains.incoder.completion.states; + +import com.github.damiano1996.jetbrains.incoder.completion.CodeCompletionListener; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.ex.AnActionListener; +import com.intellij.openapi.editor.event.DocumentListener; +import com.intellij.openapi.editor.event.EditorMouseListener; + +public interface State + extends DocumentListener, EditorMouseListener, CodeCompletionListener, AnActionListener { + + void actionPerformed(AnActionEvent anActionEvent); +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/decision/DecisionState.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/decision/DecisionState.java new file mode 100644 index 0000000..daf3fed --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/decision/DecisionState.java @@ -0,0 +1,117 @@ +package com.github.damiano1996.jetbrains.incoder.completion.states.decision; + +import com.github.damiano1996.jetbrains.incoder.completion.CodeCompletionService; +import com.github.damiano1996.jetbrains.incoder.completion.states.BaseState; +import com.github.damiano1996.jetbrains.incoder.completion.states.idle.IdleState; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.CommonDataKeys; +import com.intellij.openapi.command.WriteCommandAction; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.event.DocumentEvent; +import com.intellij.openapi.editor.event.EditorMouseEvent; +import com.intellij.openapi.project.Project; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class DecisionState extends BaseState { + + private final String prediction; + + public DecisionState(CodeCompletionService codeCompletionService, String prediction) { + super(codeCompletionService); + this.prediction = prediction; + } + + @Override + public void actionPerformed(@NotNull AnActionEvent anActionEvent) { + super.actionPerformed(anActionEvent); + + handleAction(anActionEvent); + } + + @Override + public void beforeActionPerformed(@NotNull AnAction action, @NotNull AnActionEvent event) { + super.beforeActionPerformed(action, event); + + handleAction(event); + } + + @Override + public void mouseClicked(@NotNull EditorMouseEvent event) { + super.mouseClicked(event); + + ignorePrediction(event); + } + + private void ignorePrediction(EditorMouseEvent event) { + log.debug("Ignoring prediction due to mouse click"); + codeCompletionService.next(new IdleState(codeCompletionService)); + codeCompletionService.mouseClicked(event); + } + + private void handleAction(@NotNull AnActionEvent event) { + Project project = event.getProject(); + Editor editor = event.getData(CommonDataKeys.EDITOR); + + if (project == null || editor == null) { + log.debug("Project, or editor, was null. Returning"); + return; + } + + handleEvent(event, project, editor); + } + + private void handleEvent(@NotNull AnActionEvent anActionEvent, Project project, Editor editor) { + InputEvent inputEvent = anActionEvent.getInputEvent(); + if (inputEvent instanceof KeyEvent keyEvent) { + int keyCode = keyEvent.getKeyCode(); + + switch (keyCode) { + case KeyEvent.VK_TAB: + log.debug("TAB event detected"); + keepFullPrediction(project, editor); + break; + case KeyEvent.VK_UP: + case KeyEvent.VK_DOWN: + case KeyEvent.VK_RIGHT: + case KeyEvent.VK_LEFT: + case KeyEvent.VK_ESCAPE: + ignorePrediction(anActionEvent); + break; + } + } + } + + private void ignorePrediction(@NotNull AnActionEvent anActionEvent) { + log.debug("Ignoring prediction"); + codeCompletionService.next(new IdleState(codeCompletionService)); + codeCompletionService.actionPerformed(anActionEvent); + } + + private void keepFullPrediction(@NotNull Project project, Editor editor) { + log.debug("Going to keep the prediction"); + WriteCommandAction.runWriteCommandAction( + project, + () -> { + editor.getDocument() + .insertString(editor.getCaretModel().getOffset(), prediction); + + editor.getCaretModel() + .moveToOffset(editor.getCaretModel().getOffset() + prediction.length()); + + codeCompletionService.next(new IdleState(codeCompletionService)); + }); + } + + @Override + public void documentChanged(@NotNull DocumentEvent event) { + super.documentChanged(event); + + codeCompletionService.next(new IdleState(codeCompletionService)); + codeCompletionService.documentChanged(event); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/generation/StartGenerationState.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/generation/StartGenerationState.java new file mode 100644 index 0000000..495afb5 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/generation/StartGenerationState.java @@ -0,0 +1,59 @@ +package com.github.damiano1996.jetbrains.incoder.completion.states.generation; + +import com.github.damiano1996.jetbrains.incoder.completion.CodeCompletionContext; +import com.github.damiano1996.jetbrains.incoder.completion.CodeCompletionService; +import com.github.damiano1996.jetbrains.incoder.completion.states.BaseState; +import com.github.damiano1996.jetbrains.incoder.completion.states.idle.IdleState; +import com.github.damiano1996.jetbrains.incoder.language.model.client.inline.settings.InlineSettings; +import com.intellij.openapi.editor.event.DocumentEvent; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class StartGenerationState extends BaseState { + + public StartGenerationState(CodeCompletionService codeCompletionService) { + super(codeCompletionService); + } + + @Override + public void documentChanged(@NotNull DocumentEvent event) { + super.documentChanged(event); + + String text = event.getDocument().getText(); + + CodeCompletionContext codeCompletionContext = createRequest(text, event.getOffset() + 1); + + if (!InlineSettings.getInstance().getState().triggerEndLine) { + next(codeCompletionContext); + } else { + + log.debug("Trigger only on end of lines"); + + if (codeCompletionContext.rightContext().startsWith("\n")) { + next(codeCompletionContext); + } else { + log.debug("Going to idle state since left context is not at the end of the line."); + codeCompletionService.next(new IdleState(codeCompletionService)); + } + } + } + + private void next(CodeCompletionContext codeCompletionContext) { + codeCompletionService.enqueue(codeCompletionContext); + + log.debug("Going to wait state to let the llm to process the context"); + codeCompletionService.next(new WaitGenerationState(codeCompletionService)); + } + + @Contract("_, _ -> new") + private @NotNull CodeCompletionContext createRequest(@NotNull String text, int offset) { + + int actualOffset = Math.min(offset, text.length()); + String leftContext = text.substring(0, actualOffset); + String rightContext = text.substring(actualOffset); + + return new CodeCompletionContext(leftContext, rightContext); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/generation/WaitGenerationState.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/generation/WaitGenerationState.java new file mode 100644 index 0000000..f2209a2 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/generation/WaitGenerationState.java @@ -0,0 +1,56 @@ +package com.github.damiano1996.jetbrains.incoder.completion.states.generation; + +import com.github.damiano1996.jetbrains.incoder.completion.CodeCompletionService; +import com.github.damiano1996.jetbrains.incoder.completion.states.BaseState; +import com.github.damiano1996.jetbrains.incoder.completion.states.idle.IdleState; +import com.github.damiano1996.jetbrains.incoder.completion.states.preview.PreviewState; +import com.github.damiano1996.jetbrains.incoder.notification.NotificationService; +import com.intellij.ide.impl.ProjectUtil; +import com.intellij.openapi.editor.event.DocumentEvent; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class WaitGenerationState extends BaseState { + + public WaitGenerationState(CodeCompletionService codeCompletionService) { + super(codeCompletionService); + } + + @Override + public void documentChanged(@NotNull DocumentEvent event) { + super.documentChanged(event); + + log.debug( + "The document has been updated while waiting the prediction. New fragment: {}. " + + "Going back to start a new generation", + event.getNewFragment()); + codeCompletionService.next(new StartGenerationState(codeCompletionService)); + codeCompletionService.documentChanged(event); + } + + @Override + public void onCodeCompletionPrediction(String prediction) { + super.onCodeCompletionPrediction(prediction); + + if (prediction == null || prediction.isBlank()) { + log.debug("Prediction was null or blank"); + codeCompletionService.next(new IdleState(codeCompletionService)); + } else { + log.info("Prediction: {}", prediction); + log.debug("Going to preview state"); + codeCompletionService.next(new PreviewState(codeCompletionService, prediction)); + } + } + + @Override + public void onCodeCompletionError(@NotNull Throwable throwable) { + super.onCodeCompletionError(throwable); + log.warn("Error received on prediction...", throwable); + NotificationService.getInstance(Objects.requireNonNull(ProjectUtil.getActiveProject())) + .notifyError(throwable.getMessage()); + log.debug("Going to end state"); + codeCompletionService.next(new IdleState(codeCompletionService)); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/idle/IdleState.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/idle/IdleState.java new file mode 100644 index 0000000..f370571 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/idle/IdleState.java @@ -0,0 +1,64 @@ +package com.github.damiano1996.jetbrains.incoder.completion.states.idle; + +import com.github.damiano1996.jetbrains.incoder.completion.CodeCompletionService; +import com.github.damiano1996.jetbrains.incoder.completion.states.BaseState; +import com.github.damiano1996.jetbrains.incoder.completion.states.generation.StartGenerationState; +import com.github.damiano1996.jetbrains.incoder.language.model.client.inline.settings.InlineSettings; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.CommonDataKeys; +import com.intellij.openapi.command.WriteCommandAction; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.event.DocumentEvent; +import com.intellij.openapi.project.Project; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class IdleState extends BaseState { + + public IdleState(CodeCompletionService codeCompletionService) { + super(codeCompletionService); + } + + @Override + public void documentChanged(@NotNull DocumentEvent event) { + super.documentChanged(event); + + if (!InlineSettings.getInstance().getState().enable) { + log.debug("Inline code completion is disabled."); + return; + } + + log.debug("Executing {} state", IdleState.class.getSimpleName()); + log.debug("Idle timeout reached, transitioning to RunGenerationState"); + codeCompletionService.next(new StartGenerationState(codeCompletionService)); + codeCompletionService.documentChanged(event); + } + + @Override + public void actionPerformed(AnActionEvent anActionEvent) { + super.actionPerformed(anActionEvent); + + Project project = anActionEvent.getProject(); + Editor editor = anActionEvent.getData(CommonDataKeys.EDITOR); + + InputEvent inputEvent = anActionEvent.getInputEvent(); + if (inputEvent instanceof KeyEvent keyEvent) { + int keyCode = keyEvent.getKeyCode(); + + if (keyCode == KeyEvent.VK_TAB) { + log.debug("TAB event detected"); + WriteCommandAction.runWriteCommandAction( + project, + () -> { + Objects.requireNonNull(editor) + .getDocument() + .insertString(editor.getCaretModel().getOffset(), "\t"); + }); + } + } + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/preview/PreviewState.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/preview/PreviewState.java new file mode 100644 index 0000000..2c82cb0 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/preview/PreviewState.java @@ -0,0 +1,108 @@ +package com.github.damiano1996.jetbrains.incoder.completion.states.preview; + +import com.github.damiano1996.jetbrains.incoder.completion.CodeCompletionService; +import com.github.damiano1996.jetbrains.incoder.completion.states.BaseState; +import com.github.damiano1996.jetbrains.incoder.completion.states.decision.DecisionState; +import com.github.damiano1996.jetbrains.incoder.completion.states.preview.renderer.PreviewInlayRenderer; +import com.intellij.codeInsight.daemon.impl.HintRenderer; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.InlayModel; +import com.intellij.openapi.editor.event.DocumentEvent; +import com.intellij.openapi.editor.event.EditorMouseEvent; +import java.awt.*; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class PreviewState extends BaseState { + + private final String prediction; + + public PreviewState(CodeCompletionService codeCompletionService, String prediction) { + super(codeCompletionService); + this.prediction = prediction; + + cleanInlay(); + renderPrediction(); + } + + private void renderPrediction() { + EventQueue.invokeLater( + () -> { + Editor editor = codeCompletionService.getEditor(); + if (editor == null) { + log.debug("Editor was null, retuning"); + return; + } + + InlayModel inlayModel = editor.getInlayModel(); + + int offset = editor.getCaretModel().getOffset(); + inlayModel.addInlineElement(offset, true, new PreviewInlayRenderer(prediction)); + + int endLineOffset = + Math.max(editor.getCaretModel().getVisualLineEnd() - 1, offset); + inlayModel.addInlineElement(endLineOffset, true, new PreviewInlayRenderer(" ")); + inlayModel.addInlineElement(endLineOffset, true, new HintRenderer("Tab")); + inlayModel.addInlineElement( + endLineOffset, true, new PreviewInlayRenderer(" to complete")); + }); + } + + @Override + public void documentChanged(@NotNull DocumentEvent event) { + super.documentChanged(event); + + cleanInlay(); + updateState(); + codeCompletionService.documentChanged(event); + } + + @Override + public void actionPerformed(AnActionEvent anActionEvent) { + super.actionPerformed(anActionEvent); + + cleanInlay(); + updateState(); + codeCompletionService.actionPerformed(anActionEvent); + } + + @Override + public void beforeActionPerformed(@NotNull AnAction action, @NotNull AnActionEvent event) { + super.beforeActionPerformed(action, event); + + cleanInlay(); + updateState(); + codeCompletionService.beforeActionPerformed(action, event); + } + + @Override + public void mouseClicked(@NotNull EditorMouseEvent event) { + super.mouseClicked(event); + + cleanInlay(); + updateState(); + codeCompletionService.mouseClicked(event); + } + + private void updateState() { + log.debug("Going to decision state"); + codeCompletionService.next(new DecisionState(codeCompletionService, prediction)); + } + + private void cleanInlay() { + try { + Editor editor = codeCompletionService.getEditor(); + if (editor != null) { + editor.getInlayModel() + .getInlineElementsInRange(0, editor.getDocument().getTextLength()) + .forEach(Disposable::dispose); + } + } catch (Exception e) { + log.warn("Error while cleaning inlay model: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/preview/renderer/PreviewInlayRenderer.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/preview/renderer/PreviewInlayRenderer.java new file mode 100644 index 0000000..62f50e7 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/completion/states/preview/renderer/PreviewInlayRenderer.java @@ -0,0 +1,57 @@ +package com.github.damiano1996.jetbrains.incoder.completion.states.preview.renderer; + +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.EditorCustomElementRenderer; +import com.intellij.openapi.editor.Inlay; +import com.intellij.openapi.editor.colors.EditorFontType; +import com.intellij.openapi.editor.markup.TextAttributes; +import com.intellij.ui.JBColor; +import java.awt.*; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class PreviewInlayRenderer implements EditorCustomElementRenderer { + + private final String preview; + + public PreviewInlayRenderer(String preview) { + log.debug("Preview of: '{}'", preview); + this.preview = preview; + } + + @Override + public int calcWidthInPixels(@NotNull Inlay inlay) { + return inlay.getEditor() + .getContentComponent() + .getFontMetrics(inlay.getEditor().getColorsScheme().getFont(EditorFontType.PLAIN)) + .stringWidth(preview); + } + + @Override + public void paint( + @NotNull Inlay inlay, + @NotNull Graphics g, + @NotNull Rectangle targetRegion, + @NotNull TextAttributes textAttributes) { + Editor editor = inlay.getEditor(); + @SuppressWarnings("UseJBColor") + Color color = + new Color( + editor.getColorsScheme().getDefaultForeground().getRed(), + editor.getColorsScheme().getDefaultForeground().getGreen(), + editor.getColorsScheme().getDefaultForeground().getBlue(), + Math.min( + editor.getColorsScheme().getDefaultForeground().getAlpha() - 100, + 100)); + JBColor jbColor = new JBColor(color, color); + + g.setColor(jbColor); + + g.setFont(editor.getColorsScheme().getFont(EditorFontType.PLAIN)); + FontMetrics fontMetrics = g.getFontMetrics(); + int baseline = targetRegion.y + fontMetrics.getAscent() + 2; + + g.drawString(preview, targetRegion.x, baseline); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/LanguageModelException.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/LanguageModelException.java new file mode 100644 index 0000000..c7c7a9c --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/LanguageModelException.java @@ -0,0 +1,18 @@ +package com.github.damiano1996.jetbrains.incoder.language.model; + +public class LanguageModelException extends Exception { + + public LanguageModelException() {} + + public LanguageModelException(String message) { + super(message); + } + + public LanguageModelException(String message, Throwable cause) { + super(message, cause); + } + + public LanguageModelException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/LanguageModelService.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/LanguageModelService.java new file mode 100644 index 0000000..cb844d9 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/LanguageModelService.java @@ -0,0 +1,110 @@ +package com.github.damiano1996.jetbrains.incoder.language.model; + +import com.github.damiano1996.jetbrains.incoder.completion.CodeCompletionContext; +import com.github.damiano1996.jetbrains.incoder.language.model.client.LanguageModelClient; +import com.github.damiano1996.jetbrains.incoder.language.model.client.chat.settings.ChatSettings; +import com.github.damiano1996.jetbrains.incoder.language.model.client.inline.settings.InlineSettings; +import com.github.damiano1996.jetbrains.incoder.language.model.client.prompt.PromptType; +import com.github.damiano1996.jetbrains.incoder.language.model.server.LanguageModelServer; +import com.github.damiano1996.jetbrains.incoder.language.model.server.ServerFactoryUtils; +import com.github.damiano1996.jetbrains.incoder.settings.PluginSettings; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import dev.langchain4j.service.TokenStream; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Slf4j +@Service(Service.Level.PROJECT) +public final class LanguageModelService implements Disposable { + + private final Project project; + + @Nullable private LanguageModelServer server; + @Nullable private LanguageModelClient client; + + public LanguageModelService(Project project) { + this.project = project; + } + + public static LanguageModelService getInstance(@NotNull Project project) { + return project.getService(LanguageModelService.class); + } + + public void init() throws LanguageModelException { + log.debug("Initializing {}...", LanguageModelService.class.getSimpleName()); + + var serverName = ChatSettings.getInstance().getState().serverName; + + server = ServerFactoryUtils.findByName(serverName).createServer(); + + client = server.createClient(); + log.debug("Client created successfully!"); + + PluginSettings.getInstance().getState().isPluginConfigured = true; + log.debug("Client and server started. Plugin can be considered configured."); + } + + public boolean isReady() { + return server != null && client != null; + } + + public String getSelectedModelName() { + return Objects.requireNonNull( + server, "Server must be initialized to retrieve the selected model name.") + .getSelectedModelName(); + } + + public String complete(@NotNull CodeCompletionContext codeCompletionContext) { + return Objects.requireNonNull(client, "Client must be initialized to complete the code.") + .complete( + InlineSettings.getInstance().getState().systemMessageInstructions, + codeCompletionContext.leftContext(), + codeCompletionContext.rightContext()); + } + + @Contract("_ -> new") + public @NotNull CompletableFuture classify(String prompt) { + return CompletableFuture.supplyAsync( + () -> + Objects.requireNonNull( + client, + "Client must be initialized to classify the prompt.") + .classify(prompt)); + } + + public TokenStream chat(@NonNull Editor editor, @NonNull String editDescription) { + return Objects.requireNonNull( + client, "Client must be initialized to chat with the language model.") + .chat( + ChatSettings.getInstance().getState().systemMessageInstructionsWithCode, + editor.getDocument().getText(), + editor.getVirtualFile().getPath(), + project.getBasePath(), + editDescription); + } + + public TokenStream chat(@NonNull String editDescription) { + return Objects.requireNonNull( + client, "Client must be initialized to chat with the language model.") + .chat( + ChatSettings.getInstance().getState().systemMessageInstructions, + project.getBasePath(), + editDescription); + } + + public String createFileName(String fileContent) { + return Objects.requireNonNull(client, "Client must be initialized to create file name.") + .createFileName(fileContent); + } + + @Override + public void dispose() {} +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/LanguageModelClient.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/LanguageModelClient.java new file mode 100644 index 0000000..0bee08d --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/LanguageModelClient.java @@ -0,0 +1,18 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelException; +import com.github.damiano1996.jetbrains.incoder.language.model.client.chat.ChatCodingAssistant; +import com.github.damiano1996.jetbrains.incoder.language.model.client.file.FileManagerAssistant; +import com.github.damiano1996.jetbrains.incoder.language.model.client.inline.InlineCodingAssistant; +import com.github.damiano1996.jetbrains.incoder.language.model.client.prompt.PromptClassifier; + +public interface LanguageModelClient + extends ChatCodingAssistant, InlineCodingAssistant, FileManagerAssistant, PromptClassifier { + + /** + * Checks whether the connection is healthy. + * + * @throws LanguageModelException if the connection or settings are unhealthy. + */ + void checkServerConnection() throws LanguageModelException; +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/LanguageModelClientImpl.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/LanguageModelClientImpl.java new file mode 100644 index 0000000..1ba011d --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/LanguageModelClientImpl.java @@ -0,0 +1,102 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelException; +import com.github.damiano1996.jetbrains.incoder.language.model.client.chat.ChatCodingAssistant; +import com.github.damiano1996.jetbrains.incoder.language.model.client.file.FileManagerAssistant; +import com.github.damiano1996.jetbrains.incoder.language.model.client.inline.InlineCodingAssistant; +import com.github.damiano1996.jetbrains.incoder.language.model.client.prompt.PromptClassifier; +import com.github.damiano1996.jetbrains.incoder.language.model.client.prompt.PromptType; +import dev.langchain4j.memory.ChatMemory; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.chat.StreamingChatLanguageModel; +import dev.langchain4j.service.AiServices; +import dev.langchain4j.service.TokenStream; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class LanguageModelClientImpl implements LanguageModelClient { + + private final ChatLanguageModel chatLanguageModel; + + private final ChatCodingAssistant chatCodingAssistant; + private final InlineCodingAssistant inlineCodingAssistant; + private final FileManagerAssistant fileManagerAssistant; + private final PromptClassifier promptClassifier; + + public LanguageModelClientImpl( + ChatLanguageModel chatLanguageModel, + StreamingChatLanguageModel streamingChatLanguageModel, + ChatMemory chatMemory) { + + this.chatLanguageModel = chatLanguageModel; + + chatCodingAssistant = + AiServices.builder(ChatCodingAssistant.class) + .streamingChatLanguageModel(streamingChatLanguageModel) + .chatLanguageModel(chatLanguageModel) + .chatMemory(chatMemory) + .build(); + + inlineCodingAssistant = + AiServices.builder(InlineCodingAssistant.class) + .streamingChatLanguageModel(streamingChatLanguageModel) + .chatLanguageModel(chatLanguageModel) + .build(); + + fileManagerAssistant = + AiServices.builder(FileManagerAssistant.class) + .streamingChatLanguageModel(streamingChatLanguageModel) + .chatLanguageModel(chatLanguageModel) + .build(); + + promptClassifier = + AiServices.builder(PromptClassifier.class) + .streamingChatLanguageModel(streamingChatLanguageModel) + .chatLanguageModel(chatLanguageModel) + .build(); + } + + @Override + public String complete(String instructions, String leftContext, String rightContext) { + log.debug("Completing code..."); + return inlineCodingAssistant.complete(instructions, leftContext, rightContext); + } + + @Override + public TokenStream chat( + String instructions, + String code, + String filePath, + String projectBasePath, + String prompt) { + log.debug("Chatting about codes..."); + return chatCodingAssistant.chat(instructions, code, filePath, projectBasePath, prompt); + } + + @Override + public TokenStream chat(String instructions, String projectBasePath, String prompt) { + log.debug("Chatting..."); + return chatCodingAssistant.chat(instructions, projectBasePath, prompt); + } + + @Override + public PromptType classify(String prompt) { + log.debug("Classifying prompt: {}...", prompt); + return promptClassifier.classify(prompt); + } + + @Override + public String createFileName(String fileContent) { + log.debug("Defining file path"); + return fileManagerAssistant.createFileName(fileContent).trim(); + } + + @Override + public void checkServerConnection() throws LanguageModelException { + try { + chatLanguageModel.generate("Hello!"); + } catch (Exception e) { + throw new LanguageModelException(e); + } + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/ChatCodingAssistant.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/ChatCodingAssistant.java new file mode 100644 index 0000000..8a7757c --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/ChatCodingAssistant.java @@ -0,0 +1,41 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.chat; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.TokenStream; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +public interface ChatCodingAssistant { + + @SystemMessage( + """ + Context: + - Current code being viewed by the user: + {{code}} + + - File path: {{filePath}} + - Project base path: {{projectBasePath}} + + Instructions: + {{instructions}} + """) + TokenStream chat( + @V("instructions") String instructions, + @V("code") String code, + @V("filePath") String filePath, + @V("projectBasePath") String projectBasePath, + @UserMessage String prompt); + + @SystemMessage( + """ + Context: + - Project base path: {{projectBasePath}} + + Instructions: + {{instructions}} + """) + TokenStream chat( + @V("instructions") String instructions, + @V("projectBasePath") String projectBasePath, + @UserMessage String prompt); +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/settings/ChatSettings.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/settings/ChatSettings.java new file mode 100644 index 0000000..90a0814 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/settings/ChatSettings.java @@ -0,0 +1,47 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.chat.settings; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import lombok.Getter; +import lombok.ToString; +import org.jetbrains.annotations.NotNull; + +@Getter +@Service(Service.Level.APP) +@State( + name = "ChatSettings", + storages = {@Storage("InCoderSettings.xml")}) +public final class ChatSettings implements PersistentStateComponent { + + @NotNull private State state = new State(); + + public static ChatSettings getInstance() { + return ApplicationManager.getApplication().getService(ChatSettings.class); + } + + @Override + public void loadState(@NotNull State state) { + this.state = state; + } + + @ToString + public static class State { + public String serverName = ""; + + public int maxMessages = 10; + + public String systemMessageInstructionsWithCode = + """ + - You are an AI assistant integrated into a JetBrains plugin, providing expert coding assistance and development support directly within the IDE. + - If the user input pertains to the provided code, respond with the code edited according to the user's instructions. + """; + + public String systemMessageInstructions = + """ + - You are an AI assistant integrated into a JetBrains plugin, providing expert coding assistance and development support directly within the IDE. + """; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/settings/ChatSettingsComponent.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/settings/ChatSettingsComponent.java new file mode 100644 index 0000000..d24ad30 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/settings/ChatSettingsComponent.java @@ -0,0 +1,83 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.chat.settings; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.ServerFactory; +import com.github.damiano1996.jetbrains.incoder.language.model.server.ServerFactoryUtils; +import com.github.damiano1996.jetbrains.incoder.ui.components.DescriptionLabel; +import com.intellij.openapi.ui.ComboBox; +import com.intellij.ui.ScrollPaneFactory; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBTextArea; +import com.intellij.util.ui.FormBuilder; +import javax.swing.*; +import lombok.Getter; + +@Getter +public class ChatSettingsComponent { + + private final JPanel mainPanel; + private final ComboBox serverTypeComboBox; + private final JSpinner maxMessages; + private final JBTextArea systemMessageInstructionsWithCodeField; + private final JBTextArea systemMessageInstructionsField; + + public ChatSettingsComponent() { + + serverTypeComboBox = + new ComboBox<>( + ServerFactoryUtils.getServerFactories().stream() + .map(ServerFactory::getName) + .toList() + .toArray(new String[0])); + serverTypeComboBox.addItem(""); + + SpinnerNumberModel maxMessagesModel = new SpinnerNumberModel(10, 0, 50, 1); + maxMessages = new JSpinner(maxMessagesModel); + + systemMessageInstructionsWithCodeField = new JBTextArea(5, 20); + systemMessageInstructionsWithCodeField.setLineWrap(true); + systemMessageInstructionsWithCodeField.setWrapStyleWord(true); + + systemMessageInstructionsField = new JBTextArea(5, 20); + systemMessageInstructionsField.setLineWrap(true); + systemMessageInstructionsField.setWrapStyleWord(true); + + mainPanel = + FormBuilder.createFormBuilder() + .setFormLeftIndent(20) + .addLabeledComponent( + new JBLabel("Server type:"), serverTypeComboBox, 1, false) + .addComponent( + new DescriptionLabel( + "Select the server to be used for interaction with language" + + " models.")) + .addVerticalGap(20) + .addLabeledComponent(new JBLabel("Max messages:"), maxMessages, 1, false) + .addComponent(new DescriptionLabel("Number of messages to keep in memory.")) + .addVerticalGap(20) + .addLabeledComponent( + new JBLabel("System message instructions with code:"), + ScrollPaneFactory.createScrollPane( + systemMessageInstructionsWithCodeField), + 1, + true) + .addComponent( + new DescriptionLabel( + "System message template instructions when code context is" + + " included. These instructions will be added to the" + + " @SystemMessage.")) + .addVerticalGap(20) + .addLabeledComponent( + new JBLabel("System message instructions:"), + ScrollPaneFactory.createScrollPane(systemMessageInstructionsField), + 1, + true) + .addComponent( + new DescriptionLabel( + "System message template instructions for general" + + " questions. These instructions will be added to the" + + " @SystemMessage.")) + .setFormLeftIndent(0) + .addComponentFillVertically(new JPanel(), 0) + .getPanel(); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/settings/ChatSettingsConfigurable.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/settings/ChatSettingsConfigurable.java new file mode 100644 index 0000000..b37300a --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/chat/settings/ChatSettingsConfigurable.java @@ -0,0 +1,99 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.chat.settings; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelException; +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelService; +import com.intellij.ide.impl.ProjectUtil; +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.options.ConfigurationException; +import java.util.Objects; +import javax.swing.*; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class ChatSettingsConfigurable implements Configurable { + + private ChatSettingsComponent chatSettingsComponent; + + public ChatSettingsConfigurable() { + chatSettingsComponent = new ChatSettingsComponent(); + } + + private static ChatSettings.@NotNull State getState() { + return ChatSettings.getInstance().getState(); + } + + @Contract(pure = true) + @Nls(capitalization = Nls.Capitalization.Title) + @Override + public @NotNull String getDisplayName() { + return "Chat"; + } + + @Override + public JComponent getPreferredFocusedComponent() { + return chatSettingsComponent.getMainPanel(); + } + + @Nullable + @Override + public JComponent createComponent() { + return chatSettingsComponent.getMainPanel(); + } + + @Override + public boolean isModified() { + var state = getState(); + + return !chatSettingsComponent.getServerTypeComboBox().getItem().equals(state.serverName) + || !chatSettingsComponent.getMaxMessages().getValue().equals(state.maxMessages) + || !chatSettingsComponent + .getSystemMessageInstructionsWithCodeField() + .getText() + .equals(state.systemMessageInstructionsWithCode) + || !chatSettingsComponent + .getSystemMessageInstructionsField() + .getText() + .equals(state.systemMessageInstructions); + } + + @Override + public void apply() throws ConfigurationException { + var state = getState(); + + state.serverName = chatSettingsComponent.getServerTypeComboBox().getItem(); + state.maxMessages = (int) chatSettingsComponent.getMaxMessages().getValue(); + state.systemMessageInstructionsWithCode = + chatSettingsComponent.getSystemMessageInstructionsWithCodeField().getText(); + state.systemMessageInstructions = + chatSettingsComponent.getSystemMessageInstructionsField().getText(); + + try { + LanguageModelService.getInstance(Objects.requireNonNull(ProjectUtil.getActiveProject())) + .init(); + } catch (LanguageModelException e) { + throw new ConfigurationException( + e.getMessage(), "Unable to Initialize the Language Model Service"); + } + } + + @Override + public void reset() { + var state = getState(); + + chatSettingsComponent.getServerTypeComboBox().setItem(state.serverName); + chatSettingsComponent.getMaxMessages().setValue(state.maxMessages); + chatSettingsComponent + .getSystemMessageInstructionsWithCodeField() + .setText(state.systemMessageInstructionsWithCode); + chatSettingsComponent + .getSystemMessageInstructionsField() + .setText(state.systemMessageInstructions); + } + + @Override + public void disposeUIResources() { + chatSettingsComponent = null; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/file/FileManagerAssistant.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/file/FileManagerAssistant.java new file mode 100644 index 0000000..4d5e4c6 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/file/FileManagerAssistant.java @@ -0,0 +1,17 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.file; + +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +public interface FileManagerAssistant { + + @UserMessage( + """ + Define an file name based on the file content: + {{fileContent}} + + Return only the file name with the extension. Nothing else. No prefixes or suffixes. + It will be used to name and save the file content. + """) + String createFileName(@V("fileContent") String fileContent); +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/InlineCodingAssistant.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/InlineCodingAssistant.java new file mode 100644 index 0000000..fe15dd4 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/InlineCodingAssistant.java @@ -0,0 +1,23 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.inline; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +public interface InlineCodingAssistant { + + @SystemMessage( + """ + Instructions: + {{instructions}} + """) + @UserMessage( + """ + Complete the last line: + {{leftContext}} + """) + String complete( + @V("instructions") String instructions, + @V("leftContext") String leftContext, + @V("rightContext") String rightContext); +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/settings/InlineComponent.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/settings/InlineComponent.java new file mode 100644 index 0000000..6eb6ae4 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/settings/InlineComponent.java @@ -0,0 +1,62 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.inline.settings; + +import com.github.damiano1996.jetbrains.incoder.InCoderBundle; +import com.github.damiano1996.jetbrains.incoder.ui.components.DescriptionLabel; +import com.intellij.ui.ScrollPaneFactory; +import com.intellij.ui.components.JBCheckBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBTextArea; +import com.intellij.util.ui.FormBuilder; +import javax.swing.*; +import lombok.Getter; + +@Getter +public class InlineComponent { + + private final JPanel mainPanel; + private final JBCheckBox enableCheckbox; + private final JBCheckBox endLineCheckBox; + private final JBTextArea systemMessageInstructionsField; + + public InlineComponent() { + enableCheckbox = new JBCheckBox("Inline coding assistant"); + endLineCheckBox = new JBCheckBox("Trigger at end line"); + + systemMessageInstructionsField = new JBTextArea(5, 20); + systemMessageInstructionsField.setLineWrap(true); + systemMessageInstructionsField.setWrapStyleWord(true); + + mainPanel = + FormBuilder.createFormBuilder() + .setFormLeftIndent(20) + .addComponent( + new DescriptionLabel(InCoderBundle.message("inline.description"))) + .addVerticalGap(20) + .addComponent(enableCheckbox) + .addComponent( + new DescriptionLabel( + "Enable the inline coding assistant functionality.")) + .addVerticalGap(20) + .addComponent(endLineCheckBox) + .addComponent( + new DescriptionLabel( + "Activates code completion only when the caret is at the" + + " end of a line.\n" + + "If disabled, suggestions may also appear while" + + " typing in the middle of a line.")) + .addVerticalGap(20) + .addLabeledComponent( + new JBLabel("System message instructions:"), + ScrollPaneFactory.createScrollPane(systemMessageInstructionsField), + 1, + true) + .addComponent( + new DescriptionLabel( + "System message template instructions. These instructions" + + " will be added to the @SystemMessage with an" + + " additional context.")) + .setFormLeftIndent(0) + .addComponentFillVertically(new JPanel(), 0) + .getPanel(); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/settings/InlineConfigurable.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/settings/InlineConfigurable.java new file mode 100644 index 0000000..b9331fa --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/settings/InlineConfigurable.java @@ -0,0 +1,73 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.inline.settings; + +import com.intellij.openapi.options.Configurable; +import javax.swing.*; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class InlineConfigurable implements Configurable { + + private InlineComponent inlineComponent = new InlineComponent(); + + private static InlineSettings.State getState() { + return InlineSettings.getInstance().getState(); + } + + @Contract(pure = true) + @Nls(capitalization = Nls.Capitalization.Title) + @Override + public @NotNull String getDisplayName() { + return "Inline"; + } + + @Override + public JComponent getPreferredFocusedComponent() { + return inlineComponent.getMainPanel(); + } + + @Nullable + @Override + public JComponent createComponent() { + return inlineComponent.getMainPanel(); + } + + @Override + public boolean isModified() { + var state = getState(); + + return inlineComponent.getEnableCheckbox().isSelected() != state.enable + || inlineComponent.getEndLineCheckBox().isSelected() != state.triggerEndLine + || !inlineComponent + .getSystemMessageInstructionsField() + .getText() + .equals(state.systemMessageInstructions); + } + + @Override + public void apply() { + var state = getState(); + + state.enable = inlineComponent.getEnableCheckbox().isSelected(); + state.triggerEndLine = inlineComponent.getEndLineCheckBox().isSelected(); + state.systemMessageInstructions = + inlineComponent.getSystemMessageInstructionsField().getText(); + } + + @Override + public void reset() { + var state = getState(); + + inlineComponent.getEnableCheckbox().setSelected(state.enable); + inlineComponent.getEndLineCheckBox().setSelected(state.triggerEndLine); + inlineComponent + .getSystemMessageInstructionsField() + .setText(state.systemMessageInstructions); + } + + @Override + public void disposeUIResources() { + inlineComponent = null; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/settings/InlineSettings.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/settings/InlineSettings.java new file mode 100644 index 0000000..bd0c2b2 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/inline/settings/InlineSettings.java @@ -0,0 +1,43 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.inline.settings; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import lombok.Getter; +import lombok.ToString; +import org.jetbrains.annotations.NotNull; + +@Getter +@Service(Service.Level.APP) +@State( + name = "InlineSettings", + storages = {@Storage("InCoderSettings.xml")}) +public final class InlineSettings implements PersistentStateComponent { + + @NotNull private State state = new State(); + + public static InlineSettings getInstance() { + return ApplicationManager.getApplication().getService(InlineSettings.class); + } + + @Override + public void loadState(@NotNull State state) { + this.state = state; + } + + @ToString + public static class State { + public boolean enable = false; + public boolean triggerEndLine = true; + + public String systemMessageInstructions = + """ + - Respond only with the necessary code to complete the line. + - Do not include any markdown, comments, or unnecessary tokens. + - If the line is partially written, complete it up to the first newline. + - If the line is already complete, return an empty response. + """; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/prompt/PromptClassifier.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/prompt/PromptClassifier.java new file mode 100644 index 0000000..5074d26 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/prompt/PromptClassifier.java @@ -0,0 +1,16 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.prompt; + +import dev.langchain4j.service.UserMessage; + +public interface PromptClassifier { + + @UserMessage( + """ + Analyze the provided prompt and classify it into one of the available prompt types. + Respond with only the name of the appropriate prompt type, nothing else. + + Prompt: + {{it}} + """) + PromptType classify(String prompt); +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/prompt/PromptType.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/prompt/PromptType.java new file mode 100644 index 0000000..5b86a27 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/client/prompt/PromptType.java @@ -0,0 +1,43 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.client.prompt; + +import dev.langchain4j.model.output.structured.Description; +import lombok.Getter; + +@Getter +public enum PromptType { + @Description( + """ + The user wants to modify the code by adding, removing, updating, or refactoring it. + """) + EDIT("Edit"), + + @Description( + """ + The user needs help understanding, debugging, or analyzing the logic of the code. + """) + EXPLAIN("Explain"), + + @Description( + """ + The user requests to generate new code, including templates, stubs, or new features. + """) + GENERATE("Generate"), + + @Description( + """ + The user seeks performance improvements or optimization of the code. + """) + OPTIMIZE("Optimize"), + + @Description( + """ + Generic or unclassified prompt that does not fit into other specific categories, such as chat questions. + """) + GENERAL("General"); + + private final String displayName; + + PromptType(String displayName) { + this.displayName = displayName; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/BaseLanguageModelServer.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/BaseLanguageModelServer.java new file mode 100644 index 0000000..04b3f11 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/BaseLanguageModelServer.java @@ -0,0 +1,42 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelException; +import com.github.damiano1996.jetbrains.incoder.language.model.client.LanguageModelClient; +import com.github.damiano1996.jetbrains.incoder.language.model.client.LanguageModelClientImpl; +import com.github.damiano1996.jetbrains.incoder.language.model.client.chat.settings.ChatSettings; +import dev.langchain4j.memory.ChatMemory; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.chat.StreamingChatLanguageModel; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public abstract class BaseLanguageModelServer implements LanguageModelServer { + + public abstract ChatLanguageModel createChatLanguageModel(); + + public abstract StreamingChatLanguageModel createStreamingChatLanguageModel(); + + @Contract(" -> new") + @Override + public @NotNull LanguageModelClient createClient() throws LanguageModelException { + try { + ChatMemory chatMemory = createChatMemory(); + + return new LanguageModelClientImpl( + createChatLanguageModel(), createStreamingChatLanguageModel(), chatMemory); + } catch (Exception e) { + throw new LanguageModelException( + ("Unable to create the client for %s.\n" + "%s") + .formatted(getName(), e.getMessage()), + e); + } + } + + protected ChatMemory createChatMemory() { + return MessageWindowChatMemory.withMaxMessages( + ChatSettings.getInstance().getState().maxMessages); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/BaseServerConfigurable.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/BaseServerConfigurable.java new file mode 100644 index 0000000..51c8713 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/BaseServerConfigurable.java @@ -0,0 +1,54 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelException; +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.options.ConfigurationException; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.util.NlsContexts; +import com.intellij.openapi.util.ThrowableComputable; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class BaseServerConfigurable implements Configurable { + + protected abstract ServerFactory getServerFactory(); + + @Override + public @NlsContexts.ConfigurableName String getDisplayName() { + return getServerFactory().getName(); + } + + @Override + public final void apply() throws ConfigurationException { + //noinspection DialogTitleCapitalization + ProgressManager.getInstance() + .runProcessWithProgressSynchronously( + (ThrowableComputable) + () -> { + updateState(); + verifySettings(); + return null; + }, + "Verifying %s Settings".formatted(getDisplayName()), + false, + null); + } + + /** Updates the server state based on the current configuration. */ + protected abstract void updateState(); + + /** + * Verifies the configured settings and throws a {@link ConfigurationException} if any issues + * are found. + * + * @throws ConfigurationException if the settings are invalid. + */ + private void verifySettings() throws ConfigurationException { + try { + log.debug("Creating server and client to verify configurations"); + getServerFactory().createServer().createClient().checkServerConnection(); + } catch (LanguageModelException e) { + throw new ConfigurationException(e.getMessage()); + } + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/LanguageModelServer.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/LanguageModelServer.java new file mode 100644 index 0000000..2d83856 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/LanguageModelServer.java @@ -0,0 +1,16 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelException; +import com.github.damiano1996.jetbrains.incoder.language.model.client.LanguageModelClient; +import java.util.List; + +public interface LanguageModelServer { + + String getName(); + + List getAvailableModels(); + + String getSelectedModelName(); + + LanguageModelClient createClient() throws LanguageModelException; +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ServerFactory.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ServerFactory.java new file mode 100644 index 0000000..e983438 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ServerFactory.java @@ -0,0 +1,16 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelException; + +public interface ServerFactory { + + default String getName() { + try { + return createServer().getName(); + } catch (LanguageModelException e) { + throw new IllegalStateException("Name of the server must be implemented.", e); + } + } + + LanguageModelServer createServer() throws LanguageModelException; +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ServerFactoryUtils.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ServerFactoryUtils.java new file mode 100644 index 0000000..f047e50 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ServerFactoryUtils.java @@ -0,0 +1,72 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.reflections.Reflections; + +@Slf4j +@UtilityClass +public class ServerFactoryUtils { + + public ServerFactory findByName(@NotNull String name) throws LanguageModelException { + if (name.isBlank() || name.isEmpty()) + throw new LanguageModelException("Server name must be defined."); + + return getServerFactories().stream() + .filter(serverFactory -> serverFactory.getName().equals(name)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "No server factory implementation found for %s" + .formatted(name))); + } + + /** + * Retrieves all implementations of the {@link ServerFactory} interface. + * + * @return a list of server factory instances. + */ + public @NotNull List getServerFactories() { + return findImplementations(ServerFactory.class); + } + + /** + * Finds and instantiates all subtypes of the specified class. + * + * @param clazz The base class to find implementations for. + * @param The type of the base class. + * @return a list of instances of the found subclasses. + */ + private @NotNull List findImplementations(@NotNull Class clazz) { + List implementations = new ArrayList<>(); + + try { + String packageName = clazz.getPackageName(); + Reflections reflections = new Reflections(packageName); + + Set> subTypes = reflections.getSubTypesOf(clazz); + + for (Class subtype : subTypes) { + log.debug("Found implementation: {}", subtype.getName()); + try { + T instance = subtype.getDeclaredConstructor().newInstance(); + implementations.add(instance); + log.debug( + "Instance {} added successfully", instance.getClass().getSimpleName()); + } catch (ReflectiveOperationException e) { + log.error("Failed to instantiate implementation: {}", subtype.getName(), e); + } + } + } catch (Exception e) { + log.error("Error finding implementations of {}", clazz.getName(), e); + } + + return implementations; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/AnthropicFactory.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/AnthropicFactory.java new file mode 100644 index 0000000..bedce09 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/AnthropicFactory.java @@ -0,0 +1,12 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.anthropic; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.LanguageModelServer; +import com.github.damiano1996.jetbrains.incoder.language.model.server.ServerFactory; + +public class AnthropicFactory implements ServerFactory { + + @Override + public LanguageModelServer createServer() { + return new AnthropicLanguageModelServer(); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/AnthropicLanguageModelServer.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/AnthropicLanguageModelServer.java new file mode 100644 index 0000000..fb318e2 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/AnthropicLanguageModelServer.java @@ -0,0 +1,51 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.anthropic; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.BaseLanguageModelServer; +import com.github.damiano1996.jetbrains.incoder.language.model.server.anthropic.settings.AnthropicSettings; +import dev.langchain4j.model.anthropic.AnthropicChatModel; +import dev.langchain4j.model.anthropic.AnthropicChatModelName; +import dev.langchain4j.model.anthropic.AnthropicStreamingChatModel; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.chat.StreamingChatLanguageModel; +import java.util.Arrays; +import java.util.List; + +public class AnthropicLanguageModelServer extends BaseLanguageModelServer { + + private static AnthropicSettings.State getState() { + return AnthropicSettings.getInstance().getState(); + } + + @Override + public ChatLanguageModel createChatLanguageModel() { + return AnthropicChatModel.builder() + .baseUrl(getState().apiKey) + .modelName(getState().modelName) + .temperature(getState().temperature) + .build(); + } + + @Override + public StreamingChatLanguageModel createStreamingChatLanguageModel() { + return AnthropicStreamingChatModel.builder() + .baseUrl(getState().apiKey) + .modelName(getState().modelName) + .temperature(getState().temperature) + .build(); + } + + @Override + public String getName() { + return "Anthropic"; + } + + @Override + public List getAvailableModels() { + return Arrays.stream(AnthropicChatModelName.values()).map(Enum::toString).toList(); + } + + @Override + public String getSelectedModelName() { + return getState().modelName; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/settings/AnthropicComponent.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/settings/AnthropicComponent.java new file mode 100644 index 0000000..cfc644b --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/settings/AnthropicComponent.java @@ -0,0 +1,44 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.anthropic.settings; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.anthropic.AnthropicLanguageModelServer; +import com.intellij.openapi.ui.ComboBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.util.ui.FormBuilder; +import java.awt.*; +import javax.swing.*; +import lombok.Getter; + +@Getter +public class AnthropicComponent { + + private final JPanel mainPanel; + private final JPasswordField apiKeyField; + private final ComboBox modelNameField; + private final JSpinner temperatureField; + + public AnthropicComponent() { + apiKeyField = new JPasswordField(); + apiKeyField.setColumns(60); + + SpinnerNumberModel temperatureModel = new SpinnerNumberModel(0.5, 0.0, 1.0, 0.1); + temperatureField = new JSpinner(temperatureModel); + + modelNameField = + new ComboBox<>( + new AnthropicLanguageModelServer() + .getAvailableModels() + .toArray(new String[0])); + modelNameField.setPreferredSize(new Dimension(300, 30)); + + mainPanel = + FormBuilder.createFormBuilder() + .setFormLeftIndent(20) + .addLabeledComponent(new JBLabel("Api key:"), apiKeyField, 1, false) + .addLabeledComponent(new JBLabel("Model name:"), modelNameField, 1, false) + .addLabeledComponent( + new JBLabel("Temperature:"), temperatureField, 1, false) + .setFormLeftIndent(0) + .addComponentFillVertically(new JPanel(), 0) + .getPanel(); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/settings/AnthropicConfigurable.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/settings/AnthropicConfigurable.java new file mode 100644 index 0000000..7c90227 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/settings/AnthropicConfigurable.java @@ -0,0 +1,75 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.anthropic.settings; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.BaseServerConfigurable; +import com.github.damiano1996.jetbrains.incoder.language.model.server.ServerFactory; +import com.github.damiano1996.jetbrains.incoder.language.model.server.anthropic.AnthropicFactory; +import javax.swing.*; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class AnthropicConfigurable extends BaseServerConfigurable { + + private AnthropicComponent settingsComponent = new AnthropicComponent(); + + private static AnthropicSettings.State getState() { + return AnthropicSettings.getInstance().getState(); + } + + @Contract(value = " -> new", pure = true) + @Override + protected @NotNull ServerFactory getServerFactory() { + return new AnthropicFactory(); + } + + @Contract(pure = true) + @Nls(capitalization = Nls.Capitalization.Title) + @Override + public @NotNull String getDisplayName() { + return "Anthropic"; + } + + @Override + public JComponent getPreferredFocusedComponent() { + return settingsComponent.getMainPanel(); + } + + @Nullable + @Override + public JComponent createComponent() { + return settingsComponent.getMainPanel(); + } + + @Override + public boolean isModified() { + var state = getState(); + + return !settingsComponent.getApiKeyField().getText().equals(state.apiKey) + || !settingsComponent.getModelNameField().getItem().equals(state.modelName) + || !settingsComponent.getTemperatureField().getValue().equals(state.temperature); + } + + @Override + public void updateState() { + var state = getState(); + + state.apiKey = settingsComponent.getApiKeyField().getText(); + state.modelName = settingsComponent.getModelNameField().getItem(); + state.temperature = (Double) settingsComponent.getTemperatureField().getValue(); + } + + @Override + public void reset() { + var state = getState(); + + settingsComponent.getApiKeyField().setText(state.apiKey); + settingsComponent.getModelNameField().setItem(state.modelName); + settingsComponent.getTemperatureField().setValue(state.temperature); + } + + @Override + public void disposeUIResources() { + settingsComponent = null; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/settings/AnthropicSettings.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/settings/AnthropicSettings.java new file mode 100644 index 0000000..2bf4100 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/anthropic/settings/AnthropicSettings.java @@ -0,0 +1,37 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.anthropic.settings; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import lombok.Getter; +import lombok.ToString; +import org.jetbrains.annotations.NotNull; + +@Getter +@Service(Service.Level.APP) +@State( + name = "AnthropicSettings", + storages = {@Storage("InCoderSettings.xml")}) +public final class AnthropicSettings implements PersistentStateComponent { + + private State state = new State(); + + public static AnthropicSettings getInstance() { + return ApplicationManager.getApplication().getService(AnthropicSettings.class); + } + + @Override + public void loadState(@NotNull State state) { + this.state = state; + } + + @ToString + public static class State { + + public String apiKey = ""; + public String modelName = ""; + public Double temperature = 0.2; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/OllamaFactory.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/OllamaFactory.java new file mode 100644 index 0000000..a1dafa2 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/OllamaFactory.java @@ -0,0 +1,12 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.ollama; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.LanguageModelServer; +import com.github.damiano1996.jetbrains.incoder.language.model.server.ServerFactory; + +public class OllamaFactory implements ServerFactory { + + @Override + public LanguageModelServer createServer() { + return new OllamaLanguageModelServer(); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/OllamaLanguageModelServer.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/OllamaLanguageModelServer.java new file mode 100644 index 0000000..e118f64 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/OllamaLanguageModelServer.java @@ -0,0 +1,69 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.ollama; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.BaseLanguageModelServer; +import com.github.damiano1996.jetbrains.incoder.language.model.server.ollama.settings.OllamaSettings; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.chat.StreamingChatLanguageModel; +import dev.langchain4j.model.ollama.*; +import java.util.Collections; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class OllamaLanguageModelServer extends BaseLanguageModelServer { + + private static OllamaSettings.@NotNull State getState() { + return OllamaSettings.getInstance().getState(); + } + + @Override + public ChatLanguageModel createChatLanguageModel() { + return OllamaChatModel.builder() + .baseUrl(getState().baseUrl) + .modelName(getState().modelName) + .temperature(getState().temperature) + .build(); + } + + @Override + public StreamingChatLanguageModel createStreamingChatLanguageModel() { + return OllamaStreamingChatModel.builder() + .baseUrl(getState().baseUrl) + .modelName(getState().modelName) + .temperature(getState().temperature) + .build(); + } + + @Override + public String getName() { + return "Ollama"; + } + + @Override + public List getAvailableModels() { + return getAvailableModels(getState().baseUrl); + } + + public List getAvailableModels(String baseUrl) { + try { + return OllamaModels.builder() + .baseUrl(baseUrl) + .maxRetries(1) + .build() + .availableModels() + .content() + .stream() + .map(OllamaModel::getModel) + .toList(); + } catch (Exception e) { + log.warn("Unable to get available models."); + return Collections.emptyList(); + } + } + + @Override + public String getSelectedModelName() { + return getState().modelName; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/settings/OllamaComponent.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/settings/OllamaComponent.java new file mode 100644 index 0000000..a6768ed --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/settings/OllamaComponent.java @@ -0,0 +1,69 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.ollama.settings; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.ollama.OllamaLanguageModelServer; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.ui.ComboBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBTextField; +import com.intellij.util.ui.FormBuilder; +import java.awt.*; +import java.util.List; +import javax.swing.*; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +public class OllamaComponent { + + private final JPanel mainPanel; + private final JBTextField baseUrlField = new JBTextField(); + private final ComboBox modelNameField; + private final JSpinner temperatureField; + private final JButton refreshButton; + + public OllamaComponent() { + SpinnerNumberModel temperatureModel = new SpinnerNumberModel(0.5, 0.0, 1.0, 0.1); + temperatureField = new JSpinner(temperatureModel); + + modelNameField = + new ComboBox<>( + new OllamaLanguageModelServer() + .getAvailableModels() + .toArray(new String[0])); + modelNameField.setPreferredSize(new Dimension(300, 30)); + + refreshButton = new JButton(AllIcons.Actions.Refresh); + refreshButton.setPreferredSize(new Dimension(30, 30)); + + refreshButton.addActionListener( + e -> { + List availableModels = + new OllamaLanguageModelServer() + .getAvailableModels(baseUrlField.getText()); + modelNameField.removeAllItems(); + availableModels.forEach(modelNameField::addItem); + }); + + mainPanel = + FormBuilder.createFormBuilder() + .setFormLeftIndent(20) + .addLabeledComponent(new JBLabel("Base URL:"), baseUrlField, 1, false) + .addLabeledComponent( + new JBLabel("Model name:"), + new JPanel() { + { + setLayout(new FlowLayout(FlowLayout.LEFT, 0, 0)); + add(modelNameField); + add(refreshButton); + } + }, + 1, + false) + .addLabeledComponent( + new JBLabel("Temperature:"), temperatureField, 1, false) + .setFormLeftIndent(0) + .addComponentFillVertically(new JPanel(), 0) + .getPanel(); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/settings/OllamaConfigurable.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/settings/OllamaConfigurable.java new file mode 100644 index 0000000..b671d20 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/settings/OllamaConfigurable.java @@ -0,0 +1,80 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.ollama.settings; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.BaseServerConfigurable; +import com.github.damiano1996.jetbrains.incoder.language.model.server.ServerFactory; +import com.github.damiano1996.jetbrains.incoder.language.model.server.ollama.OllamaFactory; +import javax.swing.*; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class OllamaConfigurable extends BaseServerConfigurable { + + private OllamaComponent settingsComponent = new OllamaComponent(); + + private static OllamaSettings.@NotNull State getState() { + return OllamaSettings.getInstance().getState(); + } + + @Contract(value = " -> new", pure = true) + @Override + protected @NotNull ServerFactory getServerFactory() { + return new OllamaFactory(); + } + + @Contract(pure = true) + @Nls(capitalization = Nls.Capitalization.Title) + @Override + public @NotNull String getDisplayName() { + return "Ollama"; + } + + @Override + public JComponent getPreferredFocusedComponent() { + return settingsComponent.getMainPanel(); + } + + @Nullable + @Override + public JComponent createComponent() { + return settingsComponent.getMainPanel(); + } + + @Override + public boolean isModified() { + var state = getState(); + + return !settingsComponent.getBaseUrlField().getText().equals(state.baseUrl) + || !getModelName().equals(state.modelName) + || !settingsComponent.getTemperatureField().getValue().equals(state.temperature); + } + + private @NotNull String getModelName() { + var selectedModelName = settingsComponent.getModelNameField().getItem(); + return (selectedModelName == null) ? "" : selectedModelName; + } + + @Override + public void updateState() { + var state = getState(); + + state.baseUrl = settingsComponent.getBaseUrlField().getText(); + state.modelName = getModelName(); + state.temperature = (Double) settingsComponent.getTemperatureField().getValue(); + } + + @Override + public void reset() { + var state = getState(); + + settingsComponent.getBaseUrlField().setText(state.baseUrl); + settingsComponent.getModelNameField().setItem(state.modelName); + settingsComponent.getTemperatureField().setValue(state.temperature); + } + + @Override + public void disposeUIResources() { + settingsComponent = null; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/settings/OllamaSettings.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/settings/OllamaSettings.java new file mode 100644 index 0000000..02f6dce --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/ollama/settings/OllamaSettings.java @@ -0,0 +1,38 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.ollama.settings; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import lombok.Getter; +import lombok.ToString; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Getter +@Service(Service.Level.APP) +@State( + name = "OllamaSettings", + storages = {@Storage("InCoderSettings.xml")}) +public final class OllamaSettings implements PersistentStateComponent { + + @NotNull private State state = new State(); + + public static OllamaSettings getInstance() { + return ApplicationManager.getApplication().getService(OllamaSettings.class); + } + + @Override + public void loadState(@NotNull State state) { + this.state = state; + } + + @ToString + public static class State { + + public String baseUrl = "http://localhost:11434/"; + @Nullable public String modelName = null; + public Double temperature = 0.2; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/OpenAiFactory.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/OpenAiFactory.java new file mode 100644 index 0000000..928f61b --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/OpenAiFactory.java @@ -0,0 +1,12 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.openai; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.LanguageModelServer; +import com.github.damiano1996.jetbrains.incoder.language.model.server.ServerFactory; + +public class OpenAiFactory implements ServerFactory { + + @Override + public LanguageModelServer createServer() { + return new OpenAiLanguageModelServer(); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/OpenAiLanguageModelServer.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/OpenAiLanguageModelServer.java new file mode 100644 index 0000000..f3dd935 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/OpenAiLanguageModelServer.java @@ -0,0 +1,49 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.openai; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.BaseLanguageModelServer; +import com.github.damiano1996.jetbrains.incoder.language.model.server.openai.settings.OpenAiSettings; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.chat.StreamingChatLanguageModel; +import dev.langchain4j.model.openai.*; +import java.util.Arrays; +import java.util.List; + +public class OpenAiLanguageModelServer extends BaseLanguageModelServer { + + private static OpenAiSettings.State getState() { + return OpenAiSettings.getInstance().getState(); + } + + @Override + public ChatLanguageModel createChatLanguageModel() { + return OpenAiChatModel.builder() + .apiKey(getState().apiKey) + .modelName(getState().modelName) + .temperature(getState().temperature) + .build(); + } + + @Override + public StreamingChatLanguageModel createStreamingChatLanguageModel() { + return OpenAiStreamingChatModel.builder() + .apiKey(getState().apiKey) + .modelName(getState().modelName) + .temperature(getState().temperature) + .build(); + } + + @Override + public String getName() { + return "Open AI"; + } + + @Override + public List getAvailableModels() { + return Arrays.stream(OpenAiChatModelName.values()).map(Enum::toString).toList(); + } + + @Override + public String getSelectedModelName() { + return getState().modelName; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/settings/OpenAiComponent.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/settings/OpenAiComponent.java new file mode 100644 index 0000000..0d0e398 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/settings/OpenAiComponent.java @@ -0,0 +1,44 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.openai.settings; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.openai.OpenAiLanguageModelServer; +import com.intellij.openapi.ui.ComboBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.util.ui.FormBuilder; +import java.awt.*; +import javax.swing.*; +import lombok.Getter; + +@Getter +public class OpenAiComponent { + + private final JPanel mainPanel; + private final JPasswordField apiKeyField; + private final ComboBox modelNameField; + private final JSpinner temperatureField; + + public OpenAiComponent() { + apiKeyField = new JPasswordField(); + apiKeyField.setColumns(60); + + SpinnerNumberModel temperatureModel = new SpinnerNumberModel(0.5, 0.0, 1.0, 0.1); + temperatureField = new JSpinner(temperatureModel); + + modelNameField = + new ComboBox<>( + new OpenAiLanguageModelServer() + .getAvailableModels() + .toArray(new String[0])); + modelNameField.setPreferredSize(new Dimension(300, 30)); + + mainPanel = + FormBuilder.createFormBuilder() + .setFormLeftIndent(20) + .addLabeledComponent(new JBLabel("Api key:"), apiKeyField, 1, false) + .addLabeledComponent(new JBLabel("Model name:"), modelNameField, 1, false) + .addLabeledComponent( + new JBLabel("Temperature:"), temperatureField, 1, false) + .setFormLeftIndent(0) + .addComponentFillVertically(new JPanel(), 0) + .getPanel(); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/settings/OpenAiConfigurable.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/settings/OpenAiConfigurable.java new file mode 100644 index 0000000..f180faa --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/settings/OpenAiConfigurable.java @@ -0,0 +1,75 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.openai.settings; + +import com.github.damiano1996.jetbrains.incoder.language.model.server.BaseServerConfigurable; +import com.github.damiano1996.jetbrains.incoder.language.model.server.ServerFactory; +import com.github.damiano1996.jetbrains.incoder.language.model.server.openai.OpenAiFactory; +import javax.swing.*; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class OpenAiConfigurable extends BaseServerConfigurable { + + private OpenAiComponent settingsComponent = new OpenAiComponent(); + + private static OpenAiSettings.State getState() { + return OpenAiSettings.getInstance().getState(); + } + + @Contract(value = " -> new", pure = true) + @Override + protected @NotNull ServerFactory getServerFactory() { + return new OpenAiFactory(); + } + + @Contract(pure = true) + @Nls(capitalization = Nls.Capitalization.Title) + @Override + public @NotNull String getDisplayName() { + return "Open AI"; + } + + @Override + public JComponent getPreferredFocusedComponent() { + return settingsComponent.getMainPanel(); + } + + @Nullable + @Override + public JComponent createComponent() { + return settingsComponent.getMainPanel(); + } + + @Override + public boolean isModified() { + var state = getState(); + + return !settingsComponent.getApiKeyField().getText().equals(state.apiKey) + || !settingsComponent.getModelNameField().getItem().equals(state.modelName) + || !settingsComponent.getTemperatureField().getValue().equals(state.temperature); + } + + @Override + public void updateState() { + var state = getState(); + + state.apiKey = settingsComponent.getApiKeyField().getText(); + state.modelName = settingsComponent.getModelNameField().getItem(); + state.temperature = (Double) settingsComponent.getTemperatureField().getValue(); + } + + @Override + public void reset() { + var state = getState(); + + settingsComponent.getApiKeyField().setText(state.apiKey); + settingsComponent.getModelNameField().setItem(state.modelName); + settingsComponent.getTemperatureField().setValue(state.temperature); + } + + @Override + public void disposeUIResources() { + settingsComponent = null; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/settings/OpenAiSettings.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/settings/OpenAiSettings.java new file mode 100644 index 0000000..0c2f51a --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/language/model/server/openai/settings/OpenAiSettings.java @@ -0,0 +1,37 @@ +package com.github.damiano1996.jetbrains.incoder.language.model.server.openai.settings; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import lombok.Getter; +import lombok.ToString; +import org.jetbrains.annotations.NotNull; + +@Getter +@Service(Service.Level.APP) +@State( + name = "OpenAiSettings", + storages = {@Storage("InCoderSettings.xml")}) +public final class OpenAiSettings implements PersistentStateComponent { + + private State state = new State(); + + public static OpenAiSettings getInstance() { + return ApplicationManager.getApplication().getService(OpenAiSettings.class); + } + + @Override + public void loadState(@NotNull State state) { + this.state = state; + } + + @ToString + public static class State { + + public String apiKey = ""; + public String modelName = ""; + public Double temperature = 0.2; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/notification/NotificationService.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/notification/NotificationService.java new file mode 100644 index 0000000..deb0d1c --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/notification/NotificationService.java @@ -0,0 +1,83 @@ +package com.github.damiano1996.jetbrains.incoder.notification; + +import com.github.damiano1996.jetbrains.incoder.InCoderBundle; +import com.intellij.notification.NotificationAction; +import com.intellij.notification.NotificationGroupManager; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.options.ShowSettingsUtil; +import com.intellij.openapi.project.Project; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +@Service(Service.Level.PROJECT) +public final class NotificationService { + + private final Project project; + + public NotificationService(Project project) { + this.project = project; + } + + public static NotificationService getInstance(@NotNull Project project) { + return project.getService(NotificationService.class); + } + + public void notifyInfo(String message) { + log.debug("Notifying information: {}", message); + notify(message, NotificationType.INFORMATION); + } + + public void notifyWarning(String message) { + log.debug("Notifying warning: {}", message); + notify(message, NotificationType.WARNING); + } + + public void notifyError(String message) { + log.debug("Notifying error: {}", message); + notify(message, NotificationType.ERROR); + } + + private void notify(String message, NotificationType notificationType) { + NotificationGroupManager.getInstance() + .getNotificationGroup(InCoderBundle.message("notification.group")) + .createNotification( + InCoderBundle.message("plugin.title"), message, notificationType) + .notify(project); + } + + public void notifyWelcome() { + NotificationGroupManager.getInstance() + .getNotificationGroup(InCoderBundle.message("notification.group")) + .createNotification( + InCoderBundle.message("plugin.title"), + InCoderBundle.message("notification.welcome"), + NotificationType.INFORMATION) + .notify(project); + } + + public void notifyWithSettingsActionButton() { + notifyWithSettingsActionButton( + InCoderBundle.message("notification.config.description"), + NotificationType.INFORMATION); + } + + public void notifyWithSettingsActionButton( + String description, NotificationType notificationType) { + NotificationGroupManager.getInstance() + .getNotificationGroup(InCoderBundle.message("notification.group")) + .createNotification( + InCoderBundle.message("plugin.title"), description, notificationType) + .addAction( + NotificationAction.createSimple( + InCoderBundle.message("notification.settings.button.name"), + () -> { + log.debug("Opening settings via notification."); + ShowSettingsUtil.getInstance() + .showSettingsDialog( + project, InCoderBundle.message("name")); + })) + .notify(project); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/settings/PluginSettings.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/settings/PluginSettings.java new file mode 100644 index 0000000..b24d7de --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/settings/PluginSettings.java @@ -0,0 +1,35 @@ +package com.github.damiano1996.jetbrains.incoder.settings; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import lombok.Getter; +import lombok.ToString; +import org.jetbrains.annotations.NotNull; + +@Getter +@Service(Service.Level.APP) +@State( + name = "PluginSettings", + storages = {@Storage("InCoderSettings.xml")}) +public final class PluginSettings implements PersistentStateComponent { + + @NotNull private State state = new State(); + + public static PluginSettings getInstance() { + return ApplicationManager.getApplication().getService(PluginSettings.class); + } + + @Override + public void loadState(@NotNull PluginSettings.State state) { + this.state = state; + } + + @ToString + public static class State { + public boolean isFirstPluginRun = true; + public boolean isPluginConfigured = false; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/InCoderToolWindowFactory.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/InCoderToolWindowFactory.java new file mode 100644 index 0000000..dba8936 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/InCoderToolWindowFactory.java @@ -0,0 +1,25 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window; + +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.Chat; +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentFactory; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public final class InCoderToolWindowFactory implements ToolWindowFactory, DumbAware { + + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + + Chat chat = new Chat().setActionListeners(project); + + Content content = + ContentFactory.getInstance().createContent(chat.getMainPanel(), "Chat", false); + toolWindow.getContentManager().addContent(content); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/ToolWindowColors.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/ToolWindowColors.java new file mode 100644 index 0000000..fc3e7f1 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/ToolWindowColors.java @@ -0,0 +1,24 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window; + +import com.intellij.ui.JBColor; +import java.awt.*; + +public class ToolWindowColors { + + public static final JBColor TRANSPARENT = + new JBColor(new Color(0, 0, 0, 0), new Color(0, 0, 0, 0)); + + public static JBColor BADGE_BACKGROUND = + new JBColor(new Color(255, 255, 255, 255), new Color(70, 70, 70, 255)); + public static JBColor BADGE_FOREGROUND = + new JBColor(new Color(40, 40, 40, 255), new Color(190, 190, 190, 255)); + + public static JBColor USER_MESSAGE_BACKGROUND = + new JBColor(new Color(40, 40, 40, 255), new Color(190, 190, 190, 255)); + public static JBColor USER_MESSAGE_FOREGROUND = + new JBColor(new Color(250, 250, 250, 255), new Color(40, 40, 40, 255)); + + public static JBColor AI_MESSAGE_BACKGROUND = TRANSPARENT; + public static JBColor AI_MESSAGE_FOREGROUND = + new JBColor(new Color(40, 40, 40, 255), new Color(210, 210, 210, 255)); +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/Chat.form b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/Chat.form new file mode 100644 index 0000000..fc9290d --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/Chat.form @@ -0,0 +1,47 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/Chat.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/Chat.java new file mode 100644 index 0000000..ad86433 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/Chat.java @@ -0,0 +1,139 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelService; +import com.github.damiano1996.jetbrains.incoder.notification.NotificationService; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.ChatBody; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.AiMessageComponent; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.human.HumanMessageComponent; +import com.github.damiano1996.jetbrains.incoder.ui.components.PlaceholderTextField; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.project.Project; +import com.intellij.ui.JBColor; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.model.output.Response; +import dev.langchain4j.service.TokenStream; +import java.util.function.Consumer; +import javax.swing.*; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class Chat { + + @Getter private JPanel mainPanel; + private JTextField prompt; + private JProgressBar generating; + private ChatBody chatBody; + + public Chat setActionListeners(Project project) { + prompt.addActionListener(e -> handleAction(project)); + return this; + } + + private void handleAction(Project project) { + String prompt = this.prompt.getText(); + handlePrompt(project, prompt); + } + + private void handlePrompt(Project project, @NotNull String prompt) { + if (!LanguageModelService.getInstance(project).isReady()) { + NotificationService.getInstance(project) + .notifyWithSettingsActionButton( + "The Language Model Service is not ready. " + + "Please, configure the Chat and the Server from Settings.", + NotificationType.WARNING); + return; + } + + if (prompt.isEmpty()) { + log.debug("Prompt is empty."); + return; + } + + log.debug("Prompt: {}", prompt); + this.prompt.setText(""); + + HumanMessageComponent humanMessageComponent = new HumanMessageComponent(prompt); + chatBody.addMessage(humanMessageComponent); + + isGenerating(true); + + log.debug("Classifying prompt"); + LanguageModelService.getInstance(project) + .classify(prompt) + .thenAccept( + promptType -> { + log.debug("Prompt classified as: {}", promptType); + humanMessageComponent.setPromptTypeLabel(promptType); + + var aiMessage = new AiMessageComponent(project); + aiMessage.setModelName( + LanguageModelService.getInstance(project) + .getSelectedModelName() + .toLowerCase()); + chatBody.addMessage(aiMessage); + + Editor editor = + FileEditorManager.getInstance(project).getSelectedTextEditor(); + + getChatTokenStreamer(project, prompt, editor) + .onNext( + token -> { + aiMessage.write(token); + chatBody.updateUI(); + }) + .onComplete(onTokenStreamComplete()) + .onError(onTokenStreamError()) + .start(); + }) + .exceptionally( + throwable -> { + log.debug("Error while classifying the prompt.", throwable); + isGenerating(false); + NotificationService.getInstance(project) + .notifyError("Error: %s".formatted(throwable.getMessage())); + return null; + }); + } + + private static TokenStream getChatTokenStreamer( + Project project, @NotNull String prompt, Editor editor) { + return editor == null + ? LanguageModelService.getInstance(project).chat(prompt) + : LanguageModelService.getInstance(project).chat(editor, prompt); + } + + private @NotNull Consumer onTokenStreamError() { + return throwable -> { + log.warn("Error during stream", throwable); + isGenerating(false); + }; + } + + private @NotNull Consumer> onTokenStreamComplete() { + return aiMessageResponse -> { + log.debug("Stream completed."); + isGenerating(false); + }; + } + + private void isGenerating(boolean generating) { + log.debug("Is generating..."); + this.prompt.setEnabled(!generating); + if (!generating) this.prompt.requestFocusInWindow(); + this.generating.setIndeterminate(generating); + this.generating.setVisible(generating); + } + + private void createUIComponents() { + mainPanel = new JPanel(); + mainPanel.setBackground(JBColor.namedColor("ToolWindow.background")); + + prompt = new PlaceholderTextField("Enter a prompt...", 10, 6); + generating = new JProgressBar(); + isGenerating(false); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/ChatBody.form b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/ChatBody.form new file mode 100644 index 0000000..de55f2c --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/ChatBody.form @@ -0,0 +1,27 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/ChatBody.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/ChatBody.java new file mode 100644 index 0000000..20a9eb9 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/ChatBody.java @@ -0,0 +1,82 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body; + +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.MessageComponent; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.ui.JBColor; +import com.intellij.ui.components.JBScrollPane; +import java.awt.*; +import javax.swing.*; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +@Getter +public class ChatBody { + private JPanel mainPanel; + private JPanel messagesPanel; + private JScrollPane scrollPane; + + public void addMessage(@NotNull MessageComponent messageComponent) { + var messageMainPanel = messageComponent.getMainPanel(); + messagesPanel.add(messageMainPanel); + messageMainPanel.setAlignmentY(Component.TOP_ALIGNMENT); + + updateUI(); + } + + public void updateUI() { + messagesPanel.revalidate(); + messagesPanel.repaint(); + scrollToBottomSmoothly(); + } + + private void scrollToBottom() { + ApplicationManager.getApplication() + .invokeLater( + () -> { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.setValue(vertical.getMaximum()); + }); + } + + private void scrollToBottomSmoothly() { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + if (vertical.getValue() + vertical.getVisibleAmount() >= vertical.getMaximum() - 20) { + Timer timer = new Timer(10, null); + timer.addActionListener( + e -> { + int currentValue = vertical.getValue(); + int targetValue = vertical.getMaximum() - vertical.getVisibleAmount(); + if (currentValue < targetValue) { + vertical.setValue(Math.min(currentValue + 10, targetValue)); + } else { + timer.stop(); + } + }); + timer.start(); + } + } + + private void createUIComponents() { + mainPanel = new JPanel(new BorderLayout()); + mainPanel.setBackground(JBColor.namedColor("ToolWindow.background")); + + // Create messages panel with proper constraints + messagesPanel = new JPanel(); + messagesPanel.setLayout(new BoxLayout(messagesPanel, BoxLayout.Y_AXIS)); + messagesPanel.setBackground(JBColor.namedColor("ToolWindow.background")); + messagesPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + messagesPanel.setAlignmentY(Component.TOP_ALIGNMENT); + + // Create a wrapper panel to hold messagesPanel + JPanel wrapperPanel = new JPanel(new BorderLayout()); + wrapperPanel.setBackground(JBColor.namedColor("ToolWindow.background")); + wrapperPanel.add(messagesPanel, BorderLayout.NORTH); + + // Create scroll pane + scrollPane = new JBScrollPane(wrapperPanel); + scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + + mainPanel.add(scrollPane, BorderLayout.CENTER); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/MessageComponent.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/MessageComponent.java new file mode 100644 index 0000000..feca639 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/MessageComponent.java @@ -0,0 +1,14 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages; + +import javax.swing.*; +import org.apache.commons.lang3.NotImplementedException; + +public interface MessageComponent extends StreamWriter { + + JPanel getMainPanel(); + + @Override + default void undoLastWrite() { + throw new NotImplementedException("Undo has not been implemented yet."); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/StreamWriter.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/StreamWriter.java new file mode 100644 index 0000000..be24e0f --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/StreamWriter.java @@ -0,0 +1,22 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages; + +/** A stream writer interface used to manage text input in a chat-like environment. */ +public interface StreamWriter { + + /** + * Writes a single token (string) to the internal buffer. + * + * @param token The string token to be written. + */ + void write(String token); + + /** Undoes the last write operation, effectively removing the most recently added token. */ + void undoLastWrite(); + + /** + * Retrieves the full text that has been written so far. + * + * @return A concatenated string of all tokens written to the buffer. + */ + String getFullText(); +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/AiMessageComponent.form b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/AiMessageComponent.form new file mode 100644 index 0000000..3960572 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/AiMessageComponent.form @@ -0,0 +1,82 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/AiMessageComponent.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/AiMessageComponent.java new file mode 100644 index 0000000..debc709 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/AiMessageComponent.java @@ -0,0 +1,57 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai; + +import static com.github.damiano1996.jetbrains.incoder.InCoderIcons.PLUGIN_ICON; + +import com.github.damiano1996.jetbrains.incoder.tool.window.ToolWindowColors; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.MessageComponent; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.MarkdownPanel; +import com.github.damiano1996.jetbrains.incoder.ui.components.RoundedLabel; +import com.intellij.openapi.project.Project; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBScrollPane; +import javax.swing.*; +import lombok.Getter; + +public class AiMessageComponent implements MessageComponent { + + private final Project project; + @Getter private JPanel mainPanel; + private MarkdownPanel markdownPanel; + private JLabel aiIconLabel; + private JScrollPane scrollPane; + private JLabel modelNameLabel; + + public AiMessageComponent(Project project) { + this.project = project; + } + + public void setModelName(String modelName) { + modelNameLabel.setText(modelName); + modelNameLabel.setVisible(true); + } + + @Override + public void write(String token) { + this.markdownPanel.write(token); + } + + @Override + public String getFullText() { + return markdownPanel.getFullText(); + } + + private void createUIComponents() { + mainPanel = new JPanel(); + + scrollPane = new JBScrollPane(); + + markdownPanel = new MarkdownPanel(project); + + aiIconLabel = new JBLabel(PLUGIN_ICON); + + modelNameLabel = new RoundedLabel(20, 20, 15, 2); + modelNameLabel.setBackground(ToolWindowColors.BADGE_BACKGROUND); + modelNameLabel.setForeground(ToolWindowColors.BADGE_FOREGROUND); + modelNameLabel.setVisible(false); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/MarkdownPanel.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/MarkdownPanel.java new file mode 100644 index 0000000..9e39902 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/MarkdownPanel.java @@ -0,0 +1,133 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown; + +import com.github.damiano1996.jetbrains.incoder.tool.window.ToolWindowColors; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.StreamWriter; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.CodeMarkdownBlock; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.MarkdownBlock; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.TextMarkdownBlock; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.actions.CopyClipboardCodeAction; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.actions.CreateCodeAction; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.actions.MergeAction; +import com.intellij.lang.Language; +import com.intellij.openapi.actionSystem.ActionManager; +import com.intellij.openapi.actionSystem.DefaultActionGroup; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import java.util.LinkedList; +import java.util.List; +import javax.swing.*; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.NotImplementedException; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class MarkdownPanel extends JPanel implements StreamWriter { + + public static final String MARKDOWN_CODE_BLOCK_DELIMITER = "```"; + + private final Project project; + private final List markdownBlocks; + + private boolean isWritingACodeBlock = false; + private boolean nextIsLanguage = false; + + private String fullText = ""; + + public MarkdownPanel(Project project) { + this.project = project; + + setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + setBackground(ToolWindowColors.AI_MESSAGE_BACKGROUND); + setForeground(ToolWindowColors.AI_MESSAGE_FOREGROUND); + setFocusable(false); + + markdownBlocks = new LinkedList<>(); + + addMarkdownEditorPane(); + } + + public @NotNull JComponent getActionToolbarComponent(CodeMarkdownBlock codeBlock) { + var actionGroup = new DefaultActionGroup("Coding Group", true); + + actionGroup.add(new MergeAction(codeBlock)); + actionGroup.add(new CreateCodeAction(codeBlock)); + actionGroup.add(new CopyClipboardCodeAction(codeBlock)); + + var actionToolbar = + ActionManager.getInstance() + .createActionToolbar("CodeBlockToolbar", actionGroup, true); + actionToolbar.setTargetComponent(codeBlock.getComponent()); + + actionToolbar.setMiniMode(false); + return actionToolbar.getComponent(); + } + + @Override + public void write(@NotNull String token) { + log.debug("Token received: {}", token); + + fullText += token; + + if (fullText.trim().endsWith(MARKDOWN_CODE_BLOCK_DELIMITER)) { + nextIsLanguage = !isWritingACodeBlock; + + isWritingACodeBlock = !isWritingACodeBlock; + + if (!isWritingACodeBlock) { + + if (!fullText.endsWith(MARKDOWN_CODE_BLOCK_DELIMITER)) { + // There are tokenizers that split the code block delimiter in two sub-tokens. + // E.g. `` and `\n\n + // If the trimmed version of the fullText differs from the raw one, + // we must undo the last write to clean the code block. + markdownBlocks.get(markdownBlocks.size() - 1).undoLastWrite(); + } + + addMarkdownEditorPane(); + } + + } else if (nextIsLanguage) { + + var language = CodeMarkdownBlock.getLanguage(token); + addCodeEditorPanel(language); + nextIsLanguage = false; + + } else { + markdownBlocks.get(markdownBlocks.size() - 1).write(token); + } + } + + @Override + public void undoLastWrite() { + throw new NotImplementedException(); + } + + @Override + public String getFullText() { + return fullText; + } + + private void addCodeEditorPanel(Language language) { + ApplicationManager.getApplication() + .invokeAndWait( + () -> { + var codeMarkdownBlock = new CodeMarkdownBlock(project, language); + addMarkdownBlock(codeMarkdownBlock); + add(getActionToolbarComponent(codeMarkdownBlock)); + }); + } + + private void addMarkdownEditorPane() { + ApplicationManager.getApplication() + .invokeAndWait( + () -> { + var textMarkdownBlock = new TextMarkdownBlock(); + addMarkdownBlock(textMarkdownBlock); + }); + } + + private void addMarkdownBlock(MarkdownBlock markdownBlock) { + markdownBlocks.add(markdownBlock); + add(markdownBlock.getComponent()); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/CodeMarkdownBlock.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/CodeMarkdownBlock.java new file mode 100644 index 0000000..2de788c --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/CodeMarkdownBlock.java @@ -0,0 +1,103 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks; + +import com.intellij.lang.Language; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.EditorFactory; +import com.intellij.openapi.editor.EditorKind; +import com.intellij.openapi.editor.ex.EditorEx; +import com.intellij.openapi.fileTypes.FileTypeManager; +import com.intellij.openapi.project.Project; +import com.intellij.testFramework.LightVirtualFile; +import javax.swing.*; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class CodeMarkdownBlock implements MarkdownBlock { + + private final Project project; + private Editor editor; + + private String lastWrittenToken = ""; + + public CodeMarkdownBlock(Project project, Language language) { + this.project = project; + initEditor(language); + } + + @Override + public JComponent getComponent() { + return editor.getComponent(); + } + + @Override + public void write(@NotNull String token) { + ApplicationManager.getApplication() + .invokeLater( + () -> { + String currentText = editor.getDocument().getText(); + String updatedText = currentText + token; + + lastWrittenToken = token; + + ApplicationManager.getApplication() + .runWriteAction( + () -> editor.getDocument().setText(updatedText)); + }); + } + + @Override + public void undoLastWrite() { + ApplicationManager.getApplication() + .invokeLater( + () -> { + String currentText = editor.getDocument().getText(); + String updatedText = + currentText.substring( + 0, currentText.length() - lastWrittenToken.length()); + + ApplicationManager.getApplication() + .runWriteAction( + () -> editor.getDocument().setText(updatedText)); + }); + } + + private void initEditor(Language language) { + var fileType = FileTypeManager.getInstance().findFileTypeByLanguage(language); + var virtualFile = new LightVirtualFile("temp", fileType, ""); + var document = EditorFactory.getInstance().createDocument(""); + + ApplicationManager.getApplication() + .invokeAndWait( + () -> { + editor = + EditorFactory.getInstance() + .createEditor( + document, + project, + virtualFile, + false, + EditorKind.PREVIEW); + ((EditorEx) editor).setViewer(true); + }); + } + + @Override + public String getFullText() { + return editor.getDocument().getText(); + } + + public static @NotNull Language getLanguage(String languageName) { + var languages = Language.getRegisteredLanguages(); + + for (Language language : languages) { + if (language.getID().equalsIgnoreCase(languageName)) { + return language; + } + } + + log.debug("Unable to infer the language from the language name"); + return Language.ANY; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/MarkdownBlock.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/MarkdownBlock.java new file mode 100644 index 0000000..5d94799 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/MarkdownBlock.java @@ -0,0 +1,9 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks; + +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.StreamWriter; +import javax.swing.*; + +public interface MarkdownBlock extends StreamWriter { + + JComponent getComponent(); +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/TextMarkdownBlock.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/TextMarkdownBlock.java new file mode 100644 index 0000000..5b55255 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/TextMarkdownBlock.java @@ -0,0 +1,66 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks; + +import com.github.damiano1996.jetbrains.incoder.tool.window.ToolWindowColors; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.parser.Parser; +import javax.swing.*; +import javax.swing.text.html.HTMLEditorKit; +import org.apache.commons.lang3.NotImplementedException; + +public class TextMarkdownBlock extends JEditorPane implements MarkdownBlock { + + private final Parser parser = Parser.builder().build(); + private final HtmlRenderer renderer = HtmlRenderer.builder().build(); + + private String body = ""; + + public TextMarkdownBlock() { + setEditable(false); + HTMLEditorKit editorKit = new HTMLEditorKit(); + setEditorKit(editorKit); + setContentType("text/html"); + setOpaque(false); + setBackground(ToolWindowColors.AI_MESSAGE_BACKGROUND); + setForeground(ToolWindowColors.AI_MESSAGE_FOREGROUND); + setDoubleBuffered(true); + } + + @Override + public void setText(String text) { + SwingUtilities.invokeLater( + () -> { + String html = renderer.render(parser.parse(text)); + String styledHtml = + """ + %s + """ + .formatted(html); + super.setText(styledHtml); + revalidate(); + repaint(); + }); + } + + @Override + public void write(String token) { + body += token; + setText(body); + } + + @Override + public void undoLastWrite() { + throw new NotImplementedException(); + } + + @Override + public String getFullText() { + return body; + } + + @Override + public JComponent getComponent() { + return this; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/actions/CopyClipboardCodeAction.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/actions/CopyClipboardCodeAction.java new file mode 100644 index 0000000..201bb7a --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/actions/CopyClipboardCodeAction.java @@ -0,0 +1,35 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.actions; + +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.CodeMarkdownBlock; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.ide.CopyPasteManager; +import java.awt.datatransfer.StringSelection; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class CopyClipboardCodeAction extends AnAction { + + private final CodeMarkdownBlock codeBlock; + + public CopyClipboardCodeAction(CodeMarkdownBlock codeBlock) { + super( + "Copy to Clipboard", + "Copies the code block content to clipboard", + AllIcons.Actions.Copy); + this.codeBlock = codeBlock; + } + + @Override + public void actionPerformed(@NotNull AnActionEvent anActionEvent) { + ApplicationManager.getApplication() + .invokeLater( + () -> { + String code = codeBlock.getFullText(); + CopyPasteManager.getInstance().setContents(new StringSelection(code)); + }); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/actions/CreateCodeAction.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/actions/CreateCodeAction.java new file mode 100644 index 0000000..e121597 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/actions/CreateCodeAction.java @@ -0,0 +1,99 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.actions; + +import com.github.damiano1996.jetbrains.incoder.language.model.LanguageModelService; +import com.github.damiano1996.jetbrains.incoder.notification.NotificationService; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.CodeMarkdownBlock; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.fileChooser.FileChooser; +import com.intellij.openapi.fileChooser.FileChooserDescriptor; +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class CreateCodeAction extends AnAction { + + private final CodeMarkdownBlock codeBlock; + + public CreateCodeAction(CodeMarkdownBlock codeBlock) { + super( + "Create New File from Code Block", + "Creates a new file with the code block content", + AllIcons.Actions.AddFile); + this.codeBlock = codeBlock; + } + + @Override + public void actionPerformed(@NotNull AnActionEvent anActionEvent) { + var project = anActionEvent.getProject(); + if (project == null) return; + + try { + var selectedFolder = chooseFolder(project); + if (selectedFolder == null) return; + + var selectedFolderPath = Path.of(selectedFolder.getPath()); + + Path filePath; + + if (Files.isDirectory(selectedFolderPath)) { + var fileName = + LanguageModelService.getInstance(project) + .createFileName(codeBlock.getFullText()); + filePath = selectedFolderPath.resolve(fileName); + } else { + filePath = selectedFolderPath; + } + + log.debug("Generated file path: {}", filePath); + + if (Files.exists(filePath)) { + log.debug("The file already exists. Going to propose a merge request"); + new MergeAction(codeBlock).actionPerformed(anActionEvent); + return; + } + + log.debug("Going to create file with code block content"); + createNewFile(project, filePath, codeBlock.getFullText()); + + } catch (Exception e) { + NotificationService.getInstance(project).notifyError(e.getMessage()); + } + } + + private VirtualFile chooseFolder(@NotNull Project project) { + FileChooserDescriptor descriptor = + FileChooserDescriptorFactory.createSingleFolderDescriptor(); + descriptor.setTitle("Select a Folder to Save Your File"); + VirtualFile projectBaseDir = project.getBaseDir(); + return FileChooser.chooseFile(descriptor, project, projectBaseDir); + } + + private void createNewFile(Project project, Path filePath, String fileContent) { + ApplicationManager.getApplication() + .runWriteAction( + () -> { + try { + VirtualFile targetDir = + VfsUtil.createDirectories(filePath.getParent().toString()); + VirtualFile newFile = + targetDir.createChildData( + this, filePath.getFileName().toString()); + VfsUtil.saveText(newFile, fileContent); + FileEditorManager.getInstance(project).openFile(newFile, true); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/actions/MergeAction.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/actions/MergeAction.java new file mode 100644 index 0000000..b31ce57 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/ai/markdown/blocks/actions/MergeAction.java @@ -0,0 +1,92 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.actions; + +import com.github.damiano1996.jetbrains.incoder.notification.NotificationService; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.ai.markdown.blocks.CodeMarkdownBlock; +import com.intellij.diff.DiffManager; +import com.intellij.diff.DiffRequestFactory; +import com.intellij.diff.InvalidDiffRequestException; +import com.intellij.diff.merge.TextMergeRequest; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class MergeAction extends AnAction { + + private final CodeMarkdownBlock codeBlock; + + public MergeAction(CodeMarkdownBlock codeBlock) { + super("Merge with Current Document", "Merge selected changes", AllIcons.Vcs.Merge); + this.codeBlock = codeBlock; + } + + @Override + public void actionPerformed(@NotNull AnActionEvent anActionEvent) { + var project = anActionEvent.getProject(); + + var editor = + FileEditorManager.getInstance(Objects.requireNonNull(project)) + .getSelectedTextEditor(); + + if (editor == null) { + NotificationService.getInstance(project) + .notifyWarning( + "Unable to open a merge request when no file has been selected."); + return; + } + + showDiff(project, codeBlock.getFullText(), editor); + } + + private void showDiff(Project project, String newCode, @NotNull Editor editor) { + try { + Document originalDoc = + FileDocumentManager.getInstance().getDocument(editor.getVirtualFile()); + var textMergeRequest = + showDiffWithProposedChange( + project, + editor.getVirtualFile(), + Objects.requireNonNull(originalDoc).getText(), + newCode); + DiffManager.getInstance().showMerge(project, textMergeRequest); + } catch (IOException | InvalidDiffRequestException e) { + throw new RuntimeException(e); + } + } + + private @NotNull TextMergeRequest showDiffWithProposedChange( + Project project, + VirtualFile originalFile, + @NotNull String originalContent, + @NotNull String proposedContent) + throws IOException, InvalidDiffRequestException { + + var contents = new ArrayList(); + contents.add(originalContent.getBytes()); + contents.add(proposedContent.getBytes()); + contents.add(proposedContent.getBytes()); + + log.debug("Preparing merge request"); + + return DiffRequestFactory.getInstance() + .createTextMergeRequest( + project, + originalFile, + contents, + "InCoder Proposal", + List.of("Original", "Result", "InCoder proposal"), + mergeResult -> log.debug("Merge request result: {}", mergeResult)); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/human/HumanMessageComponent.form b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/human/HumanMessageComponent.form new file mode 100644 index 0000000..4a09436 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/human/HumanMessageComponent.form @@ -0,0 +1,65 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/human/HumanMessageComponent.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/human/HumanMessageComponent.java new file mode 100644 index 0000000..d930a71 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/tool/window/chat/body/messages/human/HumanMessageComponent.java @@ -0,0 +1,51 @@ +package com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.human; + +import com.github.damiano1996.jetbrains.incoder.language.model.client.prompt.PromptType; +import com.github.damiano1996.jetbrains.incoder.tool.window.ToolWindowColors; +import com.github.damiano1996.jetbrains.incoder.tool.window.chat.body.messages.MessageComponent; +import com.github.damiano1996.jetbrains.incoder.ui.components.RoundedLabel; +import com.github.damiano1996.jetbrains.incoder.ui.components.RoundedTextArea; +import javax.swing.*; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +public class HumanMessageComponent implements MessageComponent { + + private final String userPrompt; + + private JTextArea promptTextArea; + + @Getter private JPanel mainPanel; + private JLabel promptTypeLabel; + + public HumanMessageComponent(String userPrompt) { + this.userPrompt = userPrompt; + } + + public void setPromptTypeLabel(@NotNull PromptType promptType) { + promptTypeLabel.setText(promptType.getDisplayName()); + promptTypeLabel.setVisible(true); + } + + @Override + public void write(String token) { + this.promptTextArea.setText(token); + } + + @Override + public String getFullText() { + return promptTextArea.getText(); + } + + private void createUIComponents() { + promptTextArea = new RoundedTextArea(35, 35); + promptTextArea.setBackground(ToolWindowColors.USER_MESSAGE_BACKGROUND); + promptTextArea.setForeground(ToolWindowColors.USER_MESSAGE_FOREGROUND); + promptTextArea.setText(userPrompt); + + promptTypeLabel = new RoundedLabel(20, 20, 15, 2); + promptTypeLabel.setBackground(ToolWindowColors.BADGE_BACKGROUND); + promptTypeLabel.setForeground(ToolWindowColors.BADGE_FOREGROUND); + promptTypeLabel.setVisible(false); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/DescriptionLabel.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/DescriptionLabel.java new file mode 100644 index 0000000..33db609 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/DescriptionLabel.java @@ -0,0 +1,30 @@ +package com.github.damiano1996.jetbrains.incoder.ui.components; + +import com.intellij.openapi.util.NlsContexts; +import com.intellij.ui.JBColor; +import com.intellij.ui.components.JBTextArea; +import com.intellij.util.ui.UIUtil; +import org.jetbrains.annotations.NotNull; + +public class DescriptionLabel extends JBTextArea { + + public DescriptionLabel(@NotNull @NlsContexts.Label String text) { + super(text); + setForeground(JBColor.namedColor("Label.infoForeground")); + setWrapStyleWord(true); + setLineWrap(true); + setEditable(false); + setFocusable(false); + setFont(UIUtil.getLabelFont()); + } + + @Override + public void setCaretPosition(int position) { + // Prevent caret movement + } + + @Override + public boolean isFocusable() { + return false; // Ensure no focus + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/PlaceholderTextField.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/PlaceholderTextField.java new file mode 100644 index 0000000..9559ace --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/PlaceholderTextField.java @@ -0,0 +1,31 @@ +package com.github.damiano1996.jetbrains.incoder.ui.components; + +import com.intellij.ui.components.JBTextField; +import java.awt.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +@AllArgsConstructor +public class PlaceholderTextField extends JBTextField { + + private final String placeholder; + private final int leftPadding; + private final int topPadding; + + @Override + protected void paintComponent(final Graphics pG) { + super.paintComponent(pG); + + if (placeholder == null || placeholder.isEmpty() || !getText().isEmpty()) { + return; + } + + final Graphics2D g = (Graphics2D) pG; + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(getDisabledTextColor()); + g.drawString(placeholder, leftPadding, pG.getFontMetrics().getMaxAscent() + topPadding); + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/RoundedLabel.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/RoundedLabel.java new file mode 100644 index 0000000..644b31f --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/RoundedLabel.java @@ -0,0 +1,63 @@ +package com.github.damiano1996.jetbrains.incoder.ui.components; + +import java.awt.*; +import javax.swing.*; + +public class RoundedLabel extends JLabel { + + private final int arcWidth; + private final int arcHeight; + + private final int horizontalPadding; + private final int verticalPadding; + + public RoundedLabel(int arcWidth, int arcHeight) { + this.arcWidth = arcWidth; + this.arcHeight = arcHeight; + this.horizontalPadding = 0; + this.verticalPadding = 0; + } + + public RoundedLabel(int arcWidth, int arcHeight, int horizontalPadding, int verticalPadding) { + this.arcWidth = arcWidth; + this.arcHeight = arcHeight; + this.horizontalPadding = horizontalPadding; + this.verticalPadding = verticalPadding; + } + + public RoundedLabel(Icon image, int arcWidth, int arcHeight) { + super(image); + this.arcWidth = arcWidth; + this.arcHeight = arcHeight; + this.horizontalPadding = 0; + this.verticalPadding = 0; + } + + public RoundedLabel( + Icon image, int arcWidth, int arcHeight, int horizontalPadding, int verticalPadding) { + super(image); + this.arcWidth = arcWidth; + this.arcHeight = arcHeight; + this.horizontalPadding = horizontalPadding; + this.verticalPadding = verticalPadding; + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g; + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(getBackground()); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), arcWidth, arcHeight); + super.paintComponent(g); + } + + @Override + public int getWidth() { + return super.getWidth() + horizontalPadding; + } + + @Override + public int getHeight() { + return super.getHeight() + verticalPadding; + } +} diff --git a/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/RoundedTextArea.java b/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/RoundedTextArea.java new file mode 100644 index 0000000..c8ff7c1 --- /dev/null +++ b/src/main/java/com/github/damiano1996/jetbrains/incoder/ui/components/RoundedTextArea.java @@ -0,0 +1,24 @@ +package com.github.damiano1996.jetbrains.incoder.ui.components; + +import java.awt.*; +import javax.swing.*; + +public class RoundedTextArea extends JTextArea { + + private final int arcWidth; + private final int arcHeight; + + public RoundedTextArea(int arcWidth, int arcHeight) { + this.arcWidth = arcWidth; + this.arcHeight = arcHeight; + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g; + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(getBackground()); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), arcWidth, arcHeight); + super.paintComponent(g); + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..4f96874 --- /dev/null +++ b/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,77 @@ + + + com.github.damiano1996.jetbrains.incoder + InCoder + Damiano Derin + + com.intellij.modules.platform + + messages.bundle + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/META-INF/pluginIcon.svg b/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..d280540 --- /dev/null +++ b/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/META-INF/pluginIcon_dark.svg b/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000..4fcc97e --- /dev/null +++ b/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..3757833 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + %d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/src/main/resources/messages/bundle.properties b/src/main/resources/messages/bundle.properties new file mode 100644 index 0000000..415ebe2 --- /dev/null +++ b/src/main/resources/messages/bundle.properties @@ -0,0 +1,18 @@ +name=InCoder +plugin.title=InCoder plugin +notification.group=InCoder Notifications +notification.welcome=Thank you for installing InCoder, your AI-powered coding assistant for JetBrains IDEs.
\ + InCoder is here to help you:
\ + - Generate and understand code with ease through the interactive chat.
\ + - Boost productivity with real-time inline code completion.
\ + - Customize your experience with support for multiple LLM providers, including local and cloud-based options. +notification.config.description=Configure the InCoder plugin from Settings. +notification.settings.button.name=Settings +prompt.label=Classifying prompt... +inline.description=Note: This is an experimental feature for completing code inline. \ + It uses the same model designed for chatting, \ + meaning the model is not fine-tuned or optimized specifically for code completion tasks. +plugin.group.name=InCoder +plugin.group.description=Configure the integration with third-party language model providers to chat and complete the code inline. +server.group.name=Server +server.group.description=Configure the third-party language model provider. \ No newline at end of file