diff --git a/.changes/next-release/feature-d2858ed1-62fc-461b-93b3-6edf82df2855.json b/.changes/next-release/feature-d2858ed1-62fc-461b-93b3-6edf82df2855.json new file mode 100644 index 00000000000..ac770600cf9 --- /dev/null +++ b/.changes/next-release/feature-d2858ed1-62fc-461b-93b3-6edf82df2855.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "Added inline chat support. Select some code and hit ⌘+I on Mac or Ctrl+I on Windows to start" +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a5c9bfcddea..eacfb8ffffa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ assertJ = "3.26.3" awsSdk = "2.26.25" commonmark = "0.22.0" detekt = "1.23.7" +diff-util = "4.12" intellijExt = "1.1.8" # match with /settings.gradle.kts intellijGradle = "2.1.0" @@ -71,6 +72,7 @@ commons-collections = { module = "org.apache.commons:commons-collections4", vers commons-io = { module = "commons-io:commons-io", version.ref = "apache-commons-io" } detekt-api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } detekt-formattingRules = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } +diff-util = { module = "io.github.java-diff-utils:java-diff-utils", version.ref = "diff-util" } detekt-test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } gradlePlugin-detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } gradlePlugin-ideaExt = { module = "gradle.plugin.org.jetbrains.gradle.plugin.idea-ext:gradle-idea-ext", version.ref = "intellijExt" } diff --git a/plugins/amazonq/chat/jetbrains-community/build.gradle.kts b/plugins/amazonq/chat/jetbrains-community/build.gradle.kts index daf5d50d116..43f35d4e981 100644 --- a/plugins/amazonq/chat/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/chat/jetbrains-community/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(project(":plugin-amazonq:shared:jetbrains-community")) // everything references codewhisperer, which is not ideal implementation(project(":plugin-amazonq:codewhisperer:jetbrains-community")) + implementation(libs.diff.util) compileOnly(project(":plugin-core:jetbrains-community")) diff --git a/plugins/amazonq/chat/jetbrains-community/resources/META-INF/plugin-chat.xml b/plugins/amazonq/chat/jetbrains-community/resources/META-INF/plugin-chat.xml index 6aaea05a6c6..3272dc35bfc 100644 --- a/plugins/amazonq/chat/jetbrains-community/resources/META-INF/plugin-chat.xml +++ b/plugins/amazonq/chat/jetbrains-community/resources/META-INF/plugin-chat.xml @@ -79,6 +79,13 @@ class="software.aws.toolkits.jetbrains.services.cwc.commands.SendToPromptAction"> + + + + + + diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt index c6aeccc39f3..a752eefb86b 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt @@ -19,6 +19,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.project.ProjectContextCo import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindow import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.cwc.inline.InlineChatController import java.lang.management.ManagementFactory import java.time.Duration import java.util.concurrent.atomic.AtomicBoolean @@ -31,6 +32,7 @@ class AmazonQStartupActivity : ProjectActivity { // initialize html contents in BGT so users don't have to wait when they open the tool window AmazonQToolWindow.getInstance(project) + InlineChatController.getInstance(project) if (CodeWhispererExplorerActionManager.getInstance().getIsFirstRestartAfterQInstall()) { runInEdt { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/model/Requests.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/model/Requests.kt index 14f79e8a0ab..d576088d2d7 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/model/Requests.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/model/Requests.kt @@ -14,6 +14,7 @@ enum class TriggerType { ContextMenu, Hotkeys, CodeScanButton, + Inline, } data class ChatRequestData( diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt index 8da68f5dea2..a55d9cc2075 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt @@ -51,6 +51,7 @@ import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.Recommend import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.Reference import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.SuggestedFollowUp import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.Suggestion +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.TriggerType import software.aws.toolkits.jetbrains.services.cwc.editor.context.ActiveFileContext class ChatSessionV1( @@ -200,6 +201,7 @@ class ChatSessionV1( val userInputMessageContextBuilder = UserInputMessageContext.builder() userInputMessageContextBuilder.editorState(activeFileContext.toEditorState(relevantTextDocuments, useRelevantDocuments)) val userInputMessageContext = userInputMessageContextBuilder.build() + val chatTriggerType = if (triggerType == TriggerType.Inline) ChatTriggerType.INLINE_CHAT else ChatTriggerType.MANUAL val userInput = UserInputMessage.builder() .content(message.take(ChatConstants.CUSTOMER_MESSAGE_SIZE_LIMIT)) @@ -209,7 +211,7 @@ class ChatSessionV1( val conversationState = ConversationState.builder() .conversationId(conversationId) .currentMessage(ChatMessage.fromUserInputMessage(userInput)) - .chatTriggerType(ChatTriggerType.MANUAL) + .chatTriggerType(chatTriggerType) .customizationArn(customization?.arn) .build() return GenerateAssistantResponseRequest.builder() diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt index bbbf9144979..b2cb29d8de5 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt @@ -92,7 +92,7 @@ class ChatController private constructor( ) : InboundAppMessagesHandler { private val messagePublisher: MessagePublisher = context.messagesFromAppToUi - private val telemetryHelper = TelemetryHelper(context, chatSessionStorage) + private val telemetryHelper = TelemetryHelper(context.project, chatSessionStorage) constructor( context: AmazonQAppInitContext, ) : this( @@ -217,7 +217,7 @@ class ChatController private constructor( editor.document.insertString(offset, message.code) - ReferenceLogController.addReferenceLog(message.code, message.codeReference, editor, context.project) + ReferenceLogController.addReferenceLog(message.code, message.codeReference, editor, context.project, null) CodeWhispererUserModificationTracker.getInstance(context.project).enqueue( InsertedCodeModificationEntry( diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ReferenceLogController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ReferenceLogController.kt index b316ae1d032..13537f5b933 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ReferenceLogController.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/ReferenceLogController.kt @@ -9,11 +9,12 @@ import software.amazon.awssdk.services.codewhispererruntime.model.Reference import software.amazon.awssdk.services.codewhispererruntime.model.Span import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.CodeReferenceGenerated import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition import software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow.CodeWhispererCodeReferenceManager import software.aws.toolkits.jetbrains.services.cwc.messages.CodeReference object ReferenceLogController { - fun addReferenceLog(originalCode: String, codeReferences: List?, editor: Editor, project: Project) { + fun addReferenceLog(originalCode: String, codeReferences: List?, editor: Editor, project: Project, inlineChatStartPosition: CaretPosition?) { codeReferences?.let { references -> val cwReferences = references.map { reference -> Reference.builder() @@ -36,7 +37,7 @@ object ReferenceLogController { originalCode, cwReferences, editor, - CodeWhispererEditorUtil.getCaretPosition(editor), + inlineChatStartPosition ?: CodeWhispererEditorUtil.getCaretPosition(editor), null, ) } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/messenger/ChatPromptHandler.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/messenger/ChatPromptHandler.kt index e1804ef0f00..0b6c01b805b 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/messenger/ChatPromptHandler.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/messenger/ChatPromptHandler.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import software.amazon.awssdk.awscore.exception.AwsServiceException import software.amazon.awssdk.services.codewhispererstreaming.model.CodeWhispererStreamingException @@ -55,6 +56,7 @@ class ChatPromptHandler(private val telemetryHelper: TelemetryHelper) { data: ChatRequestData, sessionInfo: ChatSessionInfo, shouldAddIndexInProgressMessage: Boolean, + isInlineChat: Boolean = false, ) = flow { val session = sessionInfo.session session.chat(data) @@ -135,14 +137,19 @@ class ChatPromptHandler(private val telemetryHelper: TelemetryHelper) { ) } } + .onEach { responseEvent -> + if (isInlineChat) processChatEvent(tabId, triggerId, data, responseEvent, shouldAddIndexInProgressMessage)?.let { emit(it) } + } .collect { responseEvent -> - processChatEvent( - tabId, - triggerId, - data, - responseEvent, - shouldAddIndexInProgressMessage - )?.let { emit(it) } + if (!isInlineChat) { + processChatEvent( + tabId, + triggerId, + data, + responseEvent, + shouldAddIndexInProgressMessage + )?.let { emit(it) } + } } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt index e22a7374c1d..635da478306 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt @@ -3,16 +3,17 @@ package software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry +import com.intellij.openapi.project.Project import org.jetbrains.annotations.VisibleForTesting import software.amazon.awssdk.services.codewhispererruntime.model.ChatInteractWithMessageEvent import software.amazon.awssdk.services.codewhispererruntime.model.ChatMessageInteractionType +import software.amazon.awssdk.services.codewhispererruntime.model.InlineChatUserDecision import software.amazon.awssdk.services.codewhispererstreaming.model.UserIntent import software.amazon.awssdk.services.toolkittelemetry.model.Sentiment import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.warn -import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomization import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator @@ -39,14 +40,14 @@ import java.time.Duration import java.time.Instant import software.amazon.awssdk.services.codewhispererruntime.model.UserIntent as CWClientUserIntent -class TelemetryHelper(private val context: AmazonQAppInitContext, private val sessionStorage: ChatSessionStorage) { +class TelemetryHelper(private val project: Project, private val sessionStorage: ChatSessionStorage) { private val responseStreamStartTime: MutableMap = mutableMapOf() private val responseStreamTotalTime: MutableMap = mutableMapOf() private val responseStreamTimeForChunks: MutableMap> = mutableMapOf() private val responseHasProjectContext: MutableMap = mutableMapOf() private val customization: CodeWhispererCustomization? - get() = CodeWhispererModelConfigurator.getInstance().activeCustomization(context.project) + get() = CodeWhispererModelConfigurator.getInstance().activeCustomization(project) fun getConversationId(tabId: String): String? = sessionStorage.getSession(tabId)?.session?.conversationId @@ -63,7 +64,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se } private fun getTelemetryTriggerType(triggerType: TriggerType): CwsprChatTriggerInteraction = when (triggerType) { - TriggerType.Click, TriggerType.CodeScanButton -> CwsprChatTriggerInteraction.Click + TriggerType.Click, TriggerType.CodeScanButton, TriggerType.Inline -> CwsprChatTriggerInteraction.Click TriggerType.ContextMenu, TriggerType.Hotkeys -> CwsprChatTriggerInteraction.ContextMenu } @@ -89,7 +90,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se cwsprChatUserIntent = data.userIntent?.let { getTelemetryUserIntent(it) }, cwsprChatHasCodeSnippet = data.activeFileContext.focusAreaContext?.codeSelection?.isNotEmpty() ?: false, cwsprChatProgrammingLanguage = data.activeFileContext.fileContext?.fileLanguage, - credentialStartUrl = getStartUrl(context.project), + credentialStartUrl = getStartUrl(project), cwsprChatHasProjectContext = getIsProjectContextEnabled() && data.useRelevantDocuments && data.relevantTextDocuments.isNotEmpty() ) } @@ -116,7 +117,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se cwsprChatRequestLength = data.message.length.toLong(), cwsprChatResponseLength = responseLength.toLong(), cwsprChatConversationType = CwsprChatConversationType.Chat, - credentialStartUrl = getStartUrl(context.project), + credentialStartUrl = getStartUrl(project), codewhispererCustomizationArn = data.customization?.arn, cwsprChatHasProjectContext = getMessageHasProjectContext(response.messageId) ) @@ -124,7 +125,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se val programmingLanguage = data.activeFileContext.fileContext?.fileLanguage val validProgrammingLanguage = if (ChatSessionV1.validLanguages.contains(programmingLanguage)) programmingLanguage else null - CodeWhispererClientAdaptor.getInstance(context.project).sendChatAddMessageTelemetry( + CodeWhispererClientAdaptor.getInstance(project).sendChatAddMessageTelemetry( getConversationId(response.tabId).orEmpty(), response.messageId, CWClientUserIntent.fromValue(data.userIntent?.name), @@ -139,11 +140,28 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se numberOfCodeBlocks, getMessageHasProjectContext(response.messageId), data.customization - ).also { - logger.debug { - "Successfully sendTelemetryEvent for ChatAddMessage with requestId=${it.responseMetadata().requestId()}" - } - } + ) + } + + fun recordInlineChatTelemetry( + requestId: String, + inputLength: Int?, + numSelectedLines: Int?, + codeIntent: Boolean?, + userDecision: InlineChatUserDecision?, + responseStartLatency: Double?, + responseEndLatency: Double?, + numSuggestionAddChars: Int?, + numSuggestionAddLines: Int?, + numSuggestionDelChars: Int?, + numSuggestionDelLines: Int?, + programmingLanguage: String?, + ) { + CodeWhispererClientAdaptor.getInstance(project).sendInlineChatTelemetry( + requestId, inputLength, numSelectedLines, codeIntent, userDecision, + responseStartLatency, responseEndLatency, numSuggestionAddChars, numSuggestionAddLines, numSuggestionDelChars, numSuggestionDelLines, + programmingLanguage + ) } fun recordMessageResponseError(data: ChatRequestData, tabId: String, responseCode: Int) { @@ -158,7 +176,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se cwsprChatResponseCode = responseCode.toLong(), cwsprChatRequestLength = data.message.length.toLong(), cwsprChatConversationType = CwsprChatConversationType.Chat, - credentialStartUrl = getStartUrl(context.project) + credentialStartUrl = getStartUrl(project) ) } @@ -174,7 +192,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se "downvote" -> CwsprChatInteractionType.Downvote else -> CwsprChatInteractionType.Unknown }, - credentialStartUrl = getStartUrl(context.project), + credentialStartUrl = getStartUrl(project), cwsprChatHasProjectContext = getMessageHasProjectContext(message.messageId) ) ChatInteractWithMessageEvent.builder().apply { @@ -196,7 +214,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se cwsprChatConversationId = getConversationId(message.tabId).orEmpty(), cwsprChatMessageId = message.messageId.orEmpty(), cwsprChatInteractionType = CwsprChatInteractionType.ClickFollowUp, - credentialStartUrl = getStartUrl(context.project), + credentialStartUrl = getStartUrl(project), cwsprChatHasProjectContext = getMessageHasProjectContext(message.messageId.orEmpty()) ) ChatInteractWithMessageEvent.builder().apply { @@ -216,7 +234,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se cwsprChatAcceptedCharactersLength = message.code.length.toLong(), cwsprChatInteractionTarget = message.insertionTargetType, cwsprChatHasReference = null, - credentialStartUrl = getStartUrl(context.project), + credentialStartUrl = getStartUrl(project), cwsprChatCodeBlockIndex = message.codeBlockIndex?.toLong(), cwsprChatTotalCodeBlocks = message.totalCodeBlocks?.toLong(), cwsprChatHasProjectContext = getMessageHasProjectContext(message.messageId), @@ -242,7 +260,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se cwsprChatAcceptedNumberOfLines = message.code.lines().size.toLong(), cwsprChatInteractionTarget = message.insertionTargetType, cwsprChatHasReference = null, - credentialStartUrl = getStartUrl(context.project), + credentialStartUrl = getStartUrl(project), cwsprChatCodeBlockIndex = message.codeBlockIndex?.toLong(), cwsprChatTotalCodeBlocks = message.totalCodeBlocks?.toLong(), cwsprChatHasProjectContext = getMessageHasProjectContext(message.messageId), @@ -274,7 +292,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se cwsprChatInteractionType = linkInteractionType, cwsprChatInteractionTarget = message.link, cwsprChatHasReference = null, - credentialStartUrl = getStartUrl(context.project), + credentialStartUrl = getStartUrl(project), cwsprChatHasProjectContext = getMessageHasProjectContext(message.messageId) ) ChatInteractWithMessageEvent.builder().apply { @@ -308,7 +326,7 @@ class TelemetryHelper(private val context: AmazonQAppInitContext, private val se } event?.let { - val steResponse = CodeWhispererClientAdaptor.getInstance(context.project).sendChatInteractWithMessageTelemetry(it) + val steResponse = CodeWhispererClientAdaptor.getInstance(project).sendChatInteractWithMessageTelemetry(it) logger.debug { "Successfully sendTelemetryEvent for ChatInteractWithMessage with requestId=${steResponse.responseMetadata().requestId()}" } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ActiveFileContextExtractor.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ActiveFileContextExtractor.kt index f2e6a2df082..7d1231b9551 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ActiveFileContextExtractor.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ActiveFileContextExtractor.kt @@ -34,7 +34,7 @@ class ActiveFileContextExtractor( } companion object { - fun create(fqnWebviewAdapter: FqnWebviewAdapter, project: Project) = ActiveFileContextExtractor( + fun create(fqnWebviewAdapter: FqnWebviewAdapter?, project: Project) = ActiveFileContextExtractor( fileContextExtractor = FileContextExtractor(fqnWebviewAdapter, project), focusAreaContextExtractor = FocusAreaContextExtractor(fqnWebviewAdapter, project), ) diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/FileContextExtractor.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/FileContextExtractor.kt index 1998c3fe321..888bdb198bb 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/FileContextExtractor.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/FileContextExtractor.kt @@ -14,7 +14,7 @@ import software.aws.toolkits.jetbrains.services.cwc.editor.context.file.util.Lan import software.aws.toolkits.jetbrains.services.cwc.editor.context.file.util.MatchPolicyExtractor import software.aws.toolkits.jetbrains.utils.computeOnEdt -class FileContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter, private val project: Project) { +class FileContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter?, private val project: Project) { private val languageExtractor: LanguageExtractor = LanguageExtractor() suspend fun extract(): FileContext? { val editor = computeOnEdt { @@ -22,7 +22,7 @@ class FileContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter, pri } ?: return null val fileLanguage = computeOnEdt { - languageExtractor.extractLanguageNameFromCurrentFile(editor, project) + languageExtractor.extractLanguageNameFromCurrentFile(editor) } val fileText = computeOnEdt { editor.document.text diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt index a63cc67d429..726edc0212d 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt @@ -4,17 +4,12 @@ package software.aws.toolkits.jetbrains.services.cwc.editor.context.file.util import com.intellij.openapi.application.runReadAction -import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor -import com.intellij.openapi.project.Project -import com.intellij.psi.PsiDocumentManager -import com.intellij.psi.PsiFile +import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmingLanguage class LanguageExtractor { - fun extractLanguageNameFromCurrentFile(editor: Editor, project: Project): String? = + fun extractLanguageNameFromCurrentFile(editor: Editor): String = runReadAction { - val doc: Document = editor.document - val psiFile: PsiFile? = PsiDocumentManager.getInstance(project).getPsiFile(doc) - psiFile?.fileType?.name?.lowercase() + editor.virtualFile.programmingLanguage().languageId } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/MatchPolicyExtractor.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/MatchPolicyExtractor.kt index 0585407bca7..4ec873aa671 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/MatchPolicyExtractor.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/MatchPolicyExtractor.kt @@ -15,7 +15,7 @@ object MatchPolicyExtractor { isCodeSelected: Boolean = false, fileLanguage: String?, fileText: String?, - fqnWebviewAdapter: FqnWebviewAdapter, + fqnWebviewAdapter: FqnWebviewAdapter?, ): MatchPolicy? { val should = extractAdditionalLanguageMatchPolicies(fileLanguage) @@ -29,7 +29,7 @@ object MatchPolicyExtractor { val requestString = ChatController.objectMapper.writeValueAsString(readImportsRequest) return try { - val importsString = fqnWebviewAdapter.readImports(requestString) + val importsString = fqnWebviewAdapter?.readImports(requestString) ?: "[]" val imports = ChatController.objectMapper.readValue>(importsString) imports diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt index ceadc7c41d5..b494ddbae4f 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt @@ -14,13 +14,14 @@ import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.services.amazonq.webview.FqnWebviewAdapter import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.CodeNames import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.CodeNamesImpl +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.FullyQualifiedNames import software.aws.toolkits.jetbrains.services.cwc.controller.ChatController import software.aws.toolkits.jetbrains.services.cwc.editor.context.file.util.LanguageExtractor import software.aws.toolkits.jetbrains.utils.computeOnEdt import java.awt.Point import kotlin.math.min -class FocusAreaContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter, private val project: Project) { +class FocusAreaContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter?, private val project: Project) { private val languageExtractor: LanguageExtractor = LanguageExtractor() suspend fun extract(): FocusAreaContext? { @@ -105,7 +106,7 @@ class FocusAreaContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter // Retrieve from trimmedFileText val fileLanguage = computeOnEdt { - languageExtractor.extractLanguageNameFromCurrentFile(editor, project) + languageExtractor.extractLanguageNameFromCurrentFile(editor) } val fileText = editor.document.text val fileName = FileEditorManager.getInstance(project).selectedFiles.first().name @@ -140,8 +141,9 @@ class FocusAreaContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter val requestString = ChatController.objectMapper.writeValueAsString(extractNamesRequest) codeNames = try { - val namesString = fqnWebviewAdapter.extractNames(requestString) - ChatController.objectMapper.readValue(namesString, CodeNamesImpl::class.java) + fqnWebviewAdapter?.let { + ChatController.objectMapper.readValue(it.extractNames(requestString), CodeNamesImpl::class.java) + } ?: CodeNamesImpl(simpleNames = emptyList(), fullyQualifiedNames = FullyQualifiedNames(used = emptyList())) } catch (e: Exception) { getLogger().warn(e) { "Failed to extract names from file" } null diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatController.kt new file mode 100644 index 00000000000..42d40ed1079 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatController.kt @@ -0,0 +1,722 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.inline + +import com.github.difflib.text.DiffRow +import com.github.difflib.text.DiffRowGenerator +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.command.CommandProcessor +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.RangeMarker +import com.intellij.openapi.editor.event.CaretEvent +import com.intellij.openapi.editor.event.CaretListener +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.RangeHighlighter +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupListener +import com.intellij.openapi.ui.popup.LightweightWindowEvent +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.JBColor +import com.intellij.ui.jcef.JBCefApp +import com.jetbrains.rd.util.AtomicInteger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.apache.commons.text.StringEscapeUtils +import software.amazon.awssdk.services.codewhispererruntime.model.InlineChatUserDecision +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.EDT +import software.aws.toolkits.jetbrains.core.gettingstarted.requestCredentialsForQ +import software.aws.toolkits.jetbrains.core.webview.BrowserState +import software.aws.toolkits.jetbrains.services.amazonq.QWebviewPanel +import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController +import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AMAZON_Q_WINDOW_ID +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatRequestData +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.TriggerType +import software.aws.toolkits.jetbrains.services.cwc.controller.ReferenceLogController +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.messenger.ChatPromptHandler +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.TelemetryHelper +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.userIntent.UserIntentRecognizer +import software.aws.toolkits.jetbrains.services.cwc.editor.context.ActiveFileContextExtractor +import software.aws.toolkits.jetbrains.services.cwc.editor.context.ExtractionTriggerType +import software.aws.toolkits.jetbrains.services.cwc.inline.listeners.InlineChatFileListener +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessageType +import software.aws.toolkits.jetbrains.services.cwc.storage.ChatSessionStorage +import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings +import software.aws.toolkits.telemetry.FeatureId +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.max + +@Service(Service.Level.PROJECT) +class InlineChatController( + private val project: Project, + private val scope: CoroutineScope, +) : Disposable { + private var currentPopup: JBPopup? = null + private var rangeHighlighter: RangeHighlighter? = null + private var rejectAction: (() -> Unit)? = null + private var acceptAction: (() -> Unit)? = null + private var insertionLine = AtomicInteger(-1) + private val sessionStorage = ChatSessionStorage() + private val telemetryHelper = TelemetryHelper(project, sessionStorage) + private val shouldShowActions = AtomicBoolean(false) + private val isInProgress = AtomicBoolean(false) + private var metrics: InlineChatMetrics? = null + private var canPopupAbort = AtomicBoolean(true) + private var currentSelectionRange: RangeMarker? = null + private val listener = InlineChatFileListener(project, this) + private var isAbandoned = AtomicBoolean(false) + + init { + Disposer.register(this, listener) + project.messageBus.connect(this).subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, listener) + } + + data class InlineChatMetrics( + val requestId: String, + val inputLength: Int? = null, + val numSelectedLines: Int? = null, + val codeIntent: Boolean? = null, + var userDecision: InlineChatUserDecision? = null, + val responseStartLatency: Double? = null, + val responseEndLatency: Double? = null, + var numSuggestionAddChars: Int? = null, + var numSuggestionAddLines: Int? = null, + var numSuggestionDelChars: Int? = null, + var numSuggestionDelLines: Int? = null, + var programmingLanguage: String? = null, + ) + + private val popupSubmitHandler: suspend (String, String, Int, Editor) -> String = { + prompt: String, selectedCode: String, selectedLineStart: Int, editor: Editor -> + runBlocking { + isInProgress.set(true) + val message = handleChat(prompt, selectedCode, editor, selectedLineStart) + message + } + } + + val popupCancelHandler: (editor: Editor) -> Unit = { editor -> + isAbandoned.set(true) + if (canPopupAbort.get() && currentPopup != null) { + undoChanges() + restoreSelection(editor) + ApplicationManager.getApplication().executeOnPooledThread { + recordInlineChatTelemetry(InlineChatUserDecision.DISMISS) + } + currentPopup?.dispose() + } + } + + private fun recordInlineChatTelemetry(decision: InlineChatUserDecision) { + if (metrics == null) return + metrics?.userDecision = decision + if (metrics?.requestId?.isNotEmpty() == true) { + telemetryHelper.recordInlineChatTelemetry( + metrics?.requestId!!, + metrics?.inputLength, + metrics?.numSelectedLines, + metrics?.codeIntent, + metrics?.userDecision, + metrics?.responseStartLatency, + metrics?.responseEndLatency, + metrics?.numSuggestionAddChars, + metrics?.numSuggestionAddLines, + metrics?.numSuggestionDelChars, + metrics?.numSuggestionDelLines, + metrics?.programmingLanguage + ) + } + metrics = null + } + + private fun undoChanges() { + scope.launch(EDT) { + rejectAction?.invoke() + rejectAction = null + acceptAction = null + } + } + + private val diffAcceptHandler: () -> Unit = { + scope.launch(EDT) { + rejectAction = null + acceptAction?.invoke() + acceptAction = null + invokeLater { hidePopup() } + } + ApplicationManager.getApplication().executeOnPooledThread { + recordInlineChatTelemetry(InlineChatUserDecision.ACCEPT) + } + } + + private val diffRejectHandler: (editor: Editor) -> Unit = { editor -> + undoChanges() + invokeLater { hidePopup() } + restoreSelection(editor) + ApplicationManager.getApplication().executeOnPooledThread { + recordInlineChatTelemetry(InlineChatUserDecision.REJECT) + } + } + + private fun addPopupListeners(popup: JBPopup, editor: Editor) { + val popupListener = object : JBPopupListener { + + override fun onClosed(event: LightweightWindowEvent) { + if (canPopupAbort.get() && event.asPopup().isDisposed) { + popupCancelHandler.invoke(editor) + } + } + } + popup.addListener(popupListener) + } + + fun initPopup(editor: Editor) { + currentPopup?.let { Disposer.dispose(it) } + currentPopup = InlineChatPopupFactory( + acceptHandler = diffAcceptHandler, rejectHandler = { diffRejectHandler(editor) }, + submitHandler = popupSubmitHandler, cancelHandler = { popupCancelHandler(editor) } + ).createPopup(editor, scope) + addPopupListeners(currentPopup!!, editor) + Disposer.register(this, currentPopup!!) + canPopupAbort.set(true) + val caretListener = createCaretListener(editor) + editor.caretModel.addCaretListener(caretListener) + } + + private fun createCaretListener(editor: Editor): CaretListener = object : CaretListener { + override fun caretPositionChanged(event: CaretEvent) { + disposePopup(false) + + editor.caretModel.removeCaretListener(this) + } + } + + private fun removeSelection(editor: Editor) { + scope.launch(EDT) { + val selectionModel = editor.selectionModel + selectionModel.removeSelection() + } + } + + private fun restoreSelection(editor: Editor) { + currentSelectionRange?.let { range -> + scope.launch(EDT) { + val selectionModel = editor.selectionModel + selectionModel.setSelection(range.startOffset, range.endOffset) + } + } + currentSelectionRange = null + } + + private fun highlightCodeWithBackgroundColor(editor: Editor, startOffset: Int, endOffset: Int, isGreen: Boolean) { + val greenBackgroundAttributes = TextAttributes().apply { + backgroundColor = JBColor(0xAADEAA, 0x294436) + effectColor = JBColor(0xAADEAA, 0x294436) + } + + val redBackgroundAttributes = TextAttributes().apply { + backgroundColor = JBColor(0xFFC8BD, 0x45302B) + effectColor = JBColor(0xFFC8BD, 0x45302B) + } + val attributes = if (isGreen) greenBackgroundAttributes else redBackgroundAttributes + rangeHighlighter = editor.markupModel.addRangeHighlighter( + startOffset, endOffset, HighlighterLayer.SELECTION + 1, + attributes, HighlighterTargetArea.EXACT_RANGE + ) + } + + private fun hidePopup() { + canPopupAbort.set(false) + currentPopup?.closeOk(null) + isInProgress.set(false) + shouldShowActions.set(false) + currentSelectionRange = null + } + + fun disposePopup(isFromFileChange: Boolean) { + if (currentPopup != null && !shouldShowActions.get() || isFromFileChange) { + currentPopup?.let { Disposer.dispose(it) } + hidePopup() + currentPopup = null + } + } + + private fun compareDiffs(original: List, recommendation: List): List { + val generator = DiffRowGenerator.create().showInlineDiffs(false).build() + val rows: List = generator.generateDiffRows(original, recommendation) + return rows + } + + private fun unescape(s: String): String = StringEscapeUtils.unescapeHtml3(s) + .replace(""", "\"") + .replace("'", "'") + .replace("=>", "=>") + + private fun processNewCode(editor: Editor, line: Int, event: ChatMessage, prevMessage: String) { + if (isAbandoned.get()) return + runBlocking { + val code = event.message?.let { unescape(it) } ?: return@runBlocking + logger.debug { "received inline chat recommendation with code: \n $code" } + var insertLine = line + var linesToAdd = emptyList() + val prevLines = prevMessage.split("\n") + if (prevLines.size > 1 && code.startsWith(prevMessage)) { + if (insertionLine.get() != -1) insertLine = insertionLine.get() + linesToAdd = code.split("\n").drop(prevLines.size - 1) + } else { + linesToAdd = code.split("\n") + } + if (linesToAdd.last() == "") linesToAdd = linesToAdd.dropLast(1) + val stringToAdd = if (linesToAdd.size > 1) linesToAdd.joinToString(separator = "\n") else linesToAdd.first() + if (currentPopup?.isVisible != true) { + logger.debug { "inline chat popup cancelled before diff is shown" } + isInProgress.set(false) + isAbandoned.set(true) + recordInlineChatTelemetry(InlineChatUserDecision.DISMISS) + return@runBlocking + } + withContext(EDT) { + insertNewLineIfNeeded(insertLine, editor) + insertString(editor, getLineStartOffset(editor.document, insertLine), stringToAdd + "\n") + } + insertLine += linesToAdd.size + insertionLine.set(insertLine) + acceptAction = { + removeHighlighter(editor) + try { + val caretPosition = CaretPosition(offset = getLineStartOffset(editor.document, line), line = line) + ReferenceLogController.addReferenceLog(code, event.codeReference, editor, project, inlineChatStartPosition = caretPosition) + } catch (e: Exception) { + logger.warn { "error logging reference for inline chat: ${e.stackTraceToString()}" } + } + } + rejectAction = { + val startOffset = getLineStartOffset(editor.document, line) + val endOffset = getLineEndOffset(editor.document, line + code.split("\n").size - 1) + replaceString(editor.document, startOffset, endOffset, "") + removeHighlighter(editor) + } + isInProgress.set(false) + shouldShowActions.set(true) + } + } + + private fun processHighlights(diffRows: List, startLine: Int, editor: Editor) { + removeHighlighter(editor) + var currentDocumentLine = startLine + diffRows.forEach { row -> + when (row.tag) { + DiffRow.Tag.EQUAL -> { + currentDocumentLine++ + } + DiffRow.Tag.DELETE -> { + val startOffset = getLineStartOffset(editor.document, currentDocumentLine) + val endOffset = getLineEndOffset(editor.document, currentDocumentLine, true) + highlightString(editor, startOffset, endOffset, false) + currentDocumentLine++ + } + + DiffRow.Tag.CHANGE -> { + val startOffset = getLineStartOffset(editor.document, currentDocumentLine) + val endOffset = getLineEndOffset(editor.document, currentDocumentLine, true) + highlightString(editor, startOffset, endOffset, false) + val insetStartOffset = getLineStartOffset(editor.document, currentDocumentLine + 1) + val insertEndOffset = getLineEndOffset(editor.document, currentDocumentLine + 1, true) + highlightString(editor, insetStartOffset, insertEndOffset, true) + currentDocumentLine += 2 + } + + DiffRow.Tag.INSERT -> { + val insetStartOffset = getLineStartOffset(editor.document, currentDocumentLine) + val insertEndOffset = getLineEndOffset(editor.document, currentDocumentLine, true) + highlightString(editor, insetStartOffset, insertEndOffset, true) + currentDocumentLine++ + } + } + } + } + + private fun applyChunk(recommendation: String, editor: Editor, startLine: Int, endLine: Int) { + val startOffset = getLineStartOffset(editor.document, startLine) + val endOffset = getLineEndOffset(editor.document, endLine) + replaceString(editor.document, startOffset, endOffset, recommendation) + } + + private fun constructPatch(diff: List): String { + var patchString = "" + diff.forEach { row -> + when (row.tag) { + DiffRow.Tag.EQUAL -> { + patchString += row.oldLine + "\n" + } + + DiffRow.Tag.DELETE -> { + patchString += row.oldLine + "\n" + } + + DiffRow.Tag.CHANGE -> { + patchString += row.oldLine + "\n" + patchString += row.newLine + "\n" + } + + DiffRow.Tag.INSERT -> { + patchString += row.newLine + "\n" + } + } + } + return unescape(patchString) + } + + private fun finalComputation(selectedCode: String, finalMessage: String?) { + if (finalMessage == null) { + canPopupAbort.set(true) + throw Exception("No suggestions from Q; please try a different instruction.") + } + var numSuggestionAddChars = 0 + var numSuggestionAddLines = 0 + var numSuggestionDelChars = 0 + var numSuggestionDelLines = 0 + + val selection = selectedCode.split("\n") + val recommendationList = unescape(finalMessage).split("\n") + val diff = compareDiffs(selection, recommendationList) + var isAllEqual = true + diff.forEach { row -> + when (row.tag) { + DiffRow.Tag.EQUAL -> { + // no-op + } + DiffRow.Tag.DELETE -> { + isAllEqual = false + numSuggestionDelLines += 1 + numSuggestionDelChars += row.oldLine.length + } + + DiffRow.Tag.CHANGE -> { + isAllEqual = false + numSuggestionDelLines += 1 + numSuggestionDelChars += row.oldLine.length + numSuggestionAddLines += 1 + numSuggestionAddChars = row.newLine.length + } + + DiffRow.Tag.INSERT -> { + isAllEqual = false + numSuggestionAddLines += 1 + numSuggestionAddChars += row.newLine.length + } + } + } + metrics?.numSuggestionAddChars = numSuggestionAddChars + metrics?.numSuggestionAddLines = numSuggestionAddLines + metrics?.numSuggestionDelChars = numSuggestionDelChars + metrics?.numSuggestionDelLines = numSuggestionDelLines + if (isAllEqual) { + canPopupAbort.set(true) + throw Exception("No suggestions from Q; please try a different instruction.") + } + } + + private fun processChatDiff(selectedCode: String, event: ChatMessage, editor: Editor, selectionRange: RangeMarker) { + if (isAbandoned.get()) return + if (event.message?.isNotEmpty() == true) { + logger.info { "inline chat recommendation: \n ${event.message}" } + runBlocking { + val recommendation = unescape(event.message) + val selection = selectedCode.split("\n") + val recommendationList = recommendation.split("\n") + val diff = compareDiffs(selection, recommendationList) + val startLine = getLineNumber(editor.document, selectionRange.startOffset) + val endLine = getLineNumber(editor.document, selectionRange.endOffset) + val patchString = constructPatch(diff) + if (currentPopup?.isVisible != true) { + logger.debug { "inline chat popup cancelled before diff is shown" } + isInProgress.set(false) + isAbandoned.set(true) + recordInlineChatTelemetry(InlineChatUserDecision.DISMISS) + return@runBlocking + } + withContext(EDT) { + removeSelection(editor) + applyChunk(patchString, editor, startLine, endLine) + processHighlights(diff, startLine, editor) + } + acceptAction = { + val startOffset = getLineStartOffset(editor.document, startLine) + val endOffset = getLineEndOffset(editor.document, max((startLine + patchString.split("\n").size - 1), endLine)) + replaceString(editor.document, startOffset, endOffset, recommendation) + removeHighlighter(editor) + try { + val caretPosition = + CaretPosition(offset = selectionRange.startOffset, line = getLineNumber(editor.document, selectionRange.startOffset)) + ReferenceLogController.addReferenceLog(recommendation, event.codeReference, editor, project, inlineChatStartPosition = caretPosition) + } catch (e: Exception) { + logger.warn { "error logging reference for inline chat: ${e.stackTraceToString()}" } + } + } + rejectAction = { + val startOffset = getLineStartOffset(editor.document, startLine) + val endOffset = getLineEndOffset(editor.document, max((startLine + patchString.split("\n").size - 1), endLine)) + replaceString(editor.document, startOffset, endOffset, selectedCode) + removeHighlighter(editor) + } + + isInProgress.set(false) + shouldShowActions.set(true) + } + } + } + + private fun insertNewLineIfNeeded(row: Int, editor: Editor): Int { + var newLineInserted = 0 + while (row > editor.document.lineCount - 1) { + insertString(editor, editor.document.textLength, "\n") + newLineInserted++ + } + return newLineInserted + } + + private fun getLineStartOffset(document: Document, row: Int): Int = ReadAction.compute { + document.getLineStartOffset(row) + } + + private fun getLineEndOffset(document: Document, row: Int, includeLastNewLine: Boolean = false): Int = ReadAction.compute { + if (row == document.lineCount - 1) { + document.getLineEndOffset(row) + } else if (row < document.lineCount - 1) { + val lineEnd = document.getLineEndOffset(row) + if (includeLastNewLine) lineEnd + 1 else lineEnd + } else { + document.getLineEndOffset((document.lineCount - 1).coerceAtLeast(0)) + } + } + + private fun getLineNumber(document: Document, offset: Int): Int = ReadAction.compute { + document.getLineNumber(offset) + } + + private fun insertString(editor: Editor, offset: Int, text: String): RangeMarker { + var rangeMarker: RangeMarker? = null + + ApplicationManager.getApplication().invokeAndWait { + CommandProcessor.getInstance().runUndoTransparentAction { + WriteCommandAction.runWriteCommandAction(project) { + editor.document.insertString(offset, text) + rangeMarker = editor.document.createRangeMarker(offset, offset + text.length) + } + rangeMarker?.let { marker -> + highlightCodeWithBackgroundColor(editor, marker.startOffset, marker.endOffset, true) + } + } + } + + return rangeMarker!! + } + + private fun replaceString(document: Document, start: Int, end: Int, text: String) { + ApplicationManager.getApplication().invokeAndWait { + CommandProcessor.getInstance().runUndoTransparentAction { + WriteCommandAction.runWriteCommandAction(project) { + document.replaceString(start, end, text) + } + } + } + } + + private fun highlightString(editor: Editor, start: Int, end: Int, isInsert: Boolean) { + ApplicationManager.getApplication().invokeAndWait { + CommandProcessor.getInstance().runUndoTransparentAction { + WriteCommandAction.runWriteCommandAction(project) { + highlightCodeWithBackgroundColor(editor, start, end, isInsert) + } + } + } + } + + private fun removeHighlighter(editor: Editor) { + ApplicationManager.getApplication().invokeAndWait { + CommandProcessor.getInstance().runUndoTransparentAction { + WriteCommandAction.runWriteCommandAction(project) { + editor.markupModel.removeAllHighlighters() + } + } + } + } + + private fun checkCredentials(): String? { + val authController = AuthController() + val credentialState = authController.getAuthNeededStates(project).chat + if (credentialState != null) { + if (!JBCefApp.isSupported()) { + requestCredentialsForQ(project, isReauth = false) + } else { + runInEdt { + QWebviewPanel.getInstance(project).browser?.prepareBrowser(BrowserState(FeatureId.Q)) + ToolWindowManager.getInstance(project).getToolWindow(AMAZON_Q_WINDOW_ID)?.activate(null, false) + ToolWindowManager.getInstance(project).getToolWindow(AMAZON_Q_WINDOW_ID)?.show() + } + } + scope.launch { + delay(3000) + withContext(EDT) { + disposePopup(true) + } + } + return "Please sign in to Amazon Q" + } + return null + } + + private suspend fun handleChat(message: String, selectedCode: String = "", editor: Editor, selectedLineStart: Int): String { + insertionLine.set(-1) + isAbandoned.set(false) + val authError = checkCredentials() + if (authError != null) { + return authError + } + val selectionStart = getLineStartOffset(editor.document, selectedLineStart) + var selectionRange: RangeMarker? = null + if (selectedCode.isNotEmpty()) { + WriteCommandAction.runWriteCommandAction(project) { + selectionRange = editor.document.createRangeMarker(selectionStart, selectionStart + selectedCode.length) + currentSelectionRange = selectionRange + } + } + val startTime = System.currentTimeMillis() + var firstResponseLatency = 0.0 + val messages = mutableListOf() + val intentRecognizer = UserIntentRecognizer() + + val contextExtractor = ActiveFileContextExtractor.create(fqnWebviewAdapter = null, project = project) + val fileContext = contextExtractor.extractContextForTrigger(ExtractionTriggerType.ChatMessage) + + val tabId = UUID.randomUUID().toString() + + val requestData = ChatRequestData( + tabId = tabId, + message = message, + activeFileContext = fileContext, + userIntent = intentRecognizer.getUserIntentFromPromptChatMessage(message, null), + triggerType = TriggerType.Inline, + customization = CodeWhispererModelConfigurator.getInstance().activeCustomization(project), + relevantTextDocuments = emptyList(), + useRelevantDocuments = false + ) + + val sessionInfo = sessionStorage.getSession(tabId, project) + val mutex = Mutex() + val isReferenceAllowed = CodeWhispererSettings.getInstance().isIncludeCodeWithReference() + + var errorMessage = "" + var prevMessage = "" + val chat = sessionInfo.scope.async { + ChatPromptHandler(telemetryHelper).handle( + tabId, + UUID.randomUUID().toString(), + requestData, + sessionInfo, + shouldAddIndexInProgressMessage = false, + isInlineChat = true + ) + .catch { e -> + canPopupAbort.set(true) + undoChanges() + logger.warn { "Error in inline chat request: ${e.message}" } + errorMessage = "Error processing request; please try again" + } + .onEach { event: ChatMessage -> + if (event.message?.isNotEmpty() == true && prevMessage != event.message) { + mutex.withLock { + if (event.codeReference?.isNotEmpty() == true && !isReferenceAllowed) { + canPopupAbort.set(true) + undoChanges() + errorMessage = "Suggestion had code reference; removed per setting." + return@withLock + } + try { + if (selectionRange != null) { + processChatDiff(selectedCode, event, editor, selectionRange!!) + } else { + processNewCode(editor, selectedLineStart, event, prevMessage) + } + } catch (e: Exception) { + canPopupAbort.set(true) + undoChanges() + logger.warn { "error streaming chat message to editor: ${e.stackTraceToString()}" } + errorMessage = "Error processing request; please try again." + } + prevMessage = unescape(event.message) + } + } + if (messages.isEmpty()) { + firstResponseLatency = (System.currentTimeMillis() - startTime).toDouble() + } + messages.add(event) + } + .toList() + } + chat.await() + val finalMessage = messages.lastOrNull { m -> m.messageType == ChatMessageType.AnswerPart } + val lastResponseLatency = (System.currentTimeMillis() - startTime).toDouble() + val requestId = messages.lastOrNull()?.messageId + requestId?.let { + metrics = InlineChatMetrics( + requestId = it, inputLength = message.length, numSelectedLines = selectedCode.split("\n").size, + codeIntent = true, responseStartLatency = firstResponseLatency, responseEndLatency = lastResponseLatency, + programmingLanguage = fileContext.fileContext?.fileLanguage + ) + } + if (finalMessage != null) { + try { + finalComputation(selectedCode, finalMessage.message) + } catch (e: Exception) { + errorMessage = e.message ?: "Error processing request; please try again." + } + } + if (errorMessage.isNotEmpty()) { + canPopupAbort.set(true) + undoChanges() + } + return errorMessage + } + + companion object { + fun getInstance(project: Project) = project.service() + private val logger = getLogger() + } + + override fun dispose() { + currentPopup?.let { Disposer.dispose(it) } + hidePopup() + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatEditorHint.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatEditorHint.kt new file mode 100644 index 00000000000..a8ffc1bb3b6 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatEditorHint.kt @@ -0,0 +1,109 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.inline +import com.intellij.codeInsight.hint.HintManager +import com.intellij.codeInsight.hint.HintManagerImpl +import com.intellij.codeInsight.hint.HintUtil +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.VisualPosition +import com.intellij.openapi.keymap.KeymapUtil +import com.intellij.openapi.util.SystemInfo +import com.intellij.ui.LightweightHint +import com.intellij.ui.SimpleColoredText +import com.intellij.ui.SimpleTextAttributes +import icons.AwsIcons +import software.aws.toolkits.resources.AmazonQBundle.message +import java.awt.BorderLayout +import java.awt.Point +import javax.swing.JPanel + +class InlineChatEditorHint { + private val hint = createHint() + + private fun getHintLocation(editor: Editor): Point { + val range = editor.calculateVisibleRange() + val document = editor.document + val selectionEnd = editor.selectionModel.selectionEnd + val isOneLineSelection = isOneLineSelection(editor) + val isBelow = editor.offsetToXY(selectionEnd) !in editor.scrollingModel.visibleArea + val areEdgesOutsideOfVisibleArea = editor.selectionModel.selectionStart !in range && editor.selectionModel.selectionEnd !in range + val offsetForHint = when { + isOneLineSelection -> selectionEnd + areEdgesOutsideOfVisibleArea -> document.getLineEndOffset(getLineByVisualStart(editor, editor.caretModel.offset, true)) + isBelow -> document.getLineEndOffset(getLineByVisualStart(editor, selectionEnd, true)) + else -> document.getLineEndOffset(getLineByVisualStart(editor, selectionEnd, false)) + } + val visualPosition = editor.offsetToVisualPosition(offsetForHint) + val hintPoint = HintManagerImpl.getHintPosition(hint, editor, visualPosition, HintManager.RIGHT) + hintPoint.translate(0, if (isBelow) editor.lineHeight else 0) + return hintPoint + } + + private fun isOneLineSelection(editor: Editor): Boolean { + val document = editor.document + val selectionModel = editor.selectionModel + val startLine = document.getLineNumber(selectionModel.selectionStart) + val endLine = document.getLineNumber(selectionModel.selectionEnd) + return startLine == endLine + } + + private fun getLineByVisualStart(editor: Editor, offset: Int, skipLineStartOffset: Boolean): Int { + val visualPosition = editor.offsetToVisualPosition(offset) + val skipCurrentLine = skipLineStartOffset && visualPosition.column == 0 + val line = if (skipCurrentLine) maxOf(visualPosition.line - 1, 0) else visualPosition.line + val lineStartPosition = VisualPosition(line, 0) + return editor.visualToLogicalPosition(lineStartPosition).line + } + + private fun createHint(): LightweightHint { + val icon = AwsIcons.Logos.AWS_Q_GREY + + val component = HintUtil.createInformationComponent() + component.isIconOnTheRight = false + component.icon = icon + val coloredText = + SimpleColoredText(message("amazonqInlineChat.hint.edit"), SimpleTextAttributes.REGULAR_ATTRIBUTES) + + coloredText.appendToComponent(component) + val shortcutComponent = HintUtil.createInformationComponent() + val shortCut = KeymapUtil.getShortcutText("aws.toolkit.jetbrains.core.services.cwc.inline.openChat") + if (!SystemInfo.isWindows && shortCut == "⌃I") { + val shortCutIcon = AwsIcons.Resources.InlineChat.AWS_Q_INLINECHAT_SHORTCUT + shortcutComponent.isIconOnTheRight = true + shortcutComponent.icon = shortCutIcon + } else { + val shortcutText = + SimpleColoredText(shortCut, SimpleTextAttributes.REGULAR_ATTRIBUTES) + shortcutText.appendToComponent(shortcutComponent) + } + + val panel = JPanel(BorderLayout()).apply { + add(component, BorderLayout.WEST) + add(shortcutComponent, BorderLayout.EAST) + isOpaque = true + background = component.background + revalidate() + repaint() + } + + return LightweightHint(panel) + } + + fun show(editor: Editor) { + val location = getHintLocation(editor) + HintManagerImpl.getInstanceImpl().showEditorHint( + hint, + editor, + location, + HintManager.HIDE_BY_TEXT_CHANGE or HintManager.HIDE_BY_SCROLLING, + 0, + false, + HintManagerImpl.createHintHint(editor, location, hint, HintManager.RIGHT).setContentActive(false) + ) + } + + fun hide() { + hint.hide() + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatPopupFactory.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatPopupFactory.kt new file mode 100644 index 00000000000..b6f421a90ac --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatPopupFactory.kt @@ -0,0 +1,177 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.inline + +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.VisualPosition +import com.intellij.openapi.ui.popup.IconButton +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.TextRange +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.awt.RelativePoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import software.aws.toolkits.jetbrains.core.coroutines.EDT +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_BUTTON_BORDER +import software.aws.toolkits.resources.AmazonQBundle.message +import java.awt.Point + +class InlineChatPopupFactory( + private val submitHandler: suspend (String, String, Int, Editor) -> String, + private val acceptHandler: () -> Unit, + private val rejectHandler: () -> Unit, + private val cancelHandler: () -> Unit, + private val popupBufferHeight: Int = 150, +) : Disposable { + + private fun getSelectedText(editor: Editor): String = ReadAction.compute { + val selectionStartOffset = editor.selectionModel.selectionStart + val selectionEndOffset = editor.selectionModel.selectionEnd + if (selectionEndOffset > selectionStartOffset) { + val selectionLineStart = editor.document.getLineStartOffset(editor.document.getLineNumber(selectionStartOffset)) + val selectionLineEnd = editor.document.getLineEndOffset(editor.document.getLineNumber(selectionEndOffset)) + editor.document.getText(TextRange(selectionLineStart, selectionLineEnd)) + } else { + "" + } + } + + private fun getSelectionStartLine(editor: Editor): Int = ReadAction.compute { + editor.document.getLineNumber(editor.selectionModel.selectionStart) + } + + fun createPopup(editor: Editor, scope: CoroutineScope): JBPopup { + val popupPanel = InlineChatPopupPanel(this).apply { + border = IdeBorderFactory.createRoundedBorder(10).apply { + setColor(POPUP_BUTTON_BORDER) + } + + val submitListener: () -> Unit = { + val prompt = textField.text + if (prompt.isNotBlank()) { + submitButton.isEnabled = false + cancelButton.isEnabled = false + textField.isEnabled = false + setLabel(message("amazonqInlineChat.popup.generating")) + revalidate() + + scope.launch { + val selectedCode = getSelectedText(editor) + val selectedLineStart = getSelectionStartLine(editor) + var errorMessage = "" + runBlocking { + errorMessage = submitHandler(prompt, selectedCode, selectedLineStart, editor) + } + if (errorMessage.isNotEmpty()) { + withContext(EDT) { + setErrorMessage(errorMessage) + revalidate() + } + } else { + val acceptAction = { + acceptHandler.invoke() + } + val rejectAction = { + rejectHandler.invoke() + } + withContext(EDT) { + addCodeActionsPanel(acceptAction, rejectAction) + revalidate() + } + } + } + } + } + setSubmitClickListener(submitListener) + } + val popup = initPopup(popupPanel) + showPopupInEditor(popup, popupPanel, editor) + + return popup + } + + private fun showPopupInEditor(popup: JBPopup, popupPanel: InlineChatPopupPanel, editor: Editor) { + val selectionEnd = editor.selectionModel.selectionEnd + val selectionStart = editor.selectionModel.selectionStart + val preferredXY = editor.offsetToXY(selectionStart) + val visibleArea = editor.scrollingModel.visibleArea + val isBelow = preferredXY.y - visibleArea.y < popupBufferHeight + val xOffset = getLineByVisualStart(editor, selectionStart) + val preferredX = editor.contentComponent.locationOnScreen.x + xOffset + + if (isBelow) { + val offsetXY = editor.offsetToXY(selectionEnd) + val point = Point(preferredX, offsetXY.y - visibleArea.y + popupBufferHeight) + popup.show(RelativePoint(point)) + } else { + val popupXY = Point(preferredX, preferredXY.y - visibleArea.y - editor.lineHeight) + popup.show(RelativePoint(popupXY)) + } + + popupPanel.textField.requestFocusInWindow() + popupPanel.textField.addActionListener { e -> + val inputText = popupPanel.textField.text.trim() + if (inputText.isNotEmpty()) { + popupPanel.submitButton.doClick() + } + } + } + + private fun getIndentationForLine(editor: Editor, lineNumber: Int): Int { + val document = editor.document + val lineStartOffset = document.getLineStartOffset(lineNumber) + val lineEndOffset = document.getLineEndOffset(lineNumber) + val lineText = document.getText(TextRange(lineStartOffset, lineEndOffset)) + + // Find the index of the first non-whitespace character + val firstNonWhitespace = lineText.indexOfFirst { !it.isWhitespace() } + + return if (firstNonWhitespace == -1) { + 0 + } else { + firstNonWhitespace + 1 + } + } + + private fun getLineByVisualStart(editor: Editor, offset: Int): Int { + val visualPosition = editor.offsetToVisualPosition(offset) + val line = visualPosition.line + val column = getIndentationForLine(editor, line) + val lineStartPosition = VisualPosition(line, column) + return editor.visualToLogicalPosition(lineStartPosition).line + } + + private fun initPopup(panel: InlineChatPopupPanel): JBPopup { + val cancelButton = IconButton(message("amazonqInlineChat.popup.cancel"), AllIcons.Actions.Cancel) + val popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(panel, panel.textField) + .setMovable(true) + .setResizable(true) + .setTitle(message("amazonqInlineChat.popup.title")) + .setCancelButton(cancelButton) + .setCancelCallback { + cancelHandler.invoke() + true + } + .setShowBorder(true) + .setCancelOnWindowDeactivation(false) + .setCancelOnClickOutside(false) + .setCancelOnOtherWindowOpen(false) + .setFocusable(true) + .setRequestFocus(true) + .setLocateWithinScreenBounds(true) + .createPopup() + return popup + } + + override fun dispose() { + cancelHandler.invoke() + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatPopupPanel.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatPopupPanel.kt new file mode 100644 index 00000000000..f909115084f --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/InlineChatPopupPanel.kt @@ -0,0 +1,188 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.inline + +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.IdeActions +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.actionSystem.EditorActionHandler +import com.intellij.openapi.editor.actionSystem.EditorActionManager +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.util.Disposer +import com.intellij.ui.IdeBorderFactory +import icons.AwsIcons +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_BUTTON_BORDER +import software.aws.toolkits.resources.AmazonQBundle.message +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.Font +import javax.swing.BorderFactory +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JTextField +import javax.swing.SwingConstants +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener + +class InlineChatPopupPanel(private val parentDisposable: Disposable) : JPanel() { + private var submitClickListener: (() -> Unit)? = null + private val popupButtonFontSize = 14f + private val popupWidth = 600 + private val popupHeight = 90 + private val popupButtonHeight = 30 + private val popupButtonWidth = 80 + private val popupInputHeight = 40 + private val popupInputWidth = 500 + + val textField = createTextField().apply { + document.addDocumentListener(object : DocumentListener { + fun updateButtonState() { + submitButton.isEnabled = text.isNotEmpty() + } + + override fun insertUpdate(e: DocumentEvent) = updateButtonState() + override fun removeUpdate(e: DocumentEvent) = updateButtonState() + override fun changedUpdate(e: DocumentEvent) = updateButtonState() + }) + } + + val submitButton = createButton(message("amazonqInlineChat.popup.confirm")).apply { + isEnabled = false + } + + val cancelButton = createButton(message("amazonqInlineChat.popup.cancel")).apply { + addActionListener { + if (!Disposer.isDisposed(parentDisposable)) { + Disposer.dispose(parentDisposable) + } + } + } + + private val buttonsPanel = JPanel(BorderLayout()).apply { + border = BorderFactory.createEmptyBorder(5, 5, 5, 5) + add(submitButton, BorderLayout.WEST) + add(cancelButton, BorderLayout.EAST) + } + + private val acceptButton = createButton(message("amazonqInlineChat.popup.accept")) + private val rejectButton = createButton(message("amazonqInlineChat.popup.reject")) + private val textLabel = JLabel(message("amazonqInlineChat.popup.editCode"), AwsIcons.Logos.AWS_Q_GREY, SwingConstants.RIGHT).apply { + font = font.deriveFont(popupButtonFontSize) + } + + private val inputPanel = JPanel(BorderLayout()).apply { + border = BorderFactory.createEmptyBorder(10, 10, 5, 10) + } + + private val logoPanel = JPanel(BorderLayout()).apply { + border = BorderFactory.createEmptyBorder(5, 5, 5, 5) + add(textLabel, BorderLayout.CENTER) + } + + private val bottomPanel = JPanel(BorderLayout()).apply { + add(logoPanel, BorderLayout.WEST) + add(buttonsPanel, BorderLayout.EAST) + } + + private val emptyTextField = createTextField().apply { + isEnabled = false + } + + override fun getPreferredSize(): Dimension = Dimension(popupWidth, popupHeight) + + private fun createTextField(): JTextField = JTextField().apply { + val editorColorsScheme = EditorColorsManager.getInstance().globalScheme + preferredSize = Dimension(popupInputWidth, popupInputHeight) + border = IdeBorderFactory.createRoundedBorder().apply { + setColor(POPUP_BUTTON_BORDER) + } + font = Font(editorColorsScheme.editorFontName, Font.PLAIN, editorColorsScheme.editorFontSize) + } + + private fun createButton(text: String): JButton = JButton(text).apply { + preferredSize = Dimension(popupButtonWidth, popupButtonHeight) + isOpaque = false + isContentAreaFilled = false + isBorderPainted = false + font = font.deriveFont(popupButtonFontSize) + } + + init { + layout = BorderLayout() + inputPanel.add(textField, BorderLayout.CENTER) + add(inputPanel, BorderLayout.CENTER) + add(bottomPanel, BorderLayout.SOUTH) + + submitButton.addActionListener { + submitClickListener?.invoke() + } + } + + fun setSubmitClickListener(listener: () -> Unit) { + submitClickListener = listener + } + + private fun addActionListener(id: String, action: EditorActionHandler) { + val actionManager = EditorActionManager.getInstance() + val originalHandler = actionManager.getActionHandler(id) + + actionManager.setActionHandler(id, action) + val restorer = Disposable { actionManager.setActionHandler(id, originalHandler) } + Disposer.register(parentDisposable, restorer) + } + + private fun getEditorActionHandler(action: () -> Unit): EditorActionHandler = object : EditorActionHandler() { + override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) { + action.invoke() + Disposer.dispose(parentDisposable) + } + } + + fun addCodeActionsPanel(acceptAction: () -> Unit, rejectAction: () -> Unit) { + textLabel.text = message("amazonqInlineChat.popup.editCode") + // this is a workaround somehow the textField will interfere with the enter handler + emptyTextField.text = textField.text + inputPanel.remove(textField) + inputPanel.add(emptyTextField, BorderLayout.CENTER) + + buttonsPanel.remove(submitButton) + buttonsPanel.remove(cancelButton) + buttonsPanel.add(acceptButton, BorderLayout.WEST) + buttonsPanel.add(rejectButton, BorderLayout.EAST) + acceptButton.addActionListener { acceptAction.invoke() } + rejectButton.addActionListener { rejectAction.invoke() } + + val enterHandler = getEditorActionHandler(acceptAction) + addActionListener(IdeActions.ACTION_EDITOR_ENTER, enterHandler) + + val escapeHandler = getEditorActionHandler(rejectAction) + addActionListener(IdeActions.ACTION_EDITOR_ESCAPE, escapeHandler) + revalidate() + } + + fun setErrorMessage(message: String) { + setLabel(message) + inputPanel.remove(emptyTextField) + textField.text = "" + textField.isEnabled = true + inputPanel.add(textField, BorderLayout.CENTER) + + buttonsPanel.remove(acceptButton) + buttonsPanel.remove(rejectButton) + buttonsPanel.add(submitButton, BorderLayout.WEST) + buttonsPanel.add(cancelButton, BorderLayout.EAST) + submitButton.isEnabled = false + cancelButton.isEnabled = true + revalidate() + } + + fun setLabel(text: String) { + textLabel.text = text + textField.isEnabled = false + revalidate() + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/OpenChatInputAction.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/OpenChatInputAction.kt new file mode 100644 index 00000000000..e35d5a1ee67 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/OpenChatInputAction.kt @@ -0,0 +1,18 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.inline + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys + +class OpenChatInputAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val editor = e.getRequiredData(CommonDataKeys.EDITOR) + val project = editor.project ?: return + + val inlineChatController = InlineChatController.getInstance(project) + inlineChatController.initPopup(editor) + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/listeners/InlineChatFileListener.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/listeners/InlineChatFileListener.kt new file mode 100644 index 00000000000..96e2262a0eb --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/listeners/InlineChatFileListener.kt @@ -0,0 +1,57 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.inline.listeners + +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerEvent +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import software.aws.toolkits.jetbrains.services.cwc.inline.InlineChatController + +class InlineChatFileListener(project: Project, private val controller: InlineChatController) : FileEditorManagerListener, Disposable { + private var currentEditor: Editor? = null + private var selectionListener: InlineChatSelectionListener? = null + + init { + val editor = project.let { FileEditorManager.getInstance(it).selectedTextEditor } + if (editor != null) { + setupListenersForEditor(editor) + currentEditor = editor + } + } + + override fun selectionChanged(event: FileEditorManagerEvent) { + val newEditor = (event.newEditor as? TextEditor)?.editor ?: return + if (newEditor != currentEditor) { + currentEditor?.let { removeListenersFromCurrentEditor(it) } + setupListenersForEditor(newEditor) + currentEditor = newEditor + controller.disposePopup(true) + } + } + + private fun setupListenersForEditor(editor: Editor) { + selectionListener = InlineChatSelectionListener().also { listener -> + editor.selectionModel.addSelectionListener(listener) + Disposer.register(this, listener) + } + } + + private fun removeListenersFromCurrentEditor(editor: Editor) { + selectionListener?.let { listener -> + editor.selectionModel.removeSelectionListener(listener) + Disposer.dispose(listener) + } + selectionListener = null + } + + override fun dispose() { + currentEditor?.let { removeListenersFromCurrentEditor(it) } + currentEditor = null + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/listeners/InlineChatSelectionListener.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/listeners/InlineChatSelectionListener.kt new file mode 100644 index 00000000000..106e672486d --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/inline/listeners/InlineChatSelectionListener.kt @@ -0,0 +1,27 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.inline.listeners + +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.event.SelectionEvent +import com.intellij.openapi.editor.event.SelectionListener +import software.aws.toolkits.jetbrains.services.cwc.inline.InlineChatEditorHint + +class InlineChatSelectionListener : SelectionListener, Disposable { + private val inlineChatEditorHint = InlineChatEditorHint() + override fun selectionChanged(e: SelectionEvent) { + val editor = e.editor + val selectionModel = editor.selectionModel + + if (selectionModel.hasSelection()) { + inlineChatEditorHint.show(editor) + } else { + inlineChatEditorHint.hide() + } + } + + override fun dispose() { + inlineChatEditorHint.hide() + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt index 4bfc2466402..a0502367d7c 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt @@ -180,7 +180,7 @@ class TelemetryHelperTest { sessionStorage = mock { on { this.getSession(eq(tabId)) } doReturn ChatSessionInfo(session = mockSession, scope = mock(), history = mutableListOf()) } - sut = TelemetryHelper(appInitContext, sessionStorage) + sut = TelemetryHelper(appInitContext.project, sessionStorage) // set up client mockClientManager.create() diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt index 360d2e26dd2..665895011f1 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt @@ -24,6 +24,7 @@ import software.amazon.awssdk.services.codewhispererruntime.model.CreateUploadUr import software.amazon.awssdk.services.codewhispererruntime.model.Dimension import software.amazon.awssdk.services.codewhispererruntime.model.GenerateCompletionsRequest import software.amazon.awssdk.services.codewhispererruntime.model.GenerateCompletionsResponse +import software.amazon.awssdk.services.codewhispererruntime.model.InlineChatUserDecision import software.amazon.awssdk.services.codewhispererruntime.model.ListAvailableCustomizationsRequest import software.amazon.awssdk.services.codewhispererruntime.model.ListFeatureEvaluationsResponse import software.amazon.awssdk.services.codewhispererruntime.model.SendTelemetryEventResponse @@ -187,6 +188,21 @@ interface CodeWhispererClientAdaptor : Disposable { customization: CodeWhispererCustomization?, ): SendTelemetryEventResponse + fun sendInlineChatTelemetry( + requestId: String, + inputLength: Int?, + numSelectedLines: Int?, + codeIntent: Boolean?, + userDecision: InlineChatUserDecision?, + responseStartLatency: Double?, + responseEndLatency: Double?, + numSuggestionAddChars: Int?, + numSuggestionAddLines: Int?, + numSuggestionDelChars: Int?, + numSuggestionDelLines: Int?, + programmingLanguage: String?, + ): SendTelemetryEventResponse + companion object { fun getInstance(project: Project): CodeWhispererClientAdaptor = project.service() @@ -588,6 +604,41 @@ open class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeW requestBuilder.userContext(codeWhispererUserContext()) } + override fun sendInlineChatTelemetry( + requestId: String, + inputLength: Int?, + numSelectedLines: Int?, + codeIntent: Boolean?, + userDecision: InlineChatUserDecision?, + responseStartLatency: Double?, + responseEndLatency: Double?, + numSuggestionAddChars: Int?, + numSuggestionAddLines: Int?, + numSuggestionDelChars: Int?, + numSuggestionDelLines: Int?, + programmingLanguage: String?, + ): SendTelemetryEventResponse = bearerClient().sendTelemetryEvent { requestBuilder -> + requestBuilder.telemetryEvent { telemetryEventBuilder -> + telemetryEventBuilder.inlineChatEvent { + it.requestId(requestId) + it.inputLength(inputLength) + it.numSelectedLines(numSelectedLines) + it.codeIntent(codeIntent) + it.userDecision(userDecision) + it.responseStartLatency(responseStartLatency) + it.responseEndLatency(responseEndLatency) + it.numSuggestionAddChars(numSuggestionAddChars) + it.numSuggestionAddLines(numSuggestionAddLines) + it.numSuggestionDelChars(numSuggestionDelChars) + it.numSuggestionDelLines(numSuggestionDelLines) + if (programmingLanguage != null) it.programmingLanguage { langBuilder -> langBuilder.languageName(programmingLanguage) } + it.timestamp(Instant.now()) + } + } + requestBuilder.optOutPreference(getTelemetryOptOutPreference()) + requestBuilder.userContext(codeWhispererUserContext()) + } + override fun dispose() { if (this::mySigv4Client.isLazyInitialized) { mySigv4Client.close() diff --git a/plugins/amazonq/shared/jetbrains-community/resources/software/aws/toolkits/resources/AmazonQBundle.properties b/plugins/amazonq/shared/jetbrains-community/resources/software/aws/toolkits/resources/AmazonQBundle.properties index 8355e3c76f6..6e675823089 100644 --- a/plugins/amazonq/shared/jetbrains-community/resources/software/aws/toolkits/resources/AmazonQBundle.properties +++ b/plugins/amazonq/shared/jetbrains-community/resources/software/aws/toolkits/resources/AmazonQBundle.properties @@ -1,3 +1,11 @@ action.q.hello.description=Hello description +amazonqInlineChat.hint.edit = Edit +amazonqInlineChat.popup.accept=Accept \u23CE +amazonqInlineChat.popup.cancel=Cancel \u238B +amazonqInlineChat.popup.confirm=Confirm \u23CE +amazonqInlineChat.popup.editCode = Edit Code +amazonqInlineChat.popup.generating = Generating... +amazonqInlineChat.popup.reject=Reject \u238B +amazonqInlineChat.popup.title=Enter Instructions for Q amazonq.refresh.panel=Refresh Chat Session q.hello=Hello diff --git a/plugins/core/jetbrains-community/resources/icons/logos/Amazon_Q_grey.svg b/plugins/core/jetbrains-community/resources/icons/logos/Amazon_Q_grey.svg new file mode 100644 index 00000000000..f6a94b98b5f --- /dev/null +++ b/plugins/core/jetbrains-community/resources/icons/logos/Amazon_Q_grey.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/core/jetbrains-community/resources/icons/resources/inlinechat/amazonq_inline_chat_shortcut.svg b/plugins/core/jetbrains-community/resources/icons/resources/inlinechat/amazonq_inline_chat_shortcut.svg new file mode 100644 index 00000000000..c2cbaab7289 --- /dev/null +++ b/plugins/core/jetbrains-community/resources/icons/resources/inlinechat/amazonq_inline_chat_shortcut.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/core/jetbrains-community/src/icons/AwsIcons.kt b/plugins/core/jetbrains-community/src/icons/AwsIcons.kt index 4e0c3202e65..9a4703ce28b 100644 --- a/plugins/core/jetbrains-community/src/icons/AwsIcons.kt +++ b/plugins/core/jetbrains-community/src/icons/AwsIcons.kt @@ -32,6 +32,8 @@ object AwsIcons { @JvmField val AWS_Q = load("/icons/logos/AWS_Q.svg") // 13x13 + @JvmField val AWS_Q_GREY = load("/icons/logos/Amazon_Q_grey.svg") // 16x16 + @JvmField val AWS_Q_GRADIENT = load("/icons/logos/Amazon-Q-Icon_Gradient_Large.svg") // 54x54 @JvmField val AWS_Q_GRADIENT_SMALL = load("/icons/logos/Amazon-Q-Icon_Gradient_Medium.svg") // 54x54 @@ -123,6 +125,10 @@ object AwsIcons { @JvmField val SEVERITY_CRITICAL = load("/icons/resources/codewhisperer/severity-critical.svg") } + + object InlineChat { + @JvmField val AWS_Q_INLINECHAT_SHORTCUT = load("/icons/resources/inlinechat/amazonq_inline_chat_shortcut.svg") + } } object Actions { diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties index 6bb9257b5ee..3d8ea0a76d4 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -29,6 +29,7 @@ action.aws.toolkit.jetbrains.core.services.cwc.commands.RefactorCodeAction.descr action.aws.toolkit.jetbrains.core.services.cwc.commands.RefactorCodeAction.text = Refactor Code action.aws.toolkit.jetbrains.core.services.cwc.commands.SendToPromptAction.description = Sends selected code to chat action.aws.toolkit.jetbrains.core.services.cwc.commands.SendToPromptAction.text = Send to Prompt +action.aws.toolkit.jetbrains.core.services.cwc.inline.openChat.text = Inline Chat action.aws.toolkit.open.arn.browser.text=Open ARN in AWS Console action.aws.toolkit.open.telemetry.viewer.text=View AWS Telemetry action.aws.toolkit.s3.open.bucket.viewer.prefixed.text=View Bucket with Prefix... diff --git a/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/service-2.json b/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/service-2.json index 8c0517e875e..029f2d170a1 100644 --- a/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/service-2.json +++ b/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/service-2.json @@ -1099,13 +1099,21 @@ }, "InlineChatEvent": { "type": "structure", + "required": ["requestId", "timestamp"], "members": { + "requestId": { "shape": "UUID" }, + "timestamp":{"shape":"Timestamp"}, "inputLength": { "shape": "PrimitiveInteger" }, "numSelectedLines": { "shape": "PrimitiveInteger" }, + "numSuggestionAddChars": { "shape": "PrimitiveInteger" }, + "numSuggestionAddLines": { "shape": "PrimitiveInteger" }, + "numSuggestionDelChars": { "shape": "PrimitiveInteger" }, + "numSuggestionDelLines": { "shape": "PrimitiveInteger" }, "codeIntent": { "shape": "Boolean" }, "userDecision": { "shape": "InlineChatUserDecision" }, "responseStartLatency": { "shape": "Double" }, - "responseEndLatency": { "shape": "Double" } + "responseEndLatency": { "shape": "Double" }, + "programmingLanguage": { "shape": "ProgrammingLanguage" } } }, "InlineChatUserDecision": { diff --git a/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json b/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json index 863326fcb65..39344997397 100644 --- a/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json +++ b/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json @@ -169,7 +169,8 @@ "type":"string", "enum":[ "MANUAL", - "DIAGNOSTIC" + "DIAGNOSTIC", + "INLINE_CHAT" ] }, "CodeReferenceEvent":{