diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java index 91629b48..4b5064c3 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java @@ -113,7 +113,6 @@ public class ChatToolWindowTabPanel implements Disposable { tagManager, this::handleSubmit, this::handleCancel, - true, true); userInputPanel.requestFocus(); rootPanel = createRootPanel(); @@ -334,6 +333,7 @@ public class ChatToolWindowTabPanel implements Disposable { panel.addContent(new ChatMessageResponseBody( project, false, + false, message.isWebSearchIncluded(), fileContextIncluded || message.getDocumentationDetails() != null, true, @@ -523,7 +523,7 @@ public class ChatToolWindowTabPanel implements Disposable { private ResponseMessagePanel getResponseMessagePanel(Message message) { var response = message.getResponse() == null ? "" : message.getResponse(); var messageResponseBody = - new ChatMessageResponseBody(project, this).withResponse(response); + new ChatMessageResponseBody(project, false, this).withResponse(response); var responseMessagePanel = new ResponseMessagePanel(); responseMessagePanel.addContent(messageResponseBody); diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java index 9bfce520..1ef95f62 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java @@ -84,6 +84,7 @@ public class ChatMessageResponseBody extends JPanel { private final Disposable parentDisposable; private final SseMessageParser streamOutputParser; private final boolean readOnly; + private final boolean compact; private final DefaultListModel webpageListModel = new DefaultListModel<>(); private final WebpageList webpageList = new WebpageList(webpageListModel); private final ResponseBodyProgressPanel progressPanel = new ResponseBodyProgressPanel(); @@ -104,13 +105,14 @@ public class ChatMessageResponseBody extends JPanel { .withBorder(JBUI.Borders.empty(4, 0)); } - public ChatMessageResponseBody(Project project, Disposable parentDisposable) { - this(project, false, false, false, false, parentDisposable); + public ChatMessageResponseBody(Project project, boolean compact, Disposable parentDisposable) { + this(project, false, compact, false, false, false, parentDisposable); } public ChatMessageResponseBody( Project project, boolean readOnly, + boolean compact, boolean webSearchIncluded, boolean withProgress, boolean withLoading, @@ -119,6 +121,7 @@ public class ChatMessageResponseBody extends JPanel { this.parentDisposable = parentDisposable; this.streamOutputParser = new SseMessageParser(); this.readOnly = readOnly; + this.compact = compact; setLayout(new BorderLayout()); setOpaque(false); @@ -380,7 +383,7 @@ public class ChatMessageResponseBody extends JPanel { hideCaret(); currentlyProcessedTextPane = null; currentlyProcessedEditorPanel = - new ResponseEditorPanel(project, item, readOnly, parentDisposable); + new ResponseEditorPanel(project, item, readOnly, compact, parentDisposable); contentPanel.add(currentlyProcessedEditorPanel); contentPanel.revalidate(); contentPanel.repaint(); diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt index 54a4b471..9a795807 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt @@ -3,17 +3,17 @@ package ee.carlrobert.codegpt.completions import com.intellij.openapi.components.service import com.intellij.openapi.vfs.LocalFileSystem import ee.carlrobert.codegpt.EncodingManager -import ee.carlrobert.codegpt.nextedit.NextEditPromptUtil import ee.carlrobert.codegpt.completions.CompletionRequestFactory.Companion.CURSOR_MARKER -import ee.carlrobert.codegpt.completions.CompletionRequestFactory.Companion.DEFAULT_LINES_AFTER -import ee.carlrobert.codegpt.completions.CompletionRequestFactory.Companion.DEFAULT_LINES_BEFORE -import ee.carlrobert.codegpt.completions.CompletionRequestFactory.Companion.MAX_EDITABLE_REGION_LINES import ee.carlrobert.codegpt.completions.CompletionRequestFactory.Companion.MAX_RECENTLY_VIEWED_SNIPPETS import ee.carlrobert.codegpt.completions.CompletionRequestFactory.Companion.RECENTLY_VIEWED_LINES import ee.carlrobert.codegpt.completions.factory.* +import ee.carlrobert.codegpt.conversations.message.Message +import ee.carlrobert.codegpt.nextedit.NextEditPromptUtil import ee.carlrobert.codegpt.psistructure.ClassStructureSerializer +import ee.carlrobert.codegpt.settings.configuration.ChatMode import ee.carlrobert.codegpt.settings.prompts.CoreActionsState import ee.carlrobert.codegpt.settings.prompts.FilteredPromptsService +import ee.carlrobert.codegpt.settings.prompts.PersonaDetails import ee.carlrobert.codegpt.settings.prompts.PromptsSettings import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ModelSelectionService @@ -26,6 +26,7 @@ import ee.carlrobert.llm.completion.CompletionRequest interface CompletionRequestFactory { fun createChatRequest(params: ChatCompletionParameters): CompletionRequest fun createInlineEditRequest(params: InlineEditCompletionParameters): CompletionRequest + fun createInlineEditQuestionRequest(parameters: ChatCompletionParameters): CompletionRequest fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest fun createCommitMessageRequest(params: CommitMessageCompletionParameters): CompletionRequest fun createLookupRequest(params: LookupCompletionParameters): CompletionRequest @@ -71,6 +72,45 @@ abstract class BaseRequestFactory : CompletionRequestFactory { private const val DEFAULT_MAX_TOKENS = 4096 } + override fun createInlineEditQuestionRequest(parameters: ChatCompletionParameters): CompletionRequest { + val systemPrompt = """ + You are an Inline Edit assistant for a single open file. + Respond in two parts: + + 1) Explanation (concise): + - 3–5 short bullets max. + - Summarize what will change and why. + - Reference functions/classes by name. Do not paste full files. + + 2) Update Snippet(s): + - Provide ONLY partial changes as one or more fenced code blocks using triple backticks with the correct language (```python, ```kotlin, etc.). + - Do NOT include any special tags. + - Use minimal necessary context; indicate gaps with language-appropriate comments like "// ... existing code ..." or "# ... existing code ...". + - Include only changed/new lines with at most 1–3 lines of surrounding context when needed. + - Prefer stable anchors (function/class signatures, imports) to locate insertion points. + - Never output entire files or unrelated edits. + """.trimIndent() + + val userPrompt = getPromptWithFilesContext(parameters) + + val newParams = ChatCompletionParameters + .builder(parameters.conversation, Message(userPrompt)) + .sessionId(parameters.sessionId) + .conversationType(parameters.conversationType) + .retry(parameters.retry) + .imageDetails(parameters.imageDetails) + .history(parameters.history) + .referencedFiles(parameters.referencedFiles) + .personaDetails(PersonaDetails(-1L, "Inline Edit Guidance", systemPrompt)) + .psiStructure(parameters.psiStructure) + .project(parameters.project) + .chatMode(ChatMode.ASK) + .featureType(FeatureType.INLINE_EDIT) + .build() + + return createChatRequest(newParams) + } + data class InlineEditPrompts(val systemPrompt: String, val userPrompt: String) protected fun prepareInlineEditPrompts(params: InlineEditCompletionParameters): InlineEditPrompts { @@ -259,8 +299,13 @@ abstract class BaseRequestFactory : CompletionRequestFactory { val (project, fileName, filePath, fileContent, caretOffset, gitDiff, _) = params val encodingManager = EncodingManager.getInstance() - val prefixContent = encodingManager.truncateText(fileContent.substring(0, caretOffset), 4096, true) - val suffixContent = encodingManager.truncateText(fileContent.substring(caretOffset, fileContent.length), 4096, false) + val prefixContent = + encodingManager.truncateText(fileContent.substring(0, caretOffset), 4096, true) + val suffixContent = encodingManager.truncateText( + fileContent.substring(caretOffset, fileContent.length), + 4096, + false + ) val truncatedContent = prefixContent + suffixContent val adjustedCaretOffset = prefixContent.length @@ -324,4 +369,4 @@ abstract class BaseRequestFactory : CompletionRequestFactory { "" } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt index 5d07362a..3f7425eb 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt @@ -28,7 +28,7 @@ import java.nio.file.Path class OpenAIRequestFactory : BaseRequestFactory() { override fun createChatRequest(params: ChatCompletionParameters): OpenAIChatCompletionRequest { - val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.CHAT) + val model = ModelSelectionService.getInstance().getModelForFeature(params.featureType) val configuration = service().state val requestBuilder: OpenAIChatCompletionRequest.Builder = OpenAIChatCompletionRequest.Builder(buildOpenAIMessages(model, params)) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineAskResponseListener.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineAskResponseListener.kt new file mode 100644 index 00000000..3a51a5ff --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineAskResponseListener.kt @@ -0,0 +1,52 @@ +package ee.carlrobert.codegpt.inlineedit + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier +import ee.carlrobert.codegpt.completions.ChatCompletionParameters +import ee.carlrobert.codegpt.completions.CompletionResponseEventListener +import ee.carlrobert.codegpt.ui.OverlayUtil +import ee.carlrobert.llm.client.openai.completion.ErrorDetails + +class InlineAskResponseListener( + private val project: Project, + private val inlay: InlineEditInlay, +) : CompletionResponseEventListener { + + private val logger = Logger.getInstance(InlineAskResponseListener::class.java) + private val builder = StringBuilder() + + override fun handleRequestOpen() { + runInEdt { inlay.setThinkingVisible(true) } + CompletionProgressNotifier.update(project, true) + } + + override fun handleMessage(message: String) { + builder.append(message) + inlay.updateAskResponseStream(message) + inlay.setAskLastAssistantResponse(builder.toString()) + } + + override fun handleCompleted(fullMessage: String, callParameters: ChatCompletionParameters) { + try { + runInEdt { + inlay.onCompletionFinished() + inlay.setAskLastAssistantResponse(fullMessage) + inlay.updateApplyVisibilityAfterComplete(fullMessage) + CompletionProgressNotifier.update(project, false) + } + } catch (e: Exception) { + logger.warn("Inline Ask completion finalize failed", e) + } + } + + override fun handleError(error: ErrorDetails?, ex: Throwable?) { + runInEdt { + inlay.onCompletionFinished() + CompletionProgressNotifier.update(project, false) + val message = error?.message ?: ex?.message ?: "Something went wrong" + OverlayUtil.showNotification(message) + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlay.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlay.kt index 4fc34b80..ce21e787 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlay.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlay.kt @@ -1,10 +1,13 @@ package ee.carlrobert.codegpt.inlineedit import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.invokeLater import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.fileEditor.FileDocumentManager @@ -13,40 +16,49 @@ import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.observable.properties.AtomicBooleanProperty import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.util.Key import com.intellij.ui.JBColor +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import ee.carlrobert.codegpt.CodeGPTKeys +import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.ReferencedFile import ee.carlrobert.codegpt.actions.editor.EditorComponentInlaysManager import ee.carlrobert.codegpt.conversations.Conversation import ee.carlrobert.codegpt.conversations.message.Message +import ee.carlrobert.codegpt.inlineedit.engine.ApplyContext +import ee.carlrobert.codegpt.inlineedit.engine.InlineEditEngineImpl import ee.carlrobert.codegpt.psistructure.PsiStructureProvider import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository +import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel +import ee.carlrobert.codegpt.ui.IconActionButton +import ee.carlrobert.codegpt.ui.components.BadgeChip +import ee.carlrobert.codegpt.ui.components.InlineEditChips import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory import ee.carlrobert.codegpt.ui.textarea.UserInputPanel import ee.carlrobert.codegpt.ui.textarea.header.tag.* import ee.carlrobert.codegpt.util.GitUtil +import ee.carlrobert.codegpt.util.MarkdownUtil import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers import kotlinx.coroutines.* import java.awt.* +import java.awt.datatransfer.StringSelection import java.awt.event.ActionEvent import java.awt.event.KeyEvent -import javax.swing.AbstractAction -import javax.swing.JComponent -import javax.swing.KeyStroke -import javax.swing.Timer +import javax.swing.* data class ObservableProperties( val submitted: AtomicBooleanProperty = AtomicBooleanProperty(false), val accepted: AtomicBooleanProperty = AtomicBooleanProperty(false), val loading: AtomicBooleanProperty = AtomicBooleanProperty(false), - val hasPendingChanges: AtomicBooleanProperty = AtomicBooleanProperty(false), ) class InlineEditInlay(private var editor: Editor) : Disposable { @@ -111,7 +123,6 @@ class InlineEditInlay(private var editor: Editor) : Disposable { submissionHandler.restorePreviousPrompt() } }, - showModeSelector = false, withRemovableSelectedEditorTag = false ).apply { isOpaque = true @@ -125,11 +136,55 @@ class InlineEditInlay(private var editor: Editor) : Disposable { } } + private var askResponseBody: ChatMessageResponseBody? = null + private var askContainer: BorderLayoutPanel? = null + private var askPopup: JBPopup? = null + private var askApplyChip: BadgeChip? = null + private var lastAssistantResponse: String = "" + private val askMinHeight: Int = JBUI.scale(200) + private val askMaxHeight: Int = JBUI.scale(520) + + private data class AskPanelContext( + val editorEx: EditorEx, + val editorComp: JComponent, + val panelWidth: Int, + val panelLeft: Int, + val panelTop: Int, + val panelBottom: Int, + val spaceAbove: Int, + val spaceBelow: Int, + val placeAbove: Boolean, + ) + + private fun getAskPanelContext(): AskPanelContext? { + val editorEx = editor as? EditorEx ?: return null + val editorComp = editorEx.contentComponent + val panelSize = if (mainContainer.width > 0) mainContainer.size else mainContainer.preferredSize + val panelPoint = SwingUtilities.convertPoint(mainContainer, 0, 0, editorComp) + val panelTop = panelPoint.y + val panelBottom = panelTop + panelSize.height + val visible = editorEx.scrollingModel.visibleArea + val spaceAbove = panelTop - visible.y - JBUI.scale(6) + + return AskPanelContext( + editorEx = editorEx, + editorComp = editorComp, + panelWidth = panelSize.width, + panelLeft = panelPoint.x, + panelTop = panelTop, + panelBottom = panelBottom, + spaceAbove = spaceAbove, + spaceBelow = (visible.y + visible.height) - panelBottom - JBUI.scale(6), + placeAbove = spaceAbove >= askMinHeight, + ) + } + private val mainContainer = BorderLayoutPanel().apply { isOpaque = true - add(userInputPanel, BorderLayout.CENTER) + // Place input at SOUTH so it keeps its preferred height and doesn't stretch + add(userInputPanel, BorderLayout.SOUTH) - border = JBUI.Borders.empty(8, 12, 8, 12) + border = JBUI.Borders.empty(4, 8, 2, 8) background = userInputPanel.background ?: JBColor.background() isFocusable = true @@ -190,9 +245,201 @@ class InlineEditInlay(private var editor: Editor) : Disposable { val editorEx = editor as EditorEx val sessionConversation = Conversation().apply { projectPath = editorEx.project?.basePath - title = "Inline Edit (${editorEx.virtualFile?.name ?: "untitled"})" + val fileName = editorEx.virtualFile?.name ?: CodeGPTBundle.get("inlineEdit.conversation.untitled") + title = CodeGPTBundle.get("inlineEdit.conversation.title", fileName) } - submissionHandler = InlineEditSubmissionHandler(editor, observableProperties, sessionConversation) + submissionHandler = + InlineEditSubmissionHandler(editor, observableProperties, sessionConversation) + } + + fun isQuickQuestionEnabled() = userInputPanel.isQuickQuestionEnabled() + + private fun buildAskContainer(): JComponent { + val container = BorderLayoutPanel().apply { + isOpaque = true + background = userInputPanel.background ?: JBColor.background() + border = JBUI.Borders.empty(6, 8) + minimumSize = Dimension(JBUI.scale(600), JBUI.scale(400)) + } + + val responseBody = ChatMessageResponseBody( + project, + true, + this + ).apply { + isOpaque = false + } + askResponseBody = responseBody + + val scrollPane = JBScrollPane(responseBody).apply { + border = JBUI.Borders.empty() + horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED + isOpaque = false + viewport.isOpaque = false + } + + val copyButton = IconActionButton( + object : + AnAction( + CodeGPTBundle.get("shared.copy"), + CodeGPTBundle.get("shared.copyToClipboard"), + com.intellij.icons.AllIcons.Actions.Copy + ) { + override fun actionPerformed(e: AnActionEvent) { + val text = lastAssistantResponse + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(text), null) + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = lastAssistantResponse.isNotBlank() + } + }, + "COPY_MD" + ) + + val applyChip = BadgeChip(CodeGPTBundle.get("shared.apply"), InlineEditChips.GREEN, { handleApply() }).apply { + isVisible = false + isEnabled = false + } + askApplyChip = applyChip + + val header = JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(4), JBUI.scale(4))).apply { + isOpaque = false + add(copyButton) + add(applyChip) + } + + container.add(header, BorderLayout.NORTH) + container.add(scrollPane, BorderLayout.CENTER) + + askContainer = container + return container + } + + private fun showAskPopup() { + runInEdt { + if (askPopup?.isVisible == true) return@runInEdt + val content = askContainer ?: buildAskContainer() + val ctx = getAskPanelContext() ?: return@runInEdt + val availableHeight = if (ctx.placeAbove) ctx.spaceAbove else ctx.spaceBelow + val pref = content.preferredSize + val targetH = + pref.height.coerceIn(askMinHeight, askMaxHeight).coerceAtMost(availableHeight) + .coerceAtLeast(askMinHeight) + + content.preferredSize = Dimension(ctx.panelWidth, targetH) + + val builder = JBPopupFactory.getInstance() + .createComponentPopupBuilder(content, null) + .setRequestFocus(false) + .setFocusable(false) + .setResizable(true) + .setMovable(true) + .setCancelOnClickOutside(false) + .setCancelOnOtherWindowOpen(false) + .setCancelOnWindowDeactivation(false) + .setMinSize(Dimension(ctx.panelWidth, askMinHeight)) + + val popup = builder.createPopup() + askPopup = popup + + popup.size = Dimension(ctx.panelWidth, targetH) + + val margin = JBUI.scale(6) + val anchorPoint = if (ctx.placeAbove) { + Point(ctx.panelLeft, ctx.panelTop + targetH - margin) + } else { + Point(ctx.panelLeft, ctx.panelBottom + margin) + } + popup.show(RelativePoint(ctx.editorComp, anchorPoint)) + adjustAskPopupSize() + } + } + + fun hideAskPopup() { + runInEdt { + askPopup?.cancel() + askPopup = null + resetApplyChip() + askResponseBody?.clear() + } + } + + fun resetAskContainer() { + runInEdt { + showAskPopup() + askResponseBody?.clear() + resetApplyChip() + } + } + + fun updateAskResponseStream(partial: String) { + runInEdt { + showAskPopup() + askResponseBody?.updateMessage(partial) + adjustAskPopupSize() + } + } + + fun setAskLastAssistantResponse(content: String) { + lastAssistantResponse = content + val hasBlocks = MarkdownUtil.extractCodeBlocks(content).isNotEmpty() + setApplyChip(enabled = hasBlocks) + } + + fun updateApplyVisibilityAfterComplete(fullMessage: String) { + val hasBlocks = MarkdownUtil.extractCodeBlocks(fullMessage).isNotEmpty() + setApplyChip(enabled = hasBlocks, visible = hasBlocks) + adjustAskPopupSize() + } + + private fun adjustAskPopupSize() { + val popup = askPopup ?: return + val container = askContainer ?: return + val ctx = getAskPanelContext() ?: return + + container.revalidate() + val pref = container.preferredSize + val targetH = pref.height.coerceIn(askMinHeight, askMaxHeight) + + if (popup.size.width != ctx.panelWidth || popup.size.height != targetH) { + popup.size = Dimension(ctx.panelWidth, targetH) + } + + val margin = JBUI.scale(6) + val anchorPoint = if (ctx.placeAbove) { + Point(ctx.panelLeft, ctx.panelTop - targetH - margin) + } else { + Point(ctx.panelLeft, ctx.panelBottom + margin) + } + popup.setLocation(RelativePoint(ctx.editorComp, anchorPoint).screenPoint) + } + + private fun resetApplyChip() { + askApplyChip?.isVisible = false + askApplyChip?.isEnabled = false + } + + private fun setApplyChip(enabled: Boolean, visible: Boolean? = null) { + askApplyChip?.isEnabled = enabled + if (visible != null) askApplyChip?.isVisible = visible + } + + private fun handleApply() { + hideAskPopup() + + val editorEx = editor as? EditorEx ?: return + val ctx = ApplyContext( + editorEx, + this, + submissionHandler, + userInputPanel.text, + lastAssistantResponse + ) + InlineEditEngineImpl().apply(ctx) } @RequiresEdt @@ -241,24 +488,25 @@ class InlineEditInlay(private var editor: Editor) : Disposable { runInEdt { try { val session = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) - val hasPending = (session?.hasPendingHunks() == true) || observableProperties.hasPendingChanges.get() + val accepted = observableProperties.accepted.get() + val renderer = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) + val hasPending = (session?.hasPendingHunks() == true) || (renderer?.hasPendingChanges() == true) + val shouldPrompt = !accepted && hasPending - if (hasPending) { + if (shouldPrompt) { val result = Messages.showYesNoDialog( project, - "You have pending changes that will be lost. Do you want to close anyway?", - "Pending Changes", - "Close Anyway", - "Cancel", + CodeGPTBundle.get("inlineEdit.closeWarning.message"), + CodeGPTBundle.get("inlineEdit.closeWarning.title"), + CodeGPTBundle.get("inlineEdit.closeWarning.closeAnyway"), + CodeGPTBundle.get("shared.cancel"), Messages.getWarningIcon() ) if (result != Messages.YES) { return@runInEdt } - invokeLater { - submissionHandler.handleReject() - } + invokeLater { submissionHandler.handleReject() } } logger.debug("Closing inline edit inlay") @@ -274,6 +522,10 @@ class InlineEditInlay(private var editor: Editor) : Disposable { serviceScope.cancel() inlayDisposable?.dispose() editor.putUserData(INLAY_KEY, null) + askResponseBody = null + askContainer = null + askPopup?.cancel() + askPopup = null } fun openOrCreateChatFromSession(sessionConversation: Conversation) { @@ -288,10 +540,12 @@ class InlineEditInlay(private var editor: Editor) : Disposable { val newConversation = Conversation().apply { title = sessionConversation.title projectPath = sessionConversation.projectPath - setMessages(sessionConversation.messages) + messages = sessionConversation.messages } - ee.carlrobert.codegpt.conversations.ConversationService.getInstance().addConversation(newConversation) - ee.carlrobert.codegpt.conversations.ConversationService.getInstance().saveConversation(newConversation) + ee.carlrobert.codegpt.conversations.ConversationService.getInstance() + .addConversation(newConversation) + ee.carlrobert.codegpt.conversations.ConversationService.getInstance() + .saveConversation(newConversation) openedChatConversation = newConversation project.service() .displayConversation(newConversation) @@ -315,7 +569,7 @@ class InlineEditInlay(private var editor: Editor) : Disposable { userInputPanel.setInlineEditControlsVisible(visible) } - fun setThinkingVisible(visible: Boolean, text: String = "Thinking…") { + fun setThinkingVisible(visible: Boolean, text: String = CodeGPTBundle.get("shared.thinking")) { userInputPanel.setThinkingVisible(visible, text) } @@ -460,7 +714,7 @@ class InlineEditInlay(private var editor: Editor) : Disposable { try { val currentHeight = userInputPanel.height val preferredHeight = userInputPanel.preferredSize.height - val minHeight = 80 + val minHeight = 60 logger.debug("Current sizing - Height: $currentHeight, Preferred: $preferredHeight") @@ -539,6 +793,7 @@ class InlineEditInlay(private var editor: Editor) : Disposable { when (it) { is FileTagDetails -> it.virtualFile is EditorTagDetails -> it.virtualFile + is FolderTagDetails -> it.folder // TODO else -> null } } @@ -565,6 +820,4 @@ class InlineEditInlay(private var editor: Editor) : Disposable { processor.process(Message("", ""), stringBuilder) return stringBuilder.toString().takeIf { it.isNotBlank() } } - - } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlayRenderer.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlayRenderer.kt index 6f0fbcd3..a5e233d8 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlayRenderer.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlayRenderer.kt @@ -75,6 +75,14 @@ class InlineEditInlayRenderer( private val hunkUIs = mutableListOf() + fun hasPendingChanges(): Boolean { + val session = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + if (session != null) return session.hasPendingHunks() + val anyPendingChanges = changes.any { !it.isAccepted && !it.isRejected } + val anyPendingHunks = hunkUIs.any { !it.hunk.accepted && !it.hunk.rejected } + return anyPendingChanges || anyPendingHunks + } + fun renderHunks(hunks: List) { runInEdt { hunks.forEach { renderHunk(it) } @@ -481,9 +489,40 @@ class InlineEditInlayRenderer( session.acceptAll() return } - val changesToAccept = changes.filter { !it.isAccepted && !it.isRejected } + val changesToAccept = changes + .filter { !it.isAccepted && !it.isRejected } .sortedByDescending { it.startOffset } - changesToAccept.forEach { acceptChange(it) } + if (changesToAccept.isEmpty()) return + + // Apply all pending changes in a single write command to avoid + // interleaved recomputation and multiple command entries. + WriteCommandAction.runWriteCommandAction( + project, + ee.carlrobert.codegpt.CodeGPTBundle.get("inlineEdit.undo.acceptAll.commandTitle"), + ee.carlrobert.codegpt.CodeGPTBundle.get("inlineEdit.undo.commandGroup"), + { + changesToAccept.forEach { change -> + try { + editor.getUserData(InlineEditInlay.INLAY_KEY) + ?.markChangesAsAccepted() + editor.document.replaceString( + change.startOffset, + change.endOffset, + change.newText + ) + change.isAccepted = true + removeChangeVisuals(change) + } catch (e: Exception) { + logger.debug("Error accepting change during bulk apply", e) + } + } + } + ) + + if (changes.isEmpty()) { + editor.getUserData(InlineEditInlay.INLAY_KEY) + ?.setInlineEditControlsVisible(false) + } } fun rejectAll() { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSearchReplaceListener.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSearchReplaceListener.kt index 3bb9b480..d4bb3e10 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSearchReplaceListener.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSearchReplaceListener.kt @@ -338,9 +338,9 @@ class InlineEditSearchReplaceListener( val hadChanges = showFinalDiff() val inlay = editor.getUserData(InlineEditInlay.INLAY_KEY) - inlay?.observableProperties?.hasPendingChanges?.set(hadChanges) inlay?.setThinkingVisible(false) inlay?.setInlineEditControlsVisible(hadChanges) + inlay?.hideAskPopup() inlay?.onCompletionFinished() val statusComponent = (editor.scrollPane as JBScrollPane).statusComponent @@ -1015,10 +1015,6 @@ class InlineEditSearchReplaceListener( } } - fun showHint(message: String) { - showInlineHint(message) - } - companion object { val LISTENER_KEY = Key.create("InlineEditSearchReplaceListener") diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSession.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSession.kt index c3f036bb..360622a6 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSession.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSession.kt @@ -11,10 +11,8 @@ import com.intellij.openapi.application.runReadAction import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.editor.RangeMarker import com.intellij.openapi.editor.ex.EditorEx -import com.intellij.openapi.keymap.KeymapManager import com.intellij.openapi.progress.EmptyProgressIndicator import com.intellij.openapi.project.Project -import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.TextRange import com.intellij.ui.components.JBScrollPane import ee.carlrobert.codegpt.CodeGPTKeys @@ -70,7 +68,6 @@ class InlineEditSession( hunks.clear() hunks.addAll(newHunks) renderer.renderHunks(hunks) - updateHasPendingChangesFlag() } private fun computeHunks(): List { @@ -145,7 +142,6 @@ class InlineEditSession( hunks.addAll(newHunks) renderer.setInteractive(interactive) renderer.replaceHunks(hunks) - updateHasPendingChangesFlag() } fun acceptNearestToCaret() { @@ -166,16 +162,20 @@ class InlineEditSession( fun acceptAll() { editor.getUserData(InlineEditInlay.INLAY_KEY)?.markChangesAsAccepted() - hunks - .filter { !it.accepted && !it.rejected } - .sortedByDescending { it.baseMarker.startOffset } - .forEach { acceptHunk(it) } - removeCompareLinkIfAny() - clearAcceptedHunks() - } - private fun clearAcceptedHunks() { - hunks.removeIf { it.accepted } + WriteCommandAction.runWriteCommandAction(project) { + val start = rootMarker.startOffset.coerceAtLeast(0) + val end = rootMarker.endOffset.coerceAtLeast(start).coerceAtMost(editor.document.textLength) + editor.document.replaceString(start, end, proposedText) + } + + hunks.clear() + lockedRanges.clear() + rejectedRanges.clear() + renderer.replaceHunks(emptyList()) + + editor.getUserData(InlineEditInlay.INLAY_KEY)?.setInlineEditControlsVisible(false) + removeCompareLinkIfAny() } fun rejectAll() { @@ -186,7 +186,10 @@ class InlineEditSession( editor.getUserData(InlineEditInlay.INLAY_KEY) ?.restorePreviousPrompt() editor.getUserData(InlineEditInlay.INLAY_KEY)?.let { - try { it.dispose() } catch (_: Exception) {} + try { + it.dispose() + } catch (_: Exception) { + } editor.putUserData(InlineEditInlay.INLAY_KEY, null) } dispose() @@ -216,7 +219,6 @@ class InlineEditSession( removeCompareLinkIfAny() } } - updateHasPendingChangesFlag() } private fun rejectHunk(hunk: Hunk) { @@ -244,7 +246,6 @@ class InlineEditSession( ?.setInlineEditControlsVisible(false) removeCompareLinkIfAny() } - updateHasPendingChangesFlag() } fun accept(hunk: Hunk) = acceptHunk(hunk) @@ -255,14 +256,8 @@ class InlineEditSession( return hunks.any { !it.accepted && !it.rejected } } - private fun updateHasPendingChangesFlag() { - editor.getUserData(InlineEditInlay.INLAY_KEY) - ?.observableProperties - ?.hasPendingChanges - ?.set(hasPendingHunks()) - } - private fun rangesOverlap(aStart: Int, aEnd: Int, bStart: Int, bEnd: Int): Boolean { + if (aStart == aEnd && bStart == bEnd) return aStart == bStart val start = maxOf(aStart, bStart) val end = minOf(aEnd, bEnd) return start < end diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSubmissionHandler.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSubmissionHandler.kt index ead9421d..e82c72be 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSubmissionHandler.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSubmissionHandler.kt @@ -12,12 +12,16 @@ import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.text.StringUtil import ee.carlrobert.codegpt.CodeGPTKeys +import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.ReferencedFile import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier -import ee.carlrobert.codegpt.completions.CompletionRequestService -import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters +import ee.carlrobert.codegpt.completions.* import ee.carlrobert.codegpt.conversations.Conversation import ee.carlrobert.codegpt.conversations.message.Message +import ee.carlrobert.codegpt.settings.configuration.ChatMode +import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.codegpt.settings.service.ModelSelectionService import ee.carlrobert.codegpt.ui.OverlayUtil import okhttp3.sse.EventSource import java.util.concurrent.atomic.AtomicReference @@ -33,28 +37,149 @@ class InlineEditSubmissionHandler( private val currentEventSourceRef = AtomicReference(null) private val logger = Logger.getInstance(InlineEditSubmissionHandler::class.java) + fun getSessionConversation(): Conversation = sessionConversation + fun handleSubmit( userPrompt: String, referencedFiles: List? = null, gitDiff: String? = null, conversationHistory: List? = null, - diagnosticsInfo: String? = null + diagnosticsInfo: String? = null, + forceApply: Boolean = false ) { - editor.project?.let { - CompletionProgressNotifier.Companion.update(it, true) - } - - observableProperties.loading.set(true) - observableProperties.submitted.set(true) - - previousPromptRef.getAndSet(userPrompt) - previousSourceRef.getAndSet(editor.document.text) - - runInEdt { editor.selectionModel.removeSelection() } + prepareSubmission(userPrompt) val file = FileDocumentManager.getInstance().getFile(editor.document) val editorEx = editor as? EditorEx ?: return + val inlay = editorEx.getUserData(InlineEditInlay.INLAY_KEY) ?: return + val selectedService = + ModelSelectionService.getInstance().getServiceForFeature(FeatureType.INLINE_EDIT) + + if (!forceApply && inlay.isQuickQuestionEnabled()) { + handleAskSubmission( + userPrompt, + referencedFiles, + conversationHistory, + editorEx, + inlay, + file, + selectedService + ) + return + } + + handleInlineEditSubmission( + userPrompt, + referencedFiles, + gitDiff, + conversationHistory, + diagnosticsInfo, + editorEx, + inlay, + file + ) + } + + private fun prepareSubmission(userPrompt: String) { + editor.project?.let { CompletionProgressNotifier.update(it, true) } + observableProperties.loading.set(true) + observableProperties.submitted.set(true) + previousPromptRef.getAndSet(userPrompt) + previousSourceRef.getAndSet(editor.document.text) + runInEdt { editor.selectionModel.removeSelection() } + } + + private fun handleAskSubmission( + userPrompt: String, + referencedFiles: List?, + conversationHistory: List?, + editorEx: EditorEx, + inlay: InlineEditInlay, + file: com.intellij.openapi.vfs.VirtualFile?, + selectedService: ServiceType + ) { + val message = Message(userPrompt) + sessionConversation.addMessage(message) + + inlay.resetAskContainer() + + val withCurrentFile = buildReferencedFilesWithCurrent(file, referencedFiles) + + val params = ChatCompletionParameters + .builder(sessionConversation, message) + .project(editor.project) + .referencedFiles(withCurrentFile) + .history(conversationHistory) + .chatMode(ChatMode.ASK) + .featureType(FeatureType.CHAT) + .build() + + sendAskRequest(editorEx, inlay, selectedService, params) + } + + private fun buildReferencedFilesWithCurrent( + file: com.intellij.openapi.vfs.VirtualFile?, + referencedFiles: List? + ): List { + val list = referencedFiles?.toMutableList() ?: mutableListOf() + file?.let { vf -> + val currentRef = ReferencedFile.from(vf) + if (list.none { it.filePath == currentRef.filePath }) { + list.add(currentRef) + } + } + return list.toList() + } + + private fun sendAskRequest( + editorEx: EditorEx, + inlay: InlineEditInlay, + selectedService: ServiceType, + params: ChatCompletionParameters + ) { + try { + currentEventSourceRef.getAndSet(null)?.cancel() + + val project = editor.project ?: return + val request = CompletionRequestFactory + .getFactory(selectedService) + .createInlineEditQuestionRequest(params) + val listener = ChatCompletionEventListener( + project, + params, + InlineAskResponseListener(project, inlay) + ) + + val eventSource = service() + .getChatCompletionAsync(request, listener, selectedService) + currentEventSourceRef.set(eventSource) + } catch (ex: Exception) { + logger.warn("InlineAsk: request dispatch failed", ex) + runInEdt { + OverlayUtil.showNotification( + ex.message ?: CodeGPTBundle.get("error.askRequestFailed"), + NotificationType.ERROR + ) + observableProperties.loading.set(false) + observableProperties.submitted.set(false) + editorEx.getUserData(InlineEditInlay.INLAY_KEY) + ?.setThinkingVisible(false) + } + } + } + + private fun handleInlineEditSubmission( + userPrompt: String, + referencedFiles: List?, + gitDiff: String?, + conversationHistory: List?, + diagnosticsInfo: String?, + editorEx: EditorEx, + inlay: InlineEditInlay, + file: com.intellij.openapi.vfs.VirtualFile? + ) { sessionConversation.addMessage(Message(userPrompt)) + val parameters = InlineEditCompletionParameters( userPrompt, runReadAction { editor.selectionModel.selectedText }, @@ -85,13 +210,19 @@ class InlineEditSubmissionHandler( editorEx.putUserData(InlineEditSearchReplaceListener.LISTENER_KEY, listener) - listener.showHint("Submitting inline edit…") - - editorEx.getUserData(InlineEditInlay.INLAY_KEY)?.apply { + inlay.apply { setInlineEditControlsVisible(false) setThinkingVisible(true) } + sendInlineEditRequest(editorEx, parameters, listener) + } + + private fun sendInlineEditRequest( + editorEx: EditorEx, + parameters: InlineEditCompletionParameters, + listener: InlineEditSearchReplaceListener + ) { try { currentEventSourceRef.getAndSet(null)?.cancel() @@ -100,12 +231,11 @@ class InlineEditSubmissionHandler( listener ) currentEventSourceRef.set(eventSource) - } catch (ex: Exception) { logger.warn("InlineEdit: request dispatch failed", ex) runInEdt { OverlayUtil.showNotification( - ex.message ?: "Inline Edit request failed", + ex.message ?: CodeGPTBundle.get("error.inlineEditRequestFailed"), NotificationType.ERROR ) observableProperties.loading.set(false) @@ -123,16 +253,15 @@ class InlineEditSubmissionHandler( runInEdt { editorEx.getUserData(InlineEditInlay.INLAY_KEY)?.setThinkingVisible(false) - val existingListener = editorEx.getUserData(InlineEditSearchReplaceListener.LISTENER_KEY) + val existingListener = + editorEx.getUserData(InlineEditSearchReplaceListener.LISTENER_KEY) existingListener?.stopGenerating() val session = editorEx.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) if (session != null && session.hasPendingHunks()) { editorEx.getUserData(InlineEditInlay.INLAY_KEY)?.setInlineEditControlsVisible(true) - observableProperties.hasPendingChanges.set(true) } else { editorEx.getUserData(InlineEditInlay.INLAY_KEY)?.setInlineEditControlsVisible(false) - observableProperties.hasPendingChanges.set(false) } observableProperties.loading.set(false) @@ -155,7 +284,8 @@ class InlineEditSubmissionHandler( restorePreviousPrompt() runInEdt { val editorEx = editor as? EditorEx - val existingListener = editorEx?.getUserData(InlineEditSearchReplaceListener.LISTENER_KEY) + val existingListener = + editorEx?.getUserData(InlineEditSearchReplaceListener.LISTENER_KEY) existingListener?.dispose() editorEx?.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.dispose() editorEx?.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION, null) @@ -165,7 +295,6 @@ class InlineEditSubmissionHandler( observableProperties.loading.set(false) observableProperties.submitted.set(false) - observableProperties.hasPendingChanges.set(false) editor.project?.let { project -> CompletionProgressNotifier.Companion.update(project, false) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/ApplyContext.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/ApplyContext.kt new file mode 100644 index 00000000..aeabb19f --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/ApplyContext.kt @@ -0,0 +1,14 @@ +package ee.carlrobert.codegpt.inlineedit.engine + +import com.intellij.openapi.editor.ex.EditorEx +import ee.carlrobert.codegpt.inlineedit.InlineEditInlay +import ee.carlrobert.codegpt.inlineedit.InlineEditSubmissionHandler + +data class ApplyContext( + val editor: EditorEx, + val inlay: InlineEditInlay, + val submissionHandler: InlineEditSubmissionHandler, + val promptText: String, + val lastAssistantResponse: String +) + diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/ApplyStrategy.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/ApplyStrategy.kt new file mode 100644 index 00000000..617d711f --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/ApplyStrategy.kt @@ -0,0 +1,6 @@ +package ee.carlrobert.codegpt.inlineedit.engine + +interface ApplyStrategy { + fun apply(ctx: ApplyContext) +} + diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/InlineEditApplyStrategyFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/InlineEditApplyStrategyFactory.kt new file mode 100644 index 00000000..4e712d65 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/InlineEditApplyStrategyFactory.kt @@ -0,0 +1,13 @@ +package ee.carlrobert.codegpt.inlineedit.engine + +import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.settings.service.ModelSelectionService +import ee.carlrobert.codegpt.settings.service.ServiceType + +object InlineEditApplyStrategyFactory { + fun get(): ApplyStrategy { + val serviceType = ModelSelectionService.getInstance().getServiceForFeature(FeatureType.INLINE_EDIT) + return if (serviceType == ServiceType.PROXYAI) ProxyAIApplyStrategy() else SearchReplaceApplyStrategy() + } +} + diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/InlineEditEngine.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/InlineEditEngine.kt new file mode 100644 index 00000000..16a6cdcd --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/InlineEditEngine.kt @@ -0,0 +1,12 @@ +package ee.carlrobert.codegpt.inlineedit.engine + +interface InlineEditEngine { + fun apply(ctx: ApplyContext) +} + +class InlineEditEngineImpl : InlineEditEngine { + override fun apply(ctx: ApplyContext) { + InlineEditApplyStrategyFactory.get().apply(ctx) + } +} + diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/ProxyAIApplyStrategy.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/ProxyAIApplyStrategy.kt new file mode 100644 index 00000000..11c5d05d --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/ProxyAIApplyStrategy.kt @@ -0,0 +1,67 @@ +package ee.carlrobert.codegpt.inlineedit.engine + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.util.TextRange +import ee.carlrobert.codegpt.completions.CompletionClientProvider +import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.inlineedit.InlineEditSession +import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.settings.service.ModelSelectionService +import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.llm.client.codegpt.request.AutoApplyRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import ee.carlrobert.codegpt.util.MarkdownUtil + +class ProxyAIApplyStrategy : ApplyStrategy { + + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + override fun apply(ctx: ApplyContext) { + val blocks = MarkdownUtil.extractCodeBlocks(ctx.lastAssistantResponse) + if (blocks.isEmpty()) return + + val modelSelection = + ModelSelectionService.getInstance().getModelSelectionForFeature(FeatureType.AUTO_APPLY) + if (modelSelection.provider != ServiceType.PROXYAI) return + + val updateSnippet = blocks.joinToString("\n// ... existing code ...\n\n") { it.trimEnd() } + val original = ctx.editor.document.text + + coroutineScope.launch { + runInEdt { + ctx.inlay.setThinkingVisible(true, CodeGPTBundle.get("inlineEdit.applying")) + } + + val merged = try { + CompletionClientProvider.getCodeGPTClient() + .applyChanges(AutoApplyRequest(modelSelection.model, original, updateSnippet)) + .mergedCode + } catch (_: Exception) { + null + } + + if (merged.isNullOrBlank()) { + runInEdt { + ctx.inlay.setThinkingVisible(false) + } + return@launch + } + + runInEdt { + val baseRange = TextRange(0, ctx.editor.document.textLength) + InlineEditSession.start( + requireNotNull(ctx.editor.project), + ctx.editor, + baseRange, + merged + ) + ctx.inlay.setInlineEditControlsVisible(true) + ctx.inlay.setThinkingVisible(false) + ctx.inlay.hideAskPopup() + } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/SearchReplaceApplyStrategy.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/SearchReplaceApplyStrategy.kt new file mode 100644 index 00000000..32fc44ea --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/engine/SearchReplaceApplyStrategy.kt @@ -0,0 +1,56 @@ +package ee.carlrobert.codegpt.inlineedit.engine + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.util.TextRange +import ee.carlrobert.codegpt.inlineedit.InlineEditSearchReplaceListener +import ee.carlrobert.codegpt.completions.CompletionRequestService +import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters +import com.intellij.openapi.components.service +import ee.carlrobert.codegpt.conversations.Conversation + +class SearchReplaceApplyStrategy : ApplyStrategy { + override fun apply(ctx: ApplyContext) { + val editor = ctx.editor + val inlay = ctx.inlay + + runInEdt { + inlay.setInlineEditControlsVisible(false) + inlay.setThinkingVisible(true) + } + + val file = editor.virtualFile + val parameters = InlineEditCompletionParameters( + ctx.promptText, + editor.selectionModel.selectedText, + file?.path, + file?.extension, + editor.project?.basePath, + null, + null, + ctx.submissionHandler.getSessionConversation(), + null, + null, + editor.caretModel.offset + ) + + val requestId = System.nanoTime() + editor.putUserData(InlineEditSearchReplaceListener.REQUEST_ID_KEY, requestId) + + val listener = InlineEditSearchReplaceListener( + editor, + inlay.observableProperties, + TextRange(editor.selectionModel.selectionStart, editor.selectionModel.selectionEnd), + requestId, + ctx.submissionHandler.getSessionConversation() + ) + editor.putUserData(InlineEditSearchReplaceListener.LISTENER_KEY, listener) + + try { + service().getInlineEditCompletionAsync(parameters, listener) + } catch (_: Exception) { + runInEdt { + inlay.setThinkingVisible(false) + } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt index ab297da9..14c195d5 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt @@ -61,4 +61,5 @@ class CodeCompletionSettingsState : BaseState() { var collectDependencyStructure by property(true) var contextAwareEnabled by property(false) var psiStructureAnalyzeDepth by property(2) + var myAwesomeFeatureEnabled by property(true) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.kt index 96e09d8e..0c060b70 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.kt @@ -17,6 +17,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Key import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.JBColor import com.intellij.util.application import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel @@ -46,11 +47,13 @@ import ee.carlrobert.codegpt.util.EditorUtil import ee.carlrobert.llm.client.codegpt.request.AutoApplyRequest import ee.carlrobert.llm.client.codegpt.response.CodeGPTException import java.util.regex.Pattern +import javax.swing.BorderFactory class ResponseEditorPanel( private val project: Project, item: Segment, readOnly: Boolean, + private val compact: Boolean, disposableParent: Disposable, ) : BorderLayoutPanel(), Disposable { @@ -67,17 +70,29 @@ class ResponseEditorPanel( private var searchReplaceHandler: SearchReplaceHandler init { - border = JBUI.Borders.empty(8, 0) isOpaque = false + if (compact) { + val visibleBorderColor = JBColor.namedColor("Component.borderColor", JBColor(0xC4C9D0, 0x44484F)) + border = BorderFactory.createCompoundBorder( + JBUI.Borders.customLine(visibleBorderColor, 1), + JBUI.Borders.empty(4, 6) + ) + } else { + border = JBUI.Borders.empty(8, 0) + } - val state = stateManager.createFromSegment(item, readOnly) + val state = stateManager.createFromSegment(item, readOnly, compact = compact) val editor = state.editor configureEditor(editor) searchReplaceHandler = SearchReplaceHandler(stateManager) { oldEditor, newEditor -> replaceEditor(oldEditor, newEditor) } - addToCenter(editor.component) + if (compact && editor.editorKind != EditorKind.DIFF) { + addToCenter(createCompactEditorContainer(editor)) + } else { + addToCenter(editor.component) + } updateEditorUI() Disposer.register(disposableParent, this) @@ -103,7 +118,11 @@ class ResponseEditorPanel( removeAll() configureEditor(newEditor) - addToCenter(newEditor.component) + if (compact && newEditor.editorKind != EditorKind.DIFF) { + addToCenter(createCompactEditorContainer(newEditor)) + } else { + addToCenter(newEditor.component) + } ComponentFactory.updateEditorPreferredSize(newEditor, expanded) updateEditorUI() @@ -115,7 +134,7 @@ class ResponseEditorPanel( fun replaceEditorWithSegment(segment: Segment) { val oldEditor = stateManager.getCurrentState()?.editor ?: return - val newState = stateManager.createFromSegment(segment) + val newState = stateManager.createFromSegment(segment, compact = compact) replaceEditor(oldEditor, newState.editor) } @@ -147,7 +166,12 @@ class ResponseEditorPanel( } if (!response.isNullOrBlank()) { - stateManager.transitionToDiffState(originalCode, response, params.destination, params.source) + stateManager.transitionToDiffState( + originalCode, + response, + params.destination, + params.source + ) } } catch (e: Exception) { logger.error("Failed to apply changes", e) @@ -224,8 +248,14 @@ class ResponseEditorPanel( val containsText = currentText.contains(segment.search.trim()) val newState = if (containsText) { - val finalSegment = createReplaceWaitingSegment(searchContent, replaceContent, virtualFile) - stateManager.createFromSegment(finalSegment, readOnly = false, eventSource = null, originalSuggestion = replaceContent) + val finalSegment = + createReplaceWaitingSegment(searchContent, replaceContent, virtualFile) + stateManager.createFromSegment( + finalSegment, + readOnly = false, + eventSource = null, + originalSuggestion = replaceContent + ) } else { stateManager.transitionToFailedDiffState( segment.search, @@ -277,11 +307,42 @@ class ResponseEditorPanel( } } }) + + if (compact && editor.editorKind != EditorKind.DIFF) { + editor.settings.apply { + isLineNumbersShown = false + isLineMarkerAreaShown = false + additionalLinesCount = 0 + additionalColumnsCount = 0 + isAdditionalPageAtBottom = false + isUseSoftWraps = false + } + editor.gutterComponentEx.apply { + isVisible = false + parent.isVisible = false + } + editor.component.border = JBUI.Borders.empty(0, 0) + editor.scrollPane.border = JBUI.Borders.empty(0, 0) + } + } + + private fun createCompactEditorContainer(editor: EditorEx): javax.swing.JComponent { + val container = BorderLayoutPanel().apply { + isOpaque = false + border = JBUI.Borders.empty() + } + val inner = BorderLayoutPanel().apply { + isOpaque = false + border = JBUI.Borders.empty(0) + addToCenter(editor.component) + } + container.add(inner, java.awt.BorderLayout.CENTER) + return container } private fun updateEditorUI() { updateEditorHeightAndUI() - updateExpandLinkVisibility() + if (!compact) updateExpandLinkVisibility() } private fun updateEditorHeightAndUI() { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt index 3ab77ffb..d307316b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt @@ -12,6 +12,7 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.readText import com.intellij.ui.components.AnActionLink import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ModelSelectionService import ee.carlrobert.codegpt.settings.service.ServiceType.INCEPTION @@ -24,9 +25,13 @@ class AutoApplyAction( private val filePath: String?, private val virtualFile: VirtualFile?, private val onApply: (AnActionLink) -> Unit, -) : CustomComponentAction, AnAction("Apply", "Apply changes to the editor", AllIcons.Actions.Execute) { +) : CustomComponentAction, AnAction( + CodeGPTBundle.get("shared.apply"), + CodeGPTBundle.get("inlineEdit.apply.description"), + AllIcons.Actions.Execute +) { - private val anActionLink: AnActionLink = AnActionLink("Apply", this).apply { + private val anActionLink: AnActionLink = AnActionLink(CodeGPTBundle.get("shared.apply"), this).apply { icon = AllIcons.Actions.Execute border = JBUI.Borders.empty(0, 4) } @@ -37,14 +42,14 @@ class AutoApplyAction( override fun update(e: AnActionEvent) { if (virtualFile != null) { - anActionLink.text = "Apply" + anActionLink.text = CodeGPTBundle.get("shared.apply") anActionLink.isEnabled = true - anActionLink.toolTipText = "Apply changes to ${virtualFile.name}" + anActionLink.toolTipText = CodeGPTBundle.get("inlineEdit.apply.changesTo", virtualFile.name) if (EditorUtil.getFileContent(virtualFile).trim() == toolwindowEditor.document.text.trim()) { anActionLink.isEnabled = false anActionLink.isVisible = true - anActionLink.toolTipText = "No changes to apply" + anActionLink.toolTipText = CodeGPTBundle.get("inlineEdit.apply.noChanges") } return } @@ -53,7 +58,7 @@ class AutoApplyAction( val selectedEditorFile = selectedEditor?.virtualFile val canApply = selectedEditorFile != null && selectedEditorFile.isWritable - anActionLink.text = if (canApply) "Apply to ${selectedEditorFile.name}" else "Apply" + anActionLink.text = if (canApply) CodeGPTBundle.get("inlineEdit.apply.toFile", selectedEditorFile.name) else CodeGPTBundle.get("shared.apply") anActionLink.isEnabled = canApply anActionLink.isVisible = canApply } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/EditorStateManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/EditorStateManager.kt index a98c58f6..ecba725a 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/EditorStateManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/EditorStateManager.kt @@ -25,17 +25,25 @@ class EditorStateManager(private val project: Project) { private var currentState: EditorState? = null private var diffEditorManager: DiffEditorManager? = null - fun createFromSegment(segment: Segment, readOnly: Boolean = false, eventSource: EventSource? = null, originalSuggestion: String? = null): EditorState { + fun createFromSegment( + segment: Segment, + readOnly: Boolean = false, + eventSource: EventSource? = null, + originalSuggestion: String? = null, + compact: Boolean = false + ): EditorState { val editor = EditorFactory.createEditor(project, segment) val state = if (editor.editorKind == EditorKind.DIFF) { createDiffState(editor, segment, eventSource, originalSuggestion) } else { RegularEditorState(editor, segment, project) } - - runInEdt { - val headerComponent = state.createHeaderComponent(readOnly) - EditorFactory.configureEditor(editor, headerComponent) + + if (!compact) { + runInEdt { + val headerComponent = state.createHeaderComponent(readOnly) + EditorFactory.configureEditor(editor, headerComponent) + } } RESPONSE_EDITOR_STATE_KEY.set(editor, state) @@ -79,7 +87,9 @@ class EditorStateManager(private val project: Project) { diffViewer.rediff(true) } (oldEditor.component.parent as? ResponseEditorPanel)?.replaceEditor(oldEditor, editor) - (editor.permanentHeaderComponent as? DiffHeaderPanel)?.updateDiffStats(diffViewer.diffChanges ?: emptyList()) + (editor.permanentHeaderComponent as? DiffHeaderPanel)?.updateDiffStats( + diffViewer.diffChanges ?: emptyList() + ) (editor.permanentHeaderComponent as? DiffHeaderPanel)?.handleDone() } } @@ -122,12 +132,17 @@ class EditorStateManager(private val project: Project) { currentState = null } - private fun createDiffState(editor: EditorEx, segment: Segment, eventSource: EventSource? = null, originalSuggestion: String? = null): EditorState { + private fun createDiffState( + editor: EditorEx, + segment: Segment, + eventSource: EventSource? = null, + originalSuggestion: String? = null + ): EditorState { val virtualFile = getVirtualFile(segment.filePath) val diffViewer = RESPONSE_EDITOR_DIFF_VIEWER_KEY.get(editor) val diffEditorManager = DiffEditorManager(project, diffViewer) this.diffEditorManager = diffEditorManager - + val state = StandardDiffEditorState( editor, segment, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ui/UserMessagePanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ui/UserMessagePanel.kt index 5bfee8a0..67055b59 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ui/UserMessagePanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ui/UserMessagePanel.kt @@ -131,7 +131,7 @@ class UserMessagePanel( private fun setupResponseBody() { addContent( - ChatMessageResponseBody(project, true, false, false, false, parentDisposable) + ChatMessageResponseBody(project, true, false, false, false, false, parentDisposable) .withResponse(message.prompt) ) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/ThoughtProcessPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/ThoughtProcessPanel.kt index bcab460d..8694e118 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/ThoughtProcessPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/ThoughtProcessPanel.kt @@ -5,6 +5,7 @@ import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import ee.carlrobert.codegpt.util.MarkdownUtil +import ee.carlrobert.codegpt.CodeGPTBundle import java.awt.BorderLayout import java.awt.event.ItemEvent import javax.swing.* @@ -30,7 +31,7 @@ class ThoughtProcessPanel : JPanel(BorderLayout()) { fun setFinished() { if (finished) return - toggleButton.text = "Thought Process" + toggleButton.text = CodeGPTBundle.get("thoughtProcess.title") toggleButton.isSelected = false finished = true @@ -63,7 +64,7 @@ class ThoughtProcessPanel : JPanel(BorderLayout()) { } private fun createToggleButton() = - JToggleButton("Thinking...", AllIcons.General.ArrowUp, true).apply { + JToggleButton(CodeGPTBundle.get("thoughtProcess.thinking"), AllIcons.General.ArrowUp, true).apply { isFocusPainted = false isContentAreaFilled = false background = background @@ -76,4 +77,4 @@ class ThoughtProcessPanel : JPanel(BorderLayout()) { contentPanel.isVisible = e.stateChange == ItemEvent.SELECTED } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldInlayRenderer.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldInlayRenderer.kt deleted file mode 100644 index 280b7a3f..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldInlayRenderer.kt +++ /dev/null @@ -1,244 +0,0 @@ -package ee.carlrobert.codegpt.ui.textarea - -import com.intellij.icons.AllIcons -import com.intellij.openapi.components.service -import com.intellij.openapi.editor.DefaultLanguageHighlighterColors -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.EditorCustomElementRenderer -import com.intellij.openapi.editor.Inlay -import com.intellij.openapi.editor.colors.EditorColorsManager -import com.intellij.openapi.editor.colors.EditorFontType -import com.intellij.openapi.editor.event.EditorMouseEvent -import com.intellij.openapi.editor.event.EditorMouseListener -import com.intellij.openapi.editor.event.EditorMouseMotionListener -import com.intellij.openapi.editor.markup.TextAttributes -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.popup.JBPopup -import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.ui.JBColor -import com.intellij.ui.awt.RelativePoint -import com.intellij.util.ui.JBUI -import java.awt.Cursor -import java.awt.Dimension -import java.awt.Graphics2D -import java.awt.Point -import java.awt.event.MouseEvent -import java.awt.geom.Rectangle2D -import javax.swing.Icon - -class PromptTextFieldInlayRenderer( - private val project: Project, - private val actionPrefix: String, - private val text: String?, - private val fileName: String, - private val tooltipText: String?, - private val onClose: (Inlay<*>) -> Unit -) : EditorCustomElementRenderer { - - private val closeIcon = AllIcons.Actions.Close - private val helpIcon = AllIcons.General.ContextHelp - - private var tooltip: JBPopup? = null - - override fun calcWidthInPixels(inlay: Inlay<*>): Int { - val editor = inlay.editor - val font = editor.colorsScheme.getFont(EditorFontType.PLAIN) - val textWidth = editor.component.getFontMetrics(font) - .stringWidth(actionPrefix + (if (text != null) ":$text" else "")) - - if (tooltipText.isNullOrEmpty()) { - return textWidth + closeIcon.iconWidth + JBUI.scale(10) - } - - return textWidth + closeIcon.iconWidth + JBUI.scale(10) + helpIcon.iconWidth + JBUI.scale(10) - } - - override fun paint( - inlay: Inlay<*>, - g: Graphics2D, - target: Rectangle2D, - textAttributes: TextAttributes - ) { - val editor = inlay.editor - val currentTextAttributes: TextAttributes? = - EditorColorsManager.getInstance().globalScheme.getAttributes( - DefaultLanguageHighlighterColors.INLAY_DEFAULT - ) - - drawBackground(g, target, currentTextAttributes) - drawBorder(g, target) - drawText(g, target, editor, currentTextAttributes) - drawCloseIcon(g, target) - if (tooltipText != null) { - drawHelpIcon(g, target) - } - - addMouseListeners(editor, inlay, target) - } - - private fun drawBackground( - g: Graphics2D, - target: Rectangle2D, - textAttributes: TextAttributes? - ) { - g.color = textAttributes?.backgroundColor ?: JBColor.background() - g.fill(target) - } - - private fun drawBorder(g: Graphics2D, target: Rectangle2D) { - g.color = JBColor.border() - g.draw(target) - } - - private fun drawText( - g: Graphics2D, - target: Rectangle2D, - editor: Editor, - textAttributes: TextAttributes? - ) { - g.font = editor.colorsScheme.getFont(EditorFontType.PLAIN) - - val metrics = g.fontMetrics - val textHeight = metrics.height - val startX = (target.x + JBUI.scale(5)).toInt() - val startY = (target.y + (target.height - textHeight) / 2 + metrics.ascent).toInt() - - g.color = textAttributes?.foregroundColor ?: JBColor.foreground() - g.drawString(actionPrefix, startX, startY) - - if (!text.isNullOrEmpty()) { - val prefixWidth = metrics.stringWidth(actionPrefix) - g.color = service().globalScheme.defaultForeground - g.drawString(":$text", startX + prefixWidth, startY) - } - } - - private fun drawCloseIcon(g: Graphics2D, target: Rectangle2D) { - val iconX = (target.x + target.width - closeIcon.iconWidth - JBUI.scale(5)).toInt() - val iconY = (target.y + (target.height - closeIcon.iconHeight) / 2).toInt() - closeIcon.paintIcon(null, g, iconX, iconY) - } - - private fun drawHelpIcon(g: Graphics2D, target: Rectangle2D) { - val iconX = - (target.x + target.width - closeIcon.iconWidth - helpIcon.iconWidth - JBUI.scale(10)).toInt() - val iconY = (target.y + (target.height - helpIcon.iconHeight) / 2).toInt() - helpIcon.paintIcon(null, g, iconX, iconY) - } - - private fun showTooltip(inlay: Inlay<*>) { - if (tooltipText != null) { - hideTooltip() - - val tooltipContent = CodePreviewTooltipContent(project, fileName, tooltipText) - tooltip = JBPopupFactory.getInstance() - .createComponentPopupBuilder( - tooltipContent, - tooltipContent.getFocusableComponent() - ) - .setTitle("Code Preview") - .setResizable(true) - .setMovable(true) - .setStretchToOwnerHeight(true) - .setStretchToOwnerWidth(true) - .setMinSize( - Dimension( - tooltipContent.preferredSize?.width ?: 240, - (tooltipContent.preferredSize?.height ?: 0) - ) - ) - .createPopup() - tooltip?.show( - RelativePoint( - inlay.editor.contentComponent, - calculatePopupPoint(inlay, tooltipContent) - ) - ) - } - } - - private fun calculatePopupPoint( - inlay: Inlay<*>, - tooltipContent: CodePreviewTooltipContent - ): Point { - val visibleArea = inlay.editor.scrollingModel.visibleArea - val inlayBounds = inlay.bounds - if (inlayBounds != null) { - val x = inlayBounds.x - val tooltipHeight = tooltipContent.preferredSize?.height ?: 0 - val y = inlayBounds.y - tooltipHeight - return Point(x, y) - } - return Point(visibleArea.x, visibleArea.y) - } - - private fun hideTooltip() { - tooltip?.dispose() - } - - private fun addMouseListeners(editor: Editor, inlay: Inlay<*>, target: Rectangle2D) { - fun isWithinIconBounds(e: MouseEvent, icon: Icon): Boolean { - val iconX = when (icon) { - closeIcon -> (target.x + target.width - closeIcon.iconWidth - JBUI.scale(5)).toInt() - helpIcon -> (target.x + target.width - closeIcon.iconWidth - helpIcon.iconWidth - JBUI.scale( - 10 - )).toInt() - - else -> return false - } - val iconY = (target.y + (target.height - icon.iconHeight) / 2).toInt() - return e.x >= iconX && e.x <= iconX + icon.iconWidth && - e.y >= iconY && e.y <= iconY + icon.iconHeight - } - - fun updateCursor(event: EditorMouseEvent, inlay: Inlay<*>) { - editor.contentComponent.let { - if (inlay.isValid) { - val inlayBounds = inlay.bounds - val mouseX = event.mouseEvent.x.toDouble() - val mouseY = event.mouseEvent.y.toDouble() - - if (inlayBounds != null && inlayBounds.contains(mouseX, mouseY)) { - it.cursor = Cursor.getDefaultCursor() - return - } - } - it.cursor = Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR) - } - } - - editor.addEditorMouseMotionListener(object : EditorMouseMotionListener { - override fun mouseMoved(event: EditorMouseEvent) { - findInlayAtMouseEvent(event)?.let { - updateCursor(event, it) - } - } - - private fun findInlayAtMouseEvent(event: EditorMouseEvent): Inlay<*>? { - val offset = editor.logicalPositionToOffset(event.logicalPosition) - val inlays = editor.inlayModel.getInlineElementsInRange(offset, offset) - return inlays.firstOrNull { inlay -> - val inlayBounds = editor.visualPositionToXY(inlay.visualPosition) - val mousePoint = event.mouseEvent.point - inlayBounds.x <= mousePoint.x && mousePoint.x <= inlayBounds.x + inlay.widthInPixels - } - } - }) - - editor.addEditorMouseListener(object : EditorMouseListener { - override fun mouseClicked(event: EditorMouseEvent) { - when { - isWithinIconBounds(event.mouseEvent, closeIcon) -> { - onClose(inlay) - event.consume() - } - - isWithinIconBounds(event.mouseEvent, helpIcon) -> { - showTooltip(inlay) - event.consume() - } - } - } - }) - } -} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchReplaceToggleAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchReplaceToggleAction.kt index a364c774..5d0ae46e 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchReplaceToggleAction.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SearchReplaceToggleAction.kt @@ -89,12 +89,12 @@ class SearchReplaceToggleAction( -
-
- ${currentMode.displayName} Mode +
+
+ ${'$'}{currentMode.displayName} Mode
-
- ${getModeDescription(currentMode)} +
+ ${'$'}{getModeDescription(currentMode)}
@@ -117,4 +117,5 @@ class SearchReplaceToggleAction( "Coming soon." } } -} \ No newline at end of file +} + diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt index 44998601..bd2f329c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt @@ -18,6 +18,7 @@ import com.intellij.openapi.observable.properties.AtomicBooleanProperty import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.components.JBCheckBox import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.RightGap import com.intellij.ui.dsl.builder.panel @@ -29,8 +30,8 @@ import com.intellij.util.ui.components.BorderLayoutPanel import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.Icons import ee.carlrobert.codegpt.ReferencedFile -import ee.carlrobert.codegpt.settings.configuration.ChatMode import ee.carlrobert.codegpt.settings.models.ModelRegistry +import ee.carlrobert.codegpt.settings.configuration.ChatMode import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ModelSelectionService import ee.carlrobert.codegpt.settings.service.ServiceType @@ -38,6 +39,7 @@ import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.ModelComboBoxAction import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel import ee.carlrobert.codegpt.ui.IconActionButton import ee.carlrobert.codegpt.ui.components.InlineEditChips +import ee.carlrobert.codegpt.ui.components.BadgeChip import ee.carlrobert.codegpt.ui.dnd.FileDragAndDrop import ee.carlrobert.codegpt.ui.textarea.header.UserInputHeaderPanel import ee.carlrobert.codegpt.ui.textarea.header.tag.* @@ -64,7 +66,8 @@ class UserInputPanel @JvmOverloads constructor( private val onStop: () -> Unit, private val onAcceptAll: (() -> Unit)? = null, private val onRejectAll: (() -> Unit)? = null, - private val showModeSelector: Boolean = true, + private val onApply: (() -> Unit)? = null, + private val getMarkdownContent: (() -> String)? = null, withRemovableSelectedEditorTag: Boolean = true, ) : BorderLayoutPanel() { @@ -76,7 +79,6 @@ class UserInputPanel @JvmOverloads constructor( tagManager: TagManager, onSubmit: (String) -> Unit, onStop: () -> Unit, - showModeSelector: Boolean, withRemovableSelectedEditorTag: Boolean ) : this( project, @@ -88,7 +90,8 @@ class UserInputPanel @JvmOverloads constructor( onStop, null, null, - showModeSelector, + null, + null, withRemovableSelectedEditorTag ) @@ -96,6 +99,9 @@ class UserInputPanel @JvmOverloads constructor( private const val CORNER_RADIUS = 16 } + private val quickQuestionCheckbox = JBCheckBox(CodeGPTBundle.get("userInput.quickQuestion"), true).apply { + isOpaque = false + } private var chatMode: ChatMode = ChatMode.ASK private val disposableCoroutineScope = DisposableCoroutineScope() private val promptTextField = @@ -117,11 +123,16 @@ class UserInputPanel @JvmOverloads constructor( tagManager, totalTokensPanel, promptTextField, - withRemovableSelectedEditorTag + withRemovableSelectedEditorTag, + onApply, + getMarkdownContent ) private var footerPanelRef: JPanel? = null + private val applyChip = onApply?.let { BadgeChip(CodeGPTBundle.get("shared.apply"), InlineEditChips.GREEN, it) }?.apply { + isVisible = false + } private val acceptChip = InlineEditChips.acceptAll { onAcceptAll?.invoke() }.apply { isVisible = false } private val rejectChip = @@ -129,7 +140,7 @@ class UserInputPanel @JvmOverloads constructor( private var inlineEditControls: List = listOf(acceptChip, rejectChip) private val thinkingIcon = AsyncProcessIcon("inline-edit-thinking").apply { isVisible = false } - private val thinkingLabel = javax.swing.JLabel("Thinking…").apply { + private val thinkingLabel = javax.swing.JLabel(CodeGPTBundle.get("shared.thinking")).apply { foreground = service().globalScheme.defaultForeground isVisible = false } @@ -177,11 +188,10 @@ class UserInputPanel @JvmOverloads constructor( val text: String get() = promptTextField.text + fun isQuickQuestionEnabled(): Boolean = quickQuestionCheckbox.isSelected fun getChatMode(): ChatMode = chatMode + fun setChatMode(mode: ChatMode) { chatMode = mode } - fun setChatMode(mode: ChatMode) { - chatMode = mode - } init { setupDisposables(parentDisposable) @@ -450,13 +460,11 @@ class UserInputPanel @JvmOverloads constructor( } modelComboBoxComponent = modelComboBox - val searchReplaceToggle = if (showModeSelector) { + val searchReplaceToggle = if (featureType == FeatureType.CHAT) { SearchReplaceToggleAction(this).createCustomComponent(ActionPlaces.UNKNOWN).apply { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) } - } else { - null - } + } else null searchReplaceToggleComponent = searchReplaceToggle val pnl = panel { @@ -468,9 +476,11 @@ class UserInputPanel @JvmOverloads constructor( cell(thinkingPanel).gap(RightGap.SMALL) cell(acceptChip).gap(RightGap.SMALL) cell(rejectChip).gap(RightGap.SMALL) - if (showModeSelector) { - cell(createToolbarSeparator()).gap(RightGap.SMALL) - cell(searchReplaceToggle!!) + cell(createToolbarSeparator()).gap(RightGap.SMALL) + if (featureType == FeatureType.INLINE_EDIT) { + cell(quickQuestionCheckbox) + } else if (searchReplaceToggle != null) { + cell(searchReplaceToggle) } } }.align(AlignX.LEFT) @@ -478,6 +488,7 @@ class UserInputPanel @JvmOverloads constructor( { panel { row { + if (applyChip != null) cell(applyChip).gap(RightGap.SMALL) cell(submitButton).gap(RightGap.SMALL) cell(stopButton) } @@ -493,7 +504,15 @@ class UserInputPanel @JvmOverloads constructor( repaint() } - fun setThinkingVisible(visible: Boolean, text: String = "Thinking…") { + fun setApplyVisible(visible: Boolean) { + userInputHeaderPanel.setApplyVisible(visible) + } + + fun setApplyEnabled(enabled: Boolean) { + userInputHeaderPanel.setApplyEnabled(enabled) + } + + fun setThinkingVisible(visible: Boolean, text: String = CodeGPTBundle.get("shared.thinking")) { thinkingLabel.text = text thinkingIcon.isVisible = visible thinkingLabel.isVisible = visible diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt index b0172216..47184554 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt @@ -21,6 +21,12 @@ import ee.carlrobert.codegpt.EncodingManager import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.TotalTokensPanel import ee.carlrobert.codegpt.ui.WrapLayout +import ee.carlrobert.codegpt.ui.IconActionButton +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ActionUpdateThread +import ee.carlrobert.codegpt.ui.components.BadgeChip +import ee.carlrobert.codegpt.ui.components.InlineEditChips import ee.carlrobert.codegpt.ui.textarea.PromptTextField import ee.carlrobert.codegpt.ui.textarea.TagDetailsComparator import ee.carlrobert.codegpt.ui.textarea.header.tag.* @@ -32,6 +38,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import java.awt.* +import java.awt.datatransfer.StringSelection +import java.awt.Toolkit import java.awt.event.ActionListener import javax.swing.JButton import javax.swing.JPanel @@ -42,14 +50,16 @@ class UserInputHeaderPanel( private val tagManager: TagManager, private val totalTokensPanel: TotalTokensPanel, private val promptTextField: PromptTextField, - private val withRemovableSelectedEditorTag: Boolean + private val withRemovableSelectedEditorTag: Boolean, + private val onApply: (() -> Unit)? = null, + private val getMarkdownContent: (() -> String)? = null ) : JPanel(WrapLayout(FlowLayout.LEFT, 4, 4)), TagManagerListener { companion object { private const val INITIAL_VISIBLE_FILES = 2 } - private val emptyText = JBLabel("No context included").apply { + private val emptyText = JBLabel(CodeGPTBundle.get("userInput.noContextIncluded")).apply { foreground = JBUI.CurrentTheme.Label.disabledForeground() font = JBUI.Fonts.smallFont() isVisible = getSelectedEditor(project) == null @@ -74,6 +84,34 @@ class UserInputHeaderPanel( add(emptyText) } + private val applyChip = onApply?.let { handler -> + BadgeChip(CodeGPTBundle.get("shared.apply"), InlineEditChips.GREEN, handler) + .apply { isVisible = false } + } + + private val copyButton = IconActionButton( + object : AnAction( + CodeGPTBundle.get("shared.copy"), + CodeGPTBundle.get("shared.copyToClipboard"), + AllIcons.Actions.Copy + ) { + override fun actionPerformed(e: AnActionEvent) { + val text = getMarkdownContent?.invoke().orEmpty() + if (text.isNotEmpty()) { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(text), null) + } + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = !getMarkdownContent?.invoke().isNullOrEmpty() + } + }, + "COPY_MD" + ).apply { isVisible = getMarkdownContent != null } + private val backgroundScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) init { @@ -233,9 +271,23 @@ class UserInputHeaderPanel( border = JBUI.Borders.empty() add(defaultHeaderTagsPanel) + applyChip?.let { add(it) } + add(copyButton) addInitialTags() } + fun setApplyVisible(visible: Boolean) { + applyChip?.isVisible = visible + revalidate() + repaint() + } + + fun setApplyEnabled(enabled: Boolean) { + applyChip?.isEnabled = enabled + revalidate() + repaint() + } + private fun addInitialTags() { val autoTaggingEnabled = ConfigurationSettings.getState().chatCompletionSettings.editorContextTagEnabled @@ -291,7 +343,7 @@ class UserInputHeaderPanel( isContentAreaFilled = false isOpaque = false border = null - toolTipText = "Add Context" + toolTipText = CodeGPTBundle.get("userInput.addContextTooltip") icon = IconUtil.scale(AllIcons.General.InlineAdd, null, 0.75f) rolloverIcon = IconUtil.scale(AllIcons.General.InlineAddHover, null, 0.75f) pressedIcon = IconUtil.scale(AllIcons.General.InlineAddHover, null, 0.75f) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/MarkdownUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/MarkdownUtil.kt index 1708f4c6..6592b548 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/util/MarkdownUtil.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/MarkdownUtil.kt @@ -45,4 +45,22 @@ object MarkdownUtil { .build() .render(document) } + + /** + * Extract raw contents of fenced triple-backtick code blocks (without the fences or language). + * Returns only non-blank code block bodies in order of appearance. + */ + @JvmStatic + fun extractCodeBlocks(inputMarkdown: String): List { + val pattern = Pattern.compile( + "(?ms)```([a-zA-Z0-9_+\-]*)\s*\r?\n([\s\S]*?)\r?\n```" + ) + val matcher = pattern.matcher(inputMarkdown) + val results = mutableListOf() + while (matcher.find()) { + val content = matcher.group(2) + if (!content.isNullOrBlank()) results.add(content) + } + return results + } } diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index 66d0f819..24dee5eb 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -291,6 +291,7 @@ shared.copyCode=Copy Code shared.copyMessageContents=Copy Message Contents shared.copyToClipboard=Copy to clipboard shared.copiedToClipboard=Copied to clipboard +shared.thinking=Thinking… shared.configuration=Configuration shared.delete=Delete Message shared.deleteDescription=Delete message @@ -436,3 +437,31 @@ inlineEdit.hint.searchingFor=Searching for: {0} inlineEdit.status.waiting=Waiting for model response… inlineEdit.status.noChanges=No applicable changes found inlineEdit.action.openInChat=Open in Chat +shared.apply="Apply" + +# Inline Edit / Ask related +inlineEdit.conversation.title=Inline Edit ({0}) +inlineEdit.conversation.untitled=untitled +inlineEdit.closeWarning.message=You have pending changes that will be lost. Do you want to close anyway? +inlineEdit.closeWarning.title=Pending Changes +inlineEdit.closeWarning.closeAnyway=Close Anyway +inlineEdit.applying=Applying… +inlineEdit.undo.acceptAll.commandTitle=Accept All Inline Edit Changes +inlineEdit.undo.commandGroup=InlineEdit +inlineEdit.apply.description=Apply changes to the editor +inlineEdit.apply.changesTo=Apply changes to {0} +inlineEdit.apply.noChanges=No changes to apply +inlineEdit.apply.toFile=Apply to {0} + +# Errors +error.askRequestFailed=Ask mode request failed +error.inlineEditRequestFailed=Inline Edit request failed + +# User input panel +userInput.quickQuestion=Quick question +userInput.noContextIncluded=No context included +userInput.addContextTooltip=Add Context + +# Thought process panel +thoughtProcess.thinking=Thinking... +thoughtProcess.title=Thought Process