diff --git a/README.md b/README.md index 5b57ea2b..306a8ac7 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ git submodule update **Tailing logs** ```shell -tail -f build/idea-sandbox/system/log/idea.log +tail -f build/idea-sandbox/IC-2024.1.2/log/idea.log ``` ## Privacy diff --git a/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java b/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java index f86328df..9dc3ceb9 100644 --- a/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java +++ b/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java @@ -1,11 +1,14 @@ package ee.carlrobert.codegpt; import com.intellij.openapi.util.Key; +import ee.carlrobert.codegpt.inlineedit.InlineEditSession; +import ee.carlrobert.codegpt.inlineedit.InlineEditInlayRenderer; import ee.carlrobert.codegpt.predictions.CodeSuggestionDiffViewer; import ee.carlrobert.codegpt.toolwindow.chat.editor.ToolWindowEditorFileDetails; import ee.carlrobert.llm.client.codegpt.CodeGPTUserDetails; import ee.carlrobert.service.NextEditResponse; import ee.carlrobert.service.PartialCodeCompletionResponse; +import javax.swing.JComponent; public class CodeGPTKeys { @@ -21,6 +24,12 @@ public class CodeGPTKeys { Key.create("codegpt.isPromptTextFieldDocument"); public static final Key EDITOR_PREDICTION_DIFF_VIEWER = Key.create("codegpt.editorPredictionDiffViewer"); + public static final Key EDITOR_INLINE_EDIT_SESSION = + Key.create("codegpt.editorInlineEditSession"); + public static final Key EDITOR_INLINE_EDIT_RENDERER = + Key.create("codegpt.editorInlineEditRenderer"); + public static final Key EDITOR_INLINE_EDIT_COMPARE_LINK = + Key.create("codegpt.editorInlineEditCompareLink"); public static final Key REMAINING_CODE_COMPLETION = Key.create("codegpt.remainingCodeCompletion"); public static final Key REMAINING_PREDICTION_RESPONSE = diff --git a/src/main/java/ee/carlrobert/codegpt/actions/editor/BaseEditorAction.java b/src/main/java/ee/carlrobert/codegpt/actions/editor/BaseEditorAction.java index 6fecdf5a..864b9c03 100644 --- a/src/main/java/ee/carlrobert/codegpt/actions/editor/BaseEditorAction.java +++ b/src/main/java/ee/carlrobert/codegpt/actions/editor/BaseEditorAction.java @@ -37,17 +37,15 @@ public abstract class BaseEditorAction extends AnAction { var project = event.getProject(); var editor = event.getData(PlatformDataKeys.EDITOR); if (editor != null && project != null) { - actionPerformed(project, editor, editor.getSelectionModel().getSelectedText()); + var selectedText = editor.getSelectionModel().getSelectedText(); + actionPerformed(project, editor, selectedText != null ? selectedText : ""); } } public void update(AnActionEvent event) { Project project = event.getProject(); Editor editor = event.getData(PlatformDataKeys.EDITOR); - boolean menuAllowed = false; - if (editor != null && project != null) { - menuAllowed = editor.getSelectionModel().getSelectedText() != null; - } + boolean menuAllowed = editor != null && project != null; event.getPresentation().setEnabled(menuAllowed); } diff --git a/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java b/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java index 26af6898..468223f3 100644 --- a/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java +++ b/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestService.java @@ -93,15 +93,15 @@ public final class CompletionRequestService { return getChatCompletionAsync(request, eventListener, serviceType, FeatureType.COMMIT_MESSAGE); } - public EventSource getEditCodeCompletionAsync( - EditCodeCompletionParameters params, + public EventSource getInlineEditCompletionAsync( + InlineEditCompletionParameters params, CompletionEventListener eventListener) { var serviceType = - ModelSelectionService.getInstance().getServiceForFeature(FeatureType.EDIT_CODE); + ModelSelectionService.getInstance().getServiceForFeature(FeatureType.INLINE_EDIT); var request = CompletionRequestFactory .getFactory(serviceType) - .createEditCodeRequest(params); - return getChatCompletionAsync(request, eventListener, serviceType, FeatureType.EDIT_CODE); + .createInlineEditRequest(params); + return getChatCompletionAsync(request, eventListener, serviceType, FeatureType.INLINE_EDIT); } public EventSource getChatCompletionAsync( diff --git a/src/main/java/ee/carlrobert/codegpt/settings/service/FeatureType.java b/src/main/java/ee/carlrobert/codegpt/settings/service/FeatureType.java index 89f0b130..7893fb84 100644 --- a/src/main/java/ee/carlrobert/codegpt/settings/service/FeatureType.java +++ b/src/main/java/ee/carlrobert/codegpt/settings/service/FeatureType.java @@ -5,7 +5,7 @@ public enum FeatureType { CODE_COMPLETION, AUTO_APPLY, COMMIT_MESSAGE, - EDIT_CODE, + INLINE_EDIT, NEXT_EDIT, LOOKUP } \ No newline at end of file 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 0438e9f4..1b496adf 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java @@ -105,9 +105,12 @@ public class ChatToolWindowTabPanel implements Disposable { project, totalTokensPanel, this, + FeatureType.CHAT, tagManager, this::handleSubmit, - this::handleCancel); + this::handleCancel, + true, + true); userInputPanel.requestFocus(); rootPanel = createRootPanel(); diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/structure/data/PsiStructureRepository.kt b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/structure/data/PsiStructureRepository.kt index 97919fdb..3d63faac 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/structure/data/PsiStructureRepository.kt +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/structure/data/PsiStructureRepository.kt @@ -5,6 +5,7 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ReadAction import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.vfs.AsyncFileListener @@ -181,8 +182,11 @@ class PsiStructureRepository( coroutineContext.ensureActive() try { PsiManager.getInstance(project).findFile(virtualFile) - } catch (exc: Exception) { - logger.warn("Failed to find file ${virtualFile.name}", exc) + } catch (ex: Exception) { + if (ex is ProcessCanceledException) { + throw ex + } + logger.warn("Failed to find file ${virtualFile.name}", ex) null } } @@ -289,4 +293,4 @@ class PsiStructureRepository( } } .toSet() -} \ No newline at end of file +} diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java index 70c5238e..ccdf9f08 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/ModelComboBoxAction.java @@ -66,12 +66,13 @@ public class ModelComboBoxAction extends ComboBoxAction { private final Project project; private final List availableProviders; private final boolean showConfigureModels; + private final FeatureType featureType; public ModelComboBoxAction( Project project, Consumer onModelChange, ServiceType selectedService) { - this(project, onModelChange, selectedService, Arrays.asList(ServiceType.values()), true); + this(project, onModelChange, selectedService, Arrays.asList(ServiceType.values()), true, FeatureType.CHAT); } public ModelComboBoxAction( @@ -80,10 +81,21 @@ public class ModelComboBoxAction extends ComboBoxAction { ServiceType selectedProvider, List availableProviders, boolean showConfigureModels) { + this(project, onModelChange, selectedProvider, availableProviders, showConfigureModels, FeatureType.CHAT); + } + + public ModelComboBoxAction( + Project project, + Consumer onModelChange, + ServiceType selectedProvider, + List availableProviders, + boolean showConfigureModels, + FeatureType featureType) { this.project = project; this.onModelChange = onModelChange; this.availableProviders = availableProviders; this.showConfigureModels = showConfigureModels; + this.featureType = featureType; setSmallVariant(true); updateTemplatePresentation(selectedProvider); @@ -92,8 +104,12 @@ public class ModelComboBoxAction extends ComboBoxAction { ModelChangeNotifier.getTopic(), new ModelChangeNotifierAdapter() { @Override - public void chatModelChanged(@NotNull String newModel, @NotNull ServiceType serviceType) { - updateTemplatePresentation(serviceType); + public void modelChanged(@NotNull FeatureType changedFeature, + @NotNull String newModel, + @NotNull ServiceType serviceType) { + if (changedFeature == featureType) { + updateTemplatePresentation(serviceType); + } } }); } @@ -275,12 +291,13 @@ public class ModelComboBoxAction extends ComboBoxAction { var application = ApplicationManager.getApplication(); var templatePresentation = getTemplatePresentation(); var chatModel = application.getService(ModelSettings.class).getState() - .getModelSelection(FeatureType.CHAT); + .getModelSelection(featureType); var modelCode = chatModel != null ? chatModel.getModel() : null; switch (selectedService) { case PROXYAI: - var proxyAIModel = ModelRegistry.getInstance().getProxyAIChatModels().stream() + var proxyAIModel = ModelRegistry.getInstance().getAllModelsForFeature(featureType).stream() + .filter(it -> it.getProvider() == PROXYAI) .filter(it -> modelCode != null && it.getModel().equals(modelCode)) .findFirst(); templatePresentation.setIcon( @@ -391,7 +408,7 @@ public class ModelComboBoxAction extends ComboBoxAction { var application = ApplicationManager.getApplication(); application .getService(ModelSettings.class) - .setModel(FeatureType.CHAT, model.getModel(), PROXYAI); + .setModel(featureType, model.getModel(), PROXYAI); handleModelChange(PROXYAI); }); @@ -420,7 +437,7 @@ public class ModelComboBoxAction extends ComboBoxAction { .setModel(model); application .getService(ModelSettings.class) - .setModel(FeatureType.CHAT, model, OLLAMA); + .setModel(featureType, model, OLLAMA); }); } @@ -434,7 +451,7 @@ public class ModelComboBoxAction extends ComboBoxAction { Icons.OpenAI, comboBoxPresentation, () -> ApplicationManager.getApplication().getService(ModelSettings.class) - .setModel(FeatureType.CHAT, model.getCode(), OPENAI)); + .setModel(featureType, model.getCode(), OPENAI)); } private AnAction createCustomOpenAIModelAction( @@ -446,7 +463,7 @@ public class ModelComboBoxAction extends ComboBoxAction { Icons.OpenAI, comboBoxPresentation, () -> ApplicationManager.getApplication().getService(ModelSettings.class) - .setModel(FeatureType.CHAT, model.getName(), CUSTOM_OPENAI)); + .setModel(featureType, model.getName(), CUSTOM_OPENAI)); } private AnAction createGoogleModelAction(GoogleModel model, Presentation comboBoxPresentation) { @@ -457,7 +474,7 @@ public class ModelComboBoxAction extends ComboBoxAction { Icons.Google, comboBoxPresentation, () -> ApplicationManager.getApplication().getService(ModelSettings.class) - .setModel(FeatureType.CHAT, model.getCode(), GOOGLE)); + .setModel(featureType, model.getCode(), GOOGLE)); } private AnAction createAnthropicModelAction( @@ -470,7 +487,7 @@ public class ModelComboBoxAction extends ComboBoxAction { Icons.Anthropic, comboBoxPresentation, () -> ApplicationManager.getApplication().getService(ModelSettings.class) - .setModel(FeatureType.CHAT, modelCode, ANTHROPIC)); + .setModel(featureType, modelCode, ANTHROPIC)); } private AnAction createLlamaModelAction(Presentation comboBoxPresentation) { @@ -480,7 +497,7 @@ public class ModelComboBoxAction extends ComboBoxAction { Icons.Llama, comboBoxPresentation, () -> ApplicationManager.getApplication().getService(ModelSettings.class) - .setModel(FeatureType.CHAT, + .setModel(featureType, LlamaSettings.getCurrentState().getHuggingFaceModel().getCode(), LLAMA_CPP)); } @@ -492,12 +509,12 @@ public class ModelComboBoxAction extends ComboBoxAction { Icons.Mistral, comboBoxPresentation, () -> ApplicationManager.getApplication().getService(ModelSettings.class) - .setModel(FeatureType.CHAT, modelCode, MISTRAL)); + .setModel(featureType, modelCode, MISTRAL)); } private String getMistralPresentationText() { var chatModel = ApplicationManager.getApplication().getService(ModelSettings.class).getState() - .getModelSelection(FeatureType.CHAT); + .getModelSelection(featureType); var modelCode = chatModel != null ? chatModel.getModel() : null; return ModelRegistry.getInstance().getModelDisplayName(MISTRAL, modelCode); } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTEditorFactoryListener.kt b/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTEditorFactoryListener.kt index f247aa9a..22dd5b66 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTEditorFactoryListener.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTEditorFactoryListener.kt @@ -56,4 +56,4 @@ class CodeGPTEditorFactoryListener : EditorFactoryListener { .syncPublisher(EditorNotifier.Released.TOPIC) .editorReleased(event.editor) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTProjectActivity.kt b/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTProjectActivity.kt index bb8f9551..e5799d04 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTProjectActivity.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/CodeGPTProjectActivity.kt @@ -7,9 +7,7 @@ import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.startup.ProjectActivity import ee.carlrobert.codegpt.actions.editor.EditorActionsUtil -import ee.carlrobert.codegpt.settings.GeneralSettings import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings -import ee.carlrobert.codegpt.settings.service.ServiceType import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTService import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.AttachImageNotifier import ee.carlrobert.codegpt.ui.OverlayUtil diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/InlayActionPromoter.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/InlayActionPromoter.kt index 4405cbcb..971ff010 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/actions/InlayActionPromoter.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/InlayActionPromoter.kt @@ -5,24 +5,34 @@ import com.intellij.openapi.actionSystem.ActionPromoter import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.DataContext +import ee.carlrobert.codegpt.CodeGPTKeys import ee.carlrobert.codegpt.codecompletions.AcceptNextLineInlayAction import ee.carlrobert.codegpt.codecompletions.AcceptNextWordInlayAction import ee.carlrobert.codegpt.predictions.OpenPredictionAction import ee.carlrobert.codegpt.predictions.TriggerCustomPredictionAction +import ee.carlrobert.codegpt.inlineedit.AcceptCurrentInlineEditAction +import ee.carlrobert.codegpt.actions.editor.AcceptInlineEditAction +import ee.carlrobert.codegpt.inlineedit.RejectCurrentInlineEditAction class InlayActionPromoter : ActionPromoter { override fun promote(actions: List, context: DataContext): List { val editor = CommonDataKeys.EDITOR.getData(context) ?: return emptyList() + val hasInlineEdit = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) != null || + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null + if (hasInlineEdit) { + actions.filterIsInstance().takeIf { it.isNotEmpty() }?.let { return it } + actions.filterIsInstance().takeIf { it.isNotEmpty() }?.let { return it } + actions.filterIsInstance().takeIf { it.isNotEmpty() }?.let { return it } + } + actions.filterIsInstance().takeIf { it.isNotEmpty() }?.let { return it } actions.filterIsInstance().takeIf { it.isNotEmpty() }?.let { return it } - if (InlineCompletionContext.getOrNull(editor) == null) { - return emptyList() - } + if (InlineCompletionContext.getOrNull(editor) == null) return emptyList() actions.filterIsInstance().takeIf { it.isNotEmpty() }?.let { return it } actions.filterIsInstance().takeIf { it.isNotEmpty() }?.let { return it } return emptyList() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/AcceptInlineEditAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/AcceptInlineEditAction.kt new file mode 100644 index 00000000..31fc144e --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/AcceptInlineEditAction.kt @@ -0,0 +1,37 @@ +package ee.carlrobert.codegpt.actions.editor + +import com.intellij.codeInsight.hint.HintManagerImpl +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.actionSystem.EditorAction +import com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler +import ee.carlrobert.codegpt.CodeGPTKeys + +class AcceptInlineEditAction : EditorAction(Handler()), HintManagerImpl.ActionToIgnore { + + companion object { + const val ID = "codegpt.acceptInlineEdit" + } + + private class Handler : EditorWriteActionHandler() { + + override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) { + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.let { session -> + session.acceptNearestToCaret() + return + } + + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptNext() + } + + override fun isEnabledForCaret( + editor: Editor, + caret: Caret, + dataContext: DataContext + ): Boolean { + return editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) != null || + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeCompletionListener.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeCompletionListener.kt deleted file mode 100644 index 5f017528..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeCompletionListener.kt +++ /dev/null @@ -1,143 +0,0 @@ -package ee.carlrobert.codegpt.actions.editor - -import com.intellij.ide.BrowserUtil -import com.intellij.notification.NotificationAction -import com.intellij.notification.NotificationType -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.application.runUndoTransparentWriteAction -import com.intellij.openapi.command.WriteCommandAction.runWriteCommandAction -import com.intellij.openapi.components.service -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.markup.* -import com.intellij.openapi.util.TextRange -import com.intellij.openapi.util.text.StringUtil -import com.intellij.psi.PsiDocumentManager -import com.intellij.psi.codeStyle.CodeStyleManager -import com.intellij.ui.JBColor -import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier -import ee.carlrobert.codegpt.util.ThinkingOutputParser -import ee.carlrobert.codegpt.ui.ObservableProperties -import ee.carlrobert.codegpt.ui.OverlayUtil -import ee.carlrobert.llm.client.openai.completion.ErrorDetails -import ee.carlrobert.llm.completion.CompletionEventListener -import okhttp3.sse.EventSource - -class EditCodeCompletionListener( - private val editor: Editor, - private val observableProperties: ObservableProperties, - private val selectionTextRange: TextRange -) : CompletionEventListener { - - private var replacedLength = 0 - private var currentHighlighter: RangeHighlighter? = null - private val thinkingOutputParser = ThinkingOutputParser() - - override fun onMessage(message: String, eventSource: EventSource) { - val processedChunk = thinkingOutputParser.processChunk(message) - if (processedChunk.isNotEmpty() && thinkingOutputParser.isFinished) { - runInEdt { handleDiff(processedChunk) } - } - } - - override fun onComplete(messageBuilder: StringBuilder) { - runInEdt { - if (replacedLength == 0 && messageBuilder.isNotEmpty()) { - handleDiff(messageBuilder.toString()) - } - cleanupAndFormat() - } - stopLoading() - } - - override fun onError(error: ErrorDetails, ex: Throwable) { - OverlayUtil.showNotification( - error.message, - NotificationType.ERROR, - NotificationAction.createSimpleExpiring("Upgrade plan") { - BrowserUtil.open("https://tryproxy.io/#pricing") - }, - ) - stopLoading() - } - - private fun stopLoading() { - observableProperties.loading.set(false) - editor.project?.let { - CompletionProgressNotifier.update(it, false) - } - } - - private fun updateHighlighter(editor: Editor) { - cleanupHighlighter() - - val document = editor.document - val lineNumber = document.getLineNumber(editor.caretModel.offset) - currentHighlighter = editor.markupModel.addRangeHighlighter( - document.getLineStartOffset(lineNumber), - document.getLineEndOffset(lineNumber), - HighlighterLayer.SELECTION - 1, - TextAttributes().apply { - effectType = EffectType.BOXED - effectColor = - JBColor.namedColor("PsiViewer.referenceHighlightColor", 0x6A7B15) - errorStripeColor = effectColor - }, - HighlighterTargetArea.EXACT_RANGE - ) - } - - private fun handleDiff(message: String) { - val document = editor.document - val startOffset = selectionTextRange.startOffset - val endOffset = selectionTextRange.endOffset - runUndoTransparentWriteAction { - val remainingOriginalLength = endOffset - (startOffset + replacedLength) - if (remainingOriginalLength > 0) { - document.replaceString( - startOffset + replacedLength, - startOffset + replacedLength + minOf( - message.length, - remainingOriginalLength - ), - StringUtil.convertLineSeparators(message) - ) - } else { - document.insertString(startOffset + replacedLength, message) - } - } - - replacedLength += message.length - editor.caretModel.moveToOffset(startOffset + replacedLength) - updateHighlighter(editor) - } - - private fun cleanupAndFormat() { - val project = editor.project ?: return - val document = editor.document - val psiDocumentManager = project.service() - val psiFile = psiDocumentManager.getPsiFile(document) ?: return - val startOffset = selectionTextRange.startOffset - val endOffset = selectionTextRange.endOffset - val newEndOffset = startOffset + replacedLength - - runWriteCommandAction(project) { - if (newEndOffset < endOffset) { - document.deleteString(newEndOffset, endOffset) - } - psiDocumentManager.commitDocument(document) - project.service().reformatText( - psiFile, - listOf(TextRange(startOffset, newEndOffset)) - ) - } - - editor.caretModel.moveToOffset(newEndOffset) - psiDocumentManager.doPostponedOperationsAndUnblockDocument(document) - cleanupHighlighter() - } - - private fun cleanupHighlighter() { - currentHighlighter?.let { editor.markupModel.removeHighlighter(it) } - currentHighlighter = null - } -} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeSubmissionHandler.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeSubmissionHandler.kt deleted file mode 100644 index e21c24f5..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeSubmissionHandler.kt +++ /dev/null @@ -1,69 +0,0 @@ -package ee.carlrobert.codegpt.actions.editor - -import com.intellij.openapi.application.readAction -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.command.WriteCommandAction.runWriteCommandAction -import com.intellij.openapi.components.service -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.util.TextRange -import com.intellij.openapi.util.text.StringUtil -import com.jetbrains.rd.util.AtomicReference -import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier -import ee.carlrobert.codegpt.completions.CompletionRequestService -import ee.carlrobert.codegpt.completions.EditCodeCompletionParameters -import ee.carlrobert.codegpt.ui.ObservableProperties - -class EditCodeSubmissionHandler( - private val editor: Editor, - private val observableProperties: ObservableProperties, -) { - - private val previousSourceRef = AtomicReference(null) - - suspend fun handleSubmit(userPrompt: String) { - editor.project?.let { - CompletionProgressNotifier.update(it, true) - } - - observableProperties.loading.set(true) - observableProperties.submitted.set(true) - - previousSourceRef.getAndSet(editor.document.text) - val (selectionTextRange, selectedText) = readAction { - editor.selectionModel.run { - Pair( - TextRange(selectionStart, selectionEnd), - editor.selectionModel.selectedText ?: "" - ) - } - } - runInEdt { editor.selectionModel.removeSelection() } - - service().getEditCodeCompletionAsync( - EditCodeCompletionParameters(userPrompt, selectedText), - EditCodeCompletionListener(editor, observableProperties, selectionTextRange) - ) - } - - fun handleAccept() { - observableProperties.accepted.set(true) - observableProperties.submitted.set(false) - } - - fun handleReject() { - val prevSource = previousSourceRef.get() - if (!observableProperties.accepted.get() && prevSource != null) { - revertAllChanges(prevSource) - } - } - - private fun revertAllChanges(prevSource: String) { - runWriteCommandAction(editor.project) { - editor.document.replaceString( - 0, - editor.document.textLength, - StringUtil.convertLineSeparators(prevSource) - ) - } - } -} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditorComponentInlaysManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditorComponentInlaysManager.kt new file mode 100644 index 00000000..df0a16f6 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditorComponentInlaysManager.kt @@ -0,0 +1,166 @@ +package ee.carlrobert.codegpt.actions.editor + +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.ex.util.EditorUtil +import com.intellij.openapi.editor.impl.EditorEmbeddedComponentManager +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.openapi.editor.impl.view.FontLayoutService +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import java.awt.Font +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import javax.swing.JComponent +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +/** + * Manages embedded component inlays in the main editor. + */ +class EditorComponentInlaysManager(val editor: EditorImpl) : Disposable { + + private val managedInlays = mutableMapOf() + private val editorWidthWatcher = EditorTextWidthWatcher() + + init { + editor.scrollPane.viewport.addComponentListener(editorWidthWatcher) + Disposer.register(this, Disposable { + editor.scrollPane.viewport.removeComponentListener(editorWidthWatcher) + }) + + EditorUtil.disposeWithEditor(editor, this) + } + + @RequiresEdt + fun insert(lineIndex: Int, component: JComponent, showAbove: Boolean = false): Disposable? { + val wrappedComponent = ComponentWrapper(component) + val offset = if (lineIndex < editor.document.lineCount) { + editor.document.getLineStartOffset(lineIndex) + } else { + editor.document.textLength + } + + val result = EditorEmbeddedComponentManager.getInstance() + .addComponent( + editor, wrappedComponent, + EditorEmbeddedComponentManager.Properties( + EditorEmbeddedComponentManager.ResizePolicy.none(), + null, + true, + showAbove, + 0, + offset + ) + ) + + return result?.also { + managedInlays[wrappedComponent] = it + Disposer.register(it, Disposable { + managedInlays.remove(wrappedComponent) + }) + } ?: run { + null + } + } + + private inner class ComponentWrapper(val component: JComponent) : JBScrollPane(component) { + init { + isOpaque = false + viewport.isOpaque = false + + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + + horizontalScrollBarPolicy = HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBar.preferredSize = Dimension(0, 0) + setViewportView(component) + + component.addComponentListener(object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) = + dispatchEvent(ComponentEvent(component, ComponentEvent.COMPONENT_RESIZED)) + }) + } + + override fun getPreferredSize(): Dimension { + return Dimension(editor.contentComponent.width, component.preferredSize.height) + } + } + + override fun dispose() { + managedInlays.values.forEach(Disposer::dispose) + } + + private inner class EditorTextWidthWatcher : ComponentAdapter() { + + var editorTextWidth: Int = 0 + + private val maximumEditorTextWidth: Int + private val verticalScrollbarFlipped: Boolean + + init { + val metrics = editor.getFontMetrics(Font.PLAIN) + val spaceWidth = FontLayoutService.getInstance().charWidth2D(metrics, ' '.code) + maximumEditorTextWidth = + ceil(spaceWidth * (editor.settings.getRightMargin(editor.project)) - 4).toInt() + + val scrollbarFlip = editor.scrollPane.getClientProperty(JBScrollPane.Flip::class.java) + verticalScrollbarFlipped = + scrollbarFlip == JBScrollPane.Flip.HORIZONTAL || scrollbarFlip == JBScrollPane.Flip.BOTH + } + + override fun componentResized(e: ComponentEvent) = updateWidthForAllInlays() + override fun componentHidden(e: ComponentEvent) = updateWidthForAllInlays() + override fun componentShown(e: ComponentEvent) = updateWidthForAllInlays() + + private fun updateWidthForAllInlays() { + val newWidth = calcWidth() + if (editorTextWidth == newWidth) return + editorTextWidth = newWidth + + managedInlays.keys.forEach { + it.dispatchEvent(ComponentEvent(it, ComponentEvent.COMPONENT_RESIZED)) + it.invalidate() + } + } + + private fun calcWidth(): Int { + val visibleEditorTextWidth = + editor.scrollPane.viewport.width - getVerticalScrollbarWidth() - getGutterTextGap() + return min(max(visibleEditorTextWidth, 0), maximumEditorTextWidth) + } + + private fun getVerticalScrollbarWidth(): Int { + val width = editor.scrollPane.verticalScrollBar.width + return if (!verticalScrollbarFlipped) width * 2 else width + } + + private fun getGutterTextGap(): Int { + return if (verticalScrollbarFlipped) { + val gutter = (editor as EditorEx).gutterComponentEx + gutter.width - gutter.whitespaceSeparatorOffset + } else 0 + } + } + + companion object { + val INLAYS_KEY: Key = Key.create("InlineEditInlaysManager") + + fun from(editor: Editor): EditorComponentInlaysManager { + return synchronized(editor) { + val manager = editor.getUserData(INLAYS_KEY) + if (manager == null) { + val newManager = EditorComponentInlaysManager(editor as EditorImpl) + editor.putUserData(INLAYS_KEY, newManager) + newManager + } else manager + } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/InlineEditAction.kt similarity index 54% rename from src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeAction.kt rename to src/main/kotlin/ee/carlrobert/codegpt/actions/editor/InlineEditAction.kt index 2d60a819..0cf3cbe6 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeAction.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/InlineEditAction.kt @@ -4,17 +4,17 @@ import com.intellij.openapi.application.runInEdt import com.intellij.openapi.editor.Editor import com.intellij.openapi.project.Project import ee.carlrobert.codegpt.Icons -import ee.carlrobert.codegpt.ui.EditCodePopover +import ee.carlrobert.codegpt.ui.InlineEditPopover import javax.swing.Icon -open class EditCodeAction(icon: Icon) : BaseEditorAction(icon) { +open class InlineEditAction(icon: Icon) : BaseEditorAction(icon) { override fun actionPerformed(project: Project, editor: Editor, selectedText: String) { runInEdt { - EditCodePopover(editor).show() + InlineEditPopover(editor).show() } } } -class EditCodeFloatingMenuAction : EditCodeAction(Icons.DefaultSmall) +class InlineEditFloatingMenuAction : InlineEditAction(Icons.DefaultSmall) -class EditCodeContextMenuAction : EditCodeAction(Icons.Sparkle) +class InlineEditContextMenuAction : InlineEditAction(Icons.Sparkle) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/ShowEditorActionGroupAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/ShowEditorActionGroupAction.kt index cc87d8cd..b88132da 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/ShowEditorActionGroupAction.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/ShowEditorActionGroupAction.kt @@ -14,9 +14,11 @@ class ShowEditorActionGroupAction : AnAction() { override fun actionPerformed(e: AnActionEvent) { val actionManager = ActionManager.getInstance() val actionGroup = actionManager.getAction("action.editor.group.EditorActionGroup") - JBPopupFactory.getInstance().createActionGroupPopup( - CodeGPTBundle.get("project.label"), (actionGroup as ActionGroup), e.dataContext, - ActionSelectionAid.ALPHA_NUMBERING, true - ).show(RelativePoint(MouseInfo.getPointerInfo().location)) + JBPopupFactory.getInstance() + .createActionGroupPopup( + CodeGPTBundle.get("project.label"), (actionGroup as ActionGroup), e.dataContext, + ActionSelectionAid.ALPHA_NUMBERING, true + ) + .show(RelativePoint(MouseInfo.getPointerInfo().location)) } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt index 3655e553..4c872e05 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt @@ -129,11 +129,17 @@ data class AutoApplyParameters( val featureType: FeatureType = FeatureType.AUTO_APPLY ) -data class EditCodeCompletionParameters( +data class InlineEditCompletionParameters( val prompt: String, - val selectedText: String, - val chatMode: ChatMode = ChatMode.EDIT, - val featureType: FeatureType = FeatureType.EDIT_CODE + val selectedText: String? = null, + val filePath: String? = null, + val fileExtension: String? = null, + val projectBasePath: String? = null, + val referencedFiles: List? = null, + val gitDiff: String? = null, + val conversation: Conversation? = null, + val conversationHistory: List? = null, + val diagnosticsInfo: String? = null ) : CompletionParameters data class ImageDetails( @@ -155,4 +161,4 @@ data class ImageDetails( result = 31 * result + data.contentHashCode() return result } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt index bf3ec4b3..ceb51a11 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestFactory.kt @@ -1,21 +1,22 @@ package ee.carlrobert.codegpt.completions import com.intellij.openapi.components.service +import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.readText import ee.carlrobert.codegpt.completions.factory.* import ee.carlrobert.codegpt.psistructure.ClassStructureSerializer import ee.carlrobert.codegpt.settings.prompts.CoreActionsState import ee.carlrobert.codegpt.settings.prompts.FilteredPromptsService import ee.carlrobert.codegpt.settings.prompts.PromptsSettings -import ee.carlrobert.codegpt.settings.service.ServiceType -import ee.carlrobert.codegpt.settings.service.ModelSelectionService import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.settings.service.ModelSelectionService +import ee.carlrobert.codegpt.settings.service.ServiceType import ee.carlrobert.codegpt.util.file.FileUtil import ee.carlrobert.llm.completion.CompletionRequest interface CompletionRequestFactory { fun createChatRequest(params: ChatCompletionParameters): CompletionRequest - fun createEditCodeRequest(params: EditCodeCompletionParameters): CompletionRequest + fun createInlineEditRequest(params: InlineEditCompletionParameters): CompletionRequest fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest fun createCommitMessageRequest(params: CommitMessageCompletionParameters): CompletionRequest fun createLookupRequest(params: LookupCompletionParameters): CompletionRequest @@ -49,15 +50,125 @@ abstract class BaseRequestFactory : CompletionRequestFactory { private const val AUTO_APPLY_MAX_TOKENS = 8192 private const val DEFAULT_MAX_TOKENS = 4096 } - override fun createEditCodeRequest(params: EditCodeCompletionParameters): CompletionRequest { - val prompt = "Code to modify:\n${params.selectedText}\n\nInstructions: ${params.prompt}" + + data class InlineEditPrompts(val systemPrompt: String, val userPrompt: String) + + protected fun prepareInlineEditPrompts(params: InlineEditCompletionParameters): InlineEditPrompts { + val language = params.fileExtension ?: "txt" + val filePath = params.filePath ?: "untitled" + var systemPrompt = + service().state.coreActions.editCode.instructions + ?: CoreActionsState.DEFAULT_EDIT_CODE_PROMPT + + + if (params.projectBasePath != null) { + val projectContext = + "Project Context:\nProject root: ${params.projectBasePath}\nAll file paths should be relative to this project root." + systemPrompt = systemPrompt.replace("{{PROJECT_CONTEXT}}", projectContext) + } else { + systemPrompt = systemPrompt.replace("\n{{PROJECT_CONTEXT}}\n", "") + } + + val currentFileContent = try { + params.filePath?.let { LocalFileSystem.getInstance().findFileByPath(it)?.readText() } + } catch (_: Throwable) { + null + } + val currentFileBlock = buildString { + append("```$language:$filePath\n") + append(currentFileContent ?: "") + append("\n```") + } + systemPrompt = systemPrompt.replace("{{CURRENT_FILE_CONTEXT}}", currentFileBlock) + + val externalContext = buildString { + val currentPath = filePath + val unique = mutableSetOf() + val hasRefs = params.referencedFiles + ?.filter { it.filePath != currentPath } + ?.any { !it.fileContent.isNullOrBlank() } == true + + if (hasRefs) { + append("\n\n### Referenced Files") + params.referencedFiles + .filter { it.filePath != currentPath } + .forEach { + if (!it.fileContent.isNullOrBlank() && unique.add(it.filePath)) { + append("\n\n```${it.fileExtension}:${it.filePath}\n") + append(it.fileContent) + append("\n```") + } + } + } + + if (!params.gitDiff.isNullOrBlank()) { + append("\n\n### Git Diff\n\n") + append("```diff\n${params.gitDiff}\n```") + } + + if (!params.conversationHistory.isNullOrEmpty()) { + append("\n\n### Conversation History\n") + params.conversationHistory.forEach { conversation -> + conversation.messages.forEach { message -> + if (!message.prompt.isNullOrBlank()) { + append("\n**User:** ${message.prompt.trim()}") + } + if (!message.response.isNullOrBlank()) { + append("\n**Assistant:** ${message.response.trim()}") + } + } + } + } + + if (!params.diagnosticsInfo.isNullOrBlank()) { + append("\n\n### Diagnostics\n") + append(params.diagnosticsInfo) + } + } + systemPrompt = if (externalContext.isEmpty()) { + systemPrompt.replace( + "{{EXTERNAL_CONTEXT}}", + "## External Context\n\nNo external context selected." + ) + } else { + systemPrompt.replace( + "{{EXTERNAL_CONTEXT}}", + "## External Context$externalContext" + ) + } + + val userPrompt = buildString { + if (!params.selectedText.isNullOrBlank()) { + append("Selected code:\n") + append("```$language\n") + append(params.selectedText) + append("\n```\n\n") + } + append("Request: ${params.prompt}") + } + + return InlineEditPrompts(systemPrompt, userPrompt) + } + + override fun createInlineEditRequest(params: InlineEditCompletionParameters): CompletionRequest { + val prepared = prepareInlineEditPrompts(params) return createBasicCompletionRequest( - service().getFilteredEditCodePrompt(params.chatMode), prompt, AUTO_APPLY_MAX_TOKENS, true, FeatureType.EDIT_CODE + prepared.systemPrompt, + prepared.userPrompt, + AUTO_APPLY_MAX_TOKENS, + true, + FeatureType.INLINE_EDIT ) } override fun createCommitMessageRequest(params: CommitMessageCompletionParameters): CompletionRequest { - return createBasicCompletionRequest(params.systemPrompt, params.gitDiff, 512, true, FeatureType.COMMIT_MESSAGE) + return createBasicCompletionRequest( + params.systemPrompt, + params.gitDiff, + 512, + true, + FeatureType.COMMIT_MESSAGE + ) } override fun createLookupRequest(params: LookupCompletionParameters): CompletionRequest { @@ -74,16 +185,26 @@ abstract class BaseRequestFactory : CompletionRequestFactory { override fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest { val destination = params.destination val language = FileUtil.getFileExtension(destination.path) - + val formattedSource = CompletionRequestUtil.formatCodeWithLanguage(params.source, language) - val formattedDestination = CompletionRequestUtil.formatCode(destination.readText(), destination.path) - - val systemPromptTemplate = service().getFilteredAutoApplyPrompt(params.chatMode, params.destination) + val formattedDestination = + CompletionRequestUtil.formatCode(destination.readText(), destination.path) + + val systemPromptTemplate = service().getFilteredAutoApplyPrompt( + params.chatMode, + params.destination + ) val systemPrompt = systemPromptTemplate .replace("{{changes_to_merge}}", formattedSource) .replace("{{destination_file}}", formattedDestination) - - return createBasicCompletionRequest(systemPrompt, "Merge the following changes to the destination file.", AUTO_APPLY_MAX_TOKENS, true, FeatureType.AUTO_APPLY) + + return createBasicCompletionRequest( + systemPrompt, + "Merge the following changes to the destination file.", + AUTO_APPLY_MAX_TOKENS, + true, + FeatureType.AUTO_APPLY + ) } abstract fun createBasicCompletionRequest( diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CodeGPTRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CodeGPTRequestFactory.kt index b9ee3c3f..9266014d 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CodeGPTRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CodeGPTRequestFactory.kt @@ -7,16 +7,17 @@ import com.intellij.openapi.vfs.VirtualFile import ee.carlrobert.codegpt.CodeGPTPlugin import ee.carlrobert.codegpt.completions.BaseRequestFactory import ee.carlrobert.codegpt.completions.ChatCompletionParameters +import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters import ee.carlrobert.codegpt.completions.factory.OpenAIRequestFactory.Companion.buildOpenAIMessages import ee.carlrobert.codegpt.psistructure.ClassStructureSerializer import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings -import ee.carlrobert.codegpt.settings.models.ModelSettings import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ModelSelectionService import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor import ee.carlrobert.codegpt.util.file.FileUtil import ee.carlrobert.llm.client.codegpt.request.chat.* import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage +import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionMessage class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructureSerializer) : BaseRequestFactory() { @@ -130,6 +131,28 @@ class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructure .build() } + override fun createInlineEditRequest(params: InlineEditCompletionParameters): ChatCompletionRequest { + val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.INLINE_EDIT) + val prepared = prepareInlineEditPrompts(params) + val messages: MutableList = + OpenAIRequestFactory.buildInlineEditMessages(prepared, params.conversation) + + if (model == "o4-mini") { + val collapsed = messages.joinToString("\n\n") { msg -> + when (msg) { + is OpenAIChatCompletionStandardMessage -> msg.content + else -> "" + } + } + return buildBasicO1Request(model, collapsed, systemPrompt = "", maxCompletionTokens = 4096, stream = true) + } + + return ChatCompletionRequest.Builder(messages) + .setModel(model) + .setStream(true) + .build() + } + private fun buildBasicO1Request( model: String, prompt: String, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt index bcc52c02..d48ed6f4 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CustomOpenAIRequestFactory.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.intellij.openapi.components.service import ee.carlrobert.codegpt.completions.BaseRequestFactory import ee.carlrobert.codegpt.completions.ChatCompletionParameters +import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey import ee.carlrobert.codegpt.credentials.CredentialsStore.getCredential import ee.carlrobert.codegpt.settings.service.FeatureType @@ -58,6 +59,19 @@ class CustomOpenAIRequestFactory : BaseRequestFactory() { return CustomOpenAIRequest(request) } + override fun createInlineEditRequest(params: InlineEditCompletionParameters): CompletionRequest { + val service = service().customServiceStateForFeatureType(FeatureType.INLINE_EDIT) + val prepared = prepareInlineEditPrompts(params) + val messages = OpenAIRequestFactory.buildInlineEditMessages(prepared, params.conversation) + val request = buildCustomOpenAIChatCompletionRequest( + service.chatCompletionSettings, + messages, + true, + getCredential(CredentialKey.CustomServiceApiKey(service.name.orEmpty())) + ) + return CustomOpenAIRequest(request) + } + companion object { fun buildCustomOpenAICompletionRequest( context: String, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/LlamaRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/LlamaRequestFactory.kt index 47840900..4b941575 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/LlamaRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/LlamaRequestFactory.kt @@ -2,6 +2,7 @@ package ee.carlrobert.codegpt.completions.factory import com.intellij.openapi.components.service import ee.carlrobert.codegpt.completions.BaseRequestFactory +import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters import ee.carlrobert.codegpt.completions.ChatCompletionParameters import ee.carlrobert.codegpt.completions.ConversationType import ee.carlrobert.codegpt.completions.llama.LlamaModel @@ -12,6 +13,7 @@ import ee.carlrobert.codegpt.settings.prompts.PromptsSettings import ee.carlrobert.codegpt.settings.prompts.addProjectPath import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings +import ee.carlrobert.codegpt.conversations.message.Message import ee.carlrobert.llm.client.llama.completion.LlamaCompletionRequest class LlamaRequestFactory : BaseRequestFactory() { @@ -52,6 +54,14 @@ class LlamaRequestFactory : BaseRequestFactory() { return buildLlamaRequest(finalPrompt, emptyList(), stream) } + override fun createInlineEditRequest(params: InlineEditCompletionParameters): LlamaCompletionRequest { + val prepared = prepareInlineEditPrompts(params) + val promptTemplate = getPromptTemplate(FeatureType.INLINE_EDIT) + val history = params.conversation?.messages?.filter { !it.response.isNullOrBlank() } ?: listOf() + val finalPrompt = promptTemplate.buildPrompt(prepared.systemPrompt, prepared.userPrompt, history) + return buildLlamaRequest(finalPrompt, emptyList(), stream = true) + } + private fun getPromptTemplate(featureType: FeatureType? = null): PromptTemplate { val settings = service().state return if (settings.isUseCustomModel) 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 4cc136a7..cfb9a817 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt @@ -25,7 +25,7 @@ import java.io.IOException import java.nio.file.Files import java.nio.file.Path -class OpenAIRequestFactory : CompletionRequestFactory { +class OpenAIRequestFactory : BaseRequestFactory() { override fun createChatRequest(params: ChatCompletionParameters): OpenAIChatCompletionRequest { val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.CHAT) @@ -47,15 +47,27 @@ class OpenAIRequestFactory : CompletionRequestFactory { return requestBuilder.build() } - override fun createEditCodeRequest(params: EditCodeCompletionParameters): OpenAIChatCompletionRequest { - val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.EDIT_CODE) - val prompt = "Code to modify:\n${params.selectedText}\n\nInstructions: ${params.prompt}" - val systemPrompt = - service().getFilteredEditCodePrompt(params.chatMode) - if (isReasoningModel(model)) { - return buildBasicO1Request(model, prompt, systemPrompt, stream = true) + override fun createInlineEditRequest(params: InlineEditCompletionParameters): OpenAIChatCompletionRequest { + val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.INLINE_EDIT) + val prepared = prepareInlineEditPrompts(params) + val messages = buildInlineEditMessages(prepared, params.conversation) + + val configuration = service().state + return if (isReasoningModel(model)) { + val collapsed = messages.joinToString("\n\n") { msg -> + when (msg) { + is OpenAIChatCompletionStandardMessage -> msg.content + else -> "" + } + } + buildBasicO1Request(model, collapsed, systemPrompt = "", stream = true) + } else { + OpenAIChatCompletionRequest.Builder(messages) + .setModel(model) + .setStream(true) + .setTemperature(configuration.temperature.toDouble()) + .build() } - return createBasicCompletionRequest(systemPrompt, prompt, model, true) } override fun createAutoApplyRequest(params: AutoApplyParameters): CompletionRequest { @@ -78,6 +90,29 @@ class OpenAIRequestFactory : CompletionRequestFactory { return createBasicCompletionRequest(systemPrompt, prompt, model, true) } + override fun createBasicCompletionRequest( + systemPrompt: String, + userPrompt: String, + maxTokens: Int, + stream: Boolean, + featureType: FeatureType + ): CompletionRequest { + val model = ModelSelectionService.getInstance().getModelForFeature(featureType) + return if (isReasoningModel(model)) { + buildBasicO1Request(model, userPrompt, systemPrompt, maxCompletionTokens = maxTokens, stream = stream) + } else { + OpenAIChatCompletionRequest.Builder( + listOf( + OpenAIChatCompletionStandardMessage("system", systemPrompt), + OpenAIChatCompletionStandardMessage("user", userPrompt) + ) + ) + .setModel(model) + .setStream(stream) + .build() + } + } + override fun createCommitMessageRequest(params: CommitMessageCompletionParameters): OpenAIChatCompletionRequest { val model = ModelSelectionService.getInstance().getModelForFeature(FeatureType.COMMIT_MESSAGE) @@ -106,6 +141,22 @@ class OpenAIRequestFactory : CompletionRequestFactory { } companion object { + fun buildInlineEditMessages( + prepared: InlineEditPrompts, + conversation: Conversation? + ): MutableList { + val messages = mutableListOf() + messages.add(OpenAIChatCompletionStandardMessage("system", prepared.systemPrompt)) + conversation?.messages?.forEach { m -> + val p = m.prompt?.trim().orEmpty() + if (p.isNotEmpty()) messages.add(OpenAIChatCompletionStandardMessage("user", p)) + val r = m.response?.trim().orEmpty() + if (r.isNotEmpty()) messages.add(OpenAIChatCompletionStandardMessage("assistant", r)) + } + messages.add(OpenAIChatCompletionStandardMessage("user", prepared.userPrompt)) + return messages + } + fun isReasoningModel(model: String?) = listOf( O_4_MINI.code, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditConversationManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditConversationManager.kt new file mode 100644 index 00000000..daca7961 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditConversationManager.kt @@ -0,0 +1,47 @@ +package ee.carlrobert.codegpt.inlineedit + +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.util.Key +import ee.carlrobert.codegpt.conversations.Conversation +import ee.carlrobert.codegpt.conversations.message.Message + +/** + * Maintains an ephemeral inline-edit conversation (user/assistant turns) per editor. + * This mirrors how regular chat builds provider-friendly message histories. + */ +object InlineEditConversationManager { + + private val CONVERSATION_KEY: Key = Key.create("InlineEditConversation") + + fun getOrCreate(editor: EditorEx): Conversation { + val existing = editor.getUserData(CONVERSATION_KEY) + if (existing != null) return existing + val conversation = Conversation().apply { + projectPath = editor.project?.basePath + title = "Inline Edit (${editor.virtualFile?.name ?: "untitled"})" + } + editor.putUserData(CONVERSATION_KEY, conversation) + return conversation + } + + fun addUserMessage(editor: EditorEx, prompt: String): Message { + val message = Message(prompt) + getOrCreate(editor).addMessage(message) + return message + } + + fun addAssistantResponse(message: Message, content: String) { + message.response = content + } + + fun clear(editor: EditorEx) { + editor.putUserData(CONVERSATION_KEY, null) + } + + fun moveConversation(source: EditorEx?, target: EditorEx?) { + if (source == null || target == null) return + val conversation = source.getUserData(CONVERSATION_KEY) ?: return + target.putUserData(CONVERSATION_KEY, conversation) + source.putUserData(CONVERSATION_KEY, null) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditFilter.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditFilter.kt new file mode 100644 index 00000000..f844d117 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditFilter.kt @@ -0,0 +1,51 @@ +package ee.carlrobert.codegpt.inlineedit + +import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchReplace + +internal data class FilterResult( + val pairs: List>, + val filteredCount: Int, + val stats: Map +) + +internal object InlineEditFilter { + + fun filterSegments( + currentPath: String?, + fileName: String?, + fileExt: String?, + segments: List + ): FilterResult { + fun fileMatches(path: String?): Boolean { + if (path.isNullOrBlank()) return true + if (currentPath == null) return false + val name = fileName ?: "" + return path == currentPath || path.endsWith("/" + name) || path == name + } + + val accepted = mutableListOf>() + var filtered = 0 + val reasons = mutableMapOf() + fun bump(reason: String) { + reasons[reason] = (reasons[reason] ?: 0) + 1 + } + + for (seg in segments) { + val search = seg.search + val replace = seg.replace + + if (!fileMatches(seg.filePath)) { + filtered++; bump("wrong-file"); continue + } + + val normalizedSearch = search.trim() + if (normalizedSearch.isBlank()) { + filtered++; bump("empty-search"); continue + } + + accepted.add(normalizedSearch to replace) + } + + return FilterResult(accepted, filtered, reasons) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlayRenderer.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlayRenderer.kt new file mode 100644 index 00000000..94ddfe58 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlayRenderer.kt @@ -0,0 +1,582 @@ +package ee.carlrobert.codegpt.inlineedit + +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.KeyboardShortcut +import com.intellij.openapi.actionSystem.Shortcut +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.openapi.editor.ex.EditorEx +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.fileTypes.SyntaxHighlighterFactory +import com.intellij.openapi.keymap.KeymapManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.intellij.ui.JBColor +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.CodeGPTKeys +import ee.carlrobert.codegpt.actions.editor.EditorComponentInlaysManager +import ee.carlrobert.codegpt.ui.InlineEditPopover +import ee.carlrobert.codegpt.ui.components.InlineEditChips +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Cursor +import java.awt.Dimension +import java.awt.FlowLayout +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.Insets +import java.awt.RenderingHints +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JTextPane +import javax.swing.KeyStroke +import javax.swing.text.SimpleAttributeSet +import javax.swing.text.StyleConstants +import kotlin.math.abs +import kotlin.math.max + +class InlineEditInlayRenderer( + private val editor: EditorEx, + private val project: Project +) : Disposable { + private val logger = Logger.getInstance(InlineEditInlayRenderer::class.java) + private var interactive: Boolean = true + + data class InlineChange( + val startOffset: Int, + val endOffset: Int, + val oldText: String, + val newText: String, + var deletionHighlighter: RangeHighlighter? = null, + var additionInlay: Disposable? = null, + var buttonInlay: Disposable? = null, + var isAccepted: Boolean = false, + var isRejected: Boolean = false + ) + + private val changes = mutableListOf() + private val allHighlighters = mutableListOf() + + private data class HunkUI( + val hunk: InlineEditSession.Hunk, + var deletionHighlighter: RangeHighlighter? = null, + var additionInlay: Disposable? = null, + var buttonInlay: Disposable? = null, + ) + + private val hunkUIs = mutableListOf() + + fun renderHunks(hunks: List) { + runInEdt { + hunks.forEach { renderHunk(it) } + showTopPanel() + } + } + + fun replaceHunks(hunks: List) { + runInEdt { + val prev = hunkUIs.toList() + prev.forEach { removeHunkUI(it) } + hunks.forEach { renderHunk(it) } + showTopPanel() + } + } + + private var topPanelDisposable: Disposable? = null + + private fun showTopPanel() { + topPanelDisposable?.dispose() + topPanelDisposable = null + } + + fun setInteractive(enabled: Boolean) { + interactive = enabled + } + + private fun renderHunk(hunk: InlineEditSession.Hunk) { + val start = hunk.baseMarker.startOffset + val end = hunk.baseMarker.endOffset + val baseLen = (end - start).coerceAtLeast(0) + + val deletion = if (baseLen > 0) highlightDeletion(start, end) else null + + val hasNew = hunk.proposedSlice.isNotBlank() + val showAbove = if (baseLen > 0 && hasNew) true else baseLen == 0 + val insertionOffset = if (baseLen == 0) start else end + val addition = if (hasNew) addInlayForAddition( + insertionOffset, + hunk.proposedSlice, + showAbove = showAbove, + onAccept = { + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + ?.accept(hunk) + }, + onReject = { + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + ?.reject(hunk) + } + ) else null + + val header = + if (!hasNew && baseLen > 0) addInlineButtons(start, hunk) else null + + hunkUIs.add(HunkUI(hunk, deletion, addition, header)) + } + + private fun highlightDeletion(startOffset: Int, endOffset: Int): RangeHighlighter? { + try { + val doc = editor.document + val boundedStart = startOffset.coerceIn(0, doc.textLength) + val inclusiveEnd = + (endOffset - 1).coerceAtLeast(boundedStart).coerceAtMost(doc.textLength - 1) + + val startLine = doc.getLineNumber(boundedStart) + val endLine = doc.getLineNumber(inclusiveEnd) + val lineStart = doc.getLineStartOffset(startLine) + val lineEnd = doc.getLineEndOffset(endLine) + + val attributes = TextAttributes().apply { + backgroundColor = JBColor( + Color(255, 220, 220, 60), + Color(80, 40, 40, 80) + ) + foregroundColor = null + effectType = null + effectColor = null + } + + val highlighter = editor.markupModel.addRangeHighlighter( + lineStart, + lineEnd, + HighlighterLayer.SELECTION - 1, + attributes, + HighlighterTargetArea.LINES_IN_RANGE + ) + + allHighlighters.add(highlighter) + return highlighter + } catch (e: Exception) { + logger.error("Error creating deletion highlight", e) + throw e + } + } + + private fun addInlayForAddition( + offset: Int, + newText: String, + showAbove: Boolean = true, + onAccept: (() -> Unit)? = null, + onReject: (() -> Unit)? = null, + ): Disposable? { + try { + val inlaysManager = EditorComponentInlaysManager.Companion.from(editor) + val leftInset = computeLeftInsetForOffset(offset) + val component = createAdditionComponent(newText, onAccept, onReject, leftInset) + val lineNumber = editor.document.getLineNumber(offset) + return inlaysManager.insert(lineNumber, component, showAbove) + } catch (e: Exception) { + logger.error("Error creating addition inlay", e) + throw e + } + } + + private fun createAdditionComponent( + text: String, + onAccept: (() -> Unit)?, + onReject: (() -> Unit)?, + leftInset: Int, + ): JComponent { + val displayText = text.trimEnd('\n', '\r') + + val panel = JPanel(BorderLayout()).apply { + isOpaque = true + background = JBColor(Color(0, 128, 0, 28), Color(0, 128, 0, 36)) + border = JBUI.Borders.empty(0, leftInset, 0, 0) + } + + if (onAccept != null || onReject != null) { + val header = JPanel(FlowLayout(FlowLayout.RIGHT, 6, 0)).apply { + isOpaque = false + } + + fun badge(textLabel: String, bg: Color, onClick: () -> Unit): JComponent { + return object : JComponent() { + init { + cursor = Cursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent?) { + onClick() + } + }) + } + + override fun getPreferredSize(): Dimension { + val fm = getFontMetrics(font) + val w = max(34, fm.stringWidth(textLabel) + 14) + return Dimension(w, 18) + } + + override fun paintComponent(g: Graphics) { + val g2 = g as Graphics2D + g2.setRenderingHint( + RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON + ) + g2.color = Color(bg.red, bg.green, bg.blue, 200) + g2.fillRoundRect(0, 0, width - 1, height - 1, 8, 8) + g2.color = Color.WHITE + val fm = g2.fontMetrics + val tx = (width - fm.stringWidth(textLabel)) / 2 + val ty = (height - fm.height) / 2 + fm.ascent + g2.drawString(textLabel, tx, ty) + } + } + } + + val acceptLabel = formatShortcutLabel( + actionId = "CodeGPT.AcceptCurrentInlineEdit", + fallback = if (SystemInfo.isMac) "⌘Y" else "Ctrl+Y" + ) + val rejectLabel = formatShortcutLabel( + actionId = "CodeGPT.RejectCurrentInlineEdit", + fallback = if (SystemInfo.isMac) "⌘N" else "Ctrl+N" + ) + + onAccept?.let { header.add(badge(acceptLabel, Color(0, 153, 0), it)) } + onReject?.let { + header.add( + badge( + rejectLabel, + JBColor(Color(0xD0, 0x36, 0x36), Color(0xD0, 0x36, 0x36)), + it + ) + ) + } + panel.add(header, BorderLayout.NORTH) + } + + val textPane = JTextPane().apply { + isEditable = false + isOpaque = false + font = editor.colorsScheme.getFont(EditorFontType.PLAIN) + foreground = editor.colorsScheme.defaultForeground + border = null + margin = Insets(0, 0, 0, 0) + } + applySyntaxColors(displayText, textPane) + panel.add(textPane, BorderLayout.CENTER) + return panel + } + + private fun formatShortcutLabel(actionId: String, fallback: String): String { + return try { + val keymap = KeymapManager.getInstance().activeKeymap + val shortcuts = keymap.getShortcuts(actionId) + val preferred = when (actionId) { + "CodeGPT.AcceptCurrentInlineEdit" -> preferredShortcut(shortcuts, KeyEvent.VK_Y) + "CodeGPT.RejectCurrentInlineEdit" -> preferredShortcut(shortcuts, KeyEvent.VK_N) + else -> shortcuts.firstOrNull() + } + val ks = (preferred as? KeyboardShortcut)?.firstKeyStroke + if (ks != null) keyStrokeToLabel(ks) else fallback + } catch (_: Exception) { + fallback + } + } + + private fun preferredShortcut(shortcuts: Array, keyCode: Int): Shortcut? { + val macKs = KeyStroke.getKeyStroke(keyCode, InputEvent.META_DOWN_MASK) + val winKs = KeyStroke.getKeyStroke(keyCode, InputEvent.CTRL_DOWN_MASK) + return shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == macKs } + ?: shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == winKs } + ?: shortcuts.firstOrNull() + } + + private fun keyStrokeToLabel(ks: KeyStroke): String { + return if (SystemInfo.isMac) { + buildString { + if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) append('⌘') + if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) append('⇧') + if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) append('⌥') + if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) append('⌃') + append(KeyEvent.getKeyText(ks.keyCode).uppercase()) + } + } else { + val parts = mutableListOf() + if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) parts.add("Ctrl") + if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) parts.add("Shift") + if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) parts.add("Alt") + if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) parts.add("Meta") + parts.add(KeyEvent.getKeyText(ks.keyCode).uppercase()) + parts.joinToString("+") + } + } + + private fun applySyntaxColors(text: String, pane: JTextPane) { + val fileType = editor.virtualFile?.fileType + if (fileType == null) { + pane.text = text; return + } + val highlighter = + SyntaxHighlighterFactory.getSyntaxHighlighter(fileType, project, editor.virtualFile) + val lexer = highlighter?.highlightingLexer ?: return + lexer.start(text) + val doc = pane.styledDocument + val scheme = EditorColorsManager.getInstance().globalScheme + while (lexer.tokenType != null) { + val start = lexer.tokenStart + val end = lexer.tokenEnd + val segment = text.substring(start, end) + val keys = highlighter.getTokenHighlights(lexer.tokenType) + val attrs = keys.firstOrNull()?.let { scheme.getAttributes(it) } + val style = SimpleAttributeSet() + val fg = attrs?.foregroundColor ?: editor.colorsScheme.defaultForeground + StyleConstants.setForeground(style, fg) + StyleConstants.setFontFamily( + style, + editor.colorsScheme.getFont(EditorFontType.PLAIN).family + ) + StyleConstants.setFontSize( + style, + editor.colorsScheme.getFont(EditorFontType.PLAIN).size + ) + doc.insertString(doc.length, segment, style) + lexer.advance() + } + } + + private fun addInlineButtons( + offset: Int, + hunk: InlineEditSession.Hunk, + ): Disposable? { + if (!interactive) return null + try { + val inlaysManager = EditorComponentInlaysManager.Companion.from(editor) + val leftInset = computeLeftInsetForOffset(offset) + val panel = createButtonPanel(hunk, leftInset) + val lineNumber = editor.document.getLineNumber(offset) + return inlaysManager.insert(lineNumber, panel, true) + } catch (e: Exception) { + logger.error("Error creating hunk button inlay", e) + throw e + } + } + + private fun createButtonPanel( + hunk: InlineEditSession.Hunk, + leftInset: Int = 0 + ): JComponent { + val container = JPanel(BorderLayout()).apply { + isOpaque = background != null + border = JBUI.Borders.empty(0, leftInset, 0, 0) + } + val row = JPanel(FlowLayout(FlowLayout.RIGHT, 6, 0)).apply { + isOpaque = false + border = JBUI.Borders.empty(0, 8, 0, 0) + } + val accept = InlineEditChips.keyY { + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.accept(hunk) + } + val reject = InlineEditChips.keyN { + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.reject(hunk) + } + row.add(accept) + row.add(reject) + container.add(row, BorderLayout.EAST) + return container + } + + private fun computeLeftInsetForOffset(offset: Int): Int { + return try { + val line = editor.document.getLineNumber(offset) + val lineStart = editor.document.getLineStartOffset(line) + val x = editor.offsetToXY(lineStart).x + val gutter = editor.gutterComponentEx.width + (x - gutter).coerceAtLeast(0) + } catch (e: Exception) { + logger.error("Error computing left inset for offset $offset", e) + 0 + } + } + + private fun acceptChange(change: InlineChange) { + if (change.isAccepted || change.isRejected) return + + runInEdt { + WriteCommandAction.runWriteCommandAction( + project, + "Accept Inline Edit Change", + "InlineEdit", + { + try { + editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + ?.markChangesAsAccepted() + editor.document.replaceString( + change.startOffset, + change.endOffset, + change.newText + ) + change.copy(isAccepted = true) + removeChangeVisuals(change) + } catch (e: Exception) { + logger.debug("Error accepting change", e) + } + }) + if (changes.isEmpty()) { + editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + ?.setInlineEditControlsVisible(false) + } + } + } + + private fun rejectChange(change: InlineChange) { + if (change.isAccepted || change.isRejected) return + + runInEdt { + change.copy(isRejected = true) + removeChangeVisuals(change) + if (changes.isEmpty()) { + editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + ?.setInlineEditControlsVisible(false) + } + } + } + + private fun removeHunkUI(ui: HunkUI) { + ui.deletionHighlighter?.let { editor.markupModel.removeHighlighter(it) } + ui.additionInlay?.dispose() + ui.buttonInlay?.dispose() + hunkUIs.remove(ui) + } + + private fun removeChangeVisuals(change: InlineChange) { + change.deletionHighlighter?.let { highlighter -> + editor.markupModel.removeHighlighter(highlighter) + allHighlighters.remove(highlighter) + } + + change.additionInlay?.dispose() + change.buttonInlay?.dispose() + + changes.remove(change) + } + + fun acceptAll() { + val session = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + if (session != null) { + session.acceptAll() + return + } + val changesToAccept = changes.filter { !it.isAccepted && !it.isRejected } + .sortedByDescending { it.startOffset } + changesToAccept.forEach { acceptChange(it) } + } + + fun rejectAll() { + val session = + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + if (session != null) { + session.rejectAll() + return + } + val changesToReject = changes.filter { !it.isAccepted && !it.isRejected } + changesToReject.forEach { rejectChange(it) } + + editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + ?.triggerPromptRestoration() + } + + fun acceptNext() { + val session = + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + if (session != null) { + session.acceptNearestToCaret() + return + } + val nextChange = changes + .filter { !it.isAccepted && !it.isRejected } + .minByOrNull { it.startOffset } + nextChange?.let { acceptChange(it) } + } + + fun rejectNext() { + val session = + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + if (session != null) { + val caret = editor.caretModel.offset + val pending = hunkUIs.minByOrNull { abs(it.hunk.startOffset - caret) } + pending?.let { + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.reject(it.hunk) + } + return + } + val nextChange = changes + .filter { !it.isAccepted && !it.isRejected } + .minByOrNull { it.startOffset } + nextChange?.let { rejectChange(it) } + } + + override fun dispose() { + runInEdt { + disposeAllHighlighters() + disposeChanges() + disposeHunkUIs() + disposeTopPanel() + } + } + + private fun disposeAllHighlighters() { + allHighlighters.forEach { highlighter -> + try { + editor.markupModel.removeHighlighter(highlighter) + } catch (e: Exception) { + logger.debug("Error removing highlighter during disposal", e) + } + } + allHighlighters.clear() + } + + private fun disposeChanges() { + changes.forEach { change -> + try { + change.additionInlay?.dispose() + change.buttonInlay?.dispose() + } catch (e: Exception) { + logger.debug("Error disposing change inlays during disposal", e) + } + } + changes.clear() + } + + private fun disposeHunkUIs() { + hunkUIs.toList().forEach { ui -> + try { + ui.additionInlay?.dispose() + ui.buttonInlay?.dispose() + ui.deletionHighlighter?.let { editor.markupModel.removeHighlighter(it) } + } catch (e: Exception) { + logger.debug("Error disposing hunk UI during disposal", e) + } + } + hunkUIs.clear() + } + + private fun disposeTopPanel() { + try { + topPanelDisposable?.dispose() + } catch (e: Exception) { + logger.debug("Error disposing top panel", e) + } + topPanelDisposable = null + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditKeyEventDispatcher.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditKeyEventDispatcher.kt new file mode 100644 index 00000000..61231412 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditKeyEventDispatcher.kt @@ -0,0 +1,57 @@ +package ee.carlrobert.codegpt.inlineedit + +import com.intellij.ide.IdeEventQueue +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.KeyboardShortcut +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.keymap.KeymapManager +import com.intellij.openapi.project.Project +import java.awt.AWTEvent +import java.awt.event.KeyEvent +import javax.swing.KeyStroke + +/** + * Editor-scoped key dispatcher to reliably intercept Cmd/Ctrl+Y and Cmd/Ctrl+N + * for Inline Edit while the session is active and the editor has focus. + */ +class InlineEditKeyEventDispatcher( + private val project: Project, + private val editor: EditorEx, + private val onAccept: () -> Unit, + private val onReject: () -> Unit, +) : IdeEventQueue.EventDispatcher, Disposable { + + override fun dispatch(e: AWTEvent): Boolean { + if (e !is KeyEvent) return false + if (e.id != KeyEvent.KEY_PRESSED) return false + + val selected = FileEditorManager.getInstance(project).selectedTextEditor + if (selected !== editor) return false + + val ks = KeyStroke.getKeyStrokeForEvent(e) + val (acceptKeys, rejectKeys) = currentInlineEditKeystrokes() + if (rejectKeys.contains(ks)) { onReject(); e.consume(); return true } + if (acceptKeys.contains(ks)) { onAccept(); e.consume(); return true } + return false + } + + fun register(parent: Disposable) { + IdeEventQueue.Companion.getInstance().addDispatcher(this, parent) + } + + override fun dispose() { + } + + private fun currentInlineEditKeystrokes(): Pair, Set> { + val km = KeymapManager.getInstance().activeKeymap + fun firstKeyStrokes(actionId: String): Set = + km.getShortcuts(actionId) + .mapNotNull { (it as? KeyboardShortcut)?.firstKeyStroke } + .toSet() + + val accept = firstKeyStrokes("CodeGPT.AcceptCurrentInlineEdit") + val reject = firstKeyStrokes("CodeGPT.RejectCurrentInlineEdit") + return Pair(accept, reject) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditKeyListener.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditKeyListener.kt new file mode 100644 index 00000000..1bd6caf6 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditKeyListener.kt @@ -0,0 +1,158 @@ +package ee.carlrobert.codegpt.inlineedit + +import com.intellij.openapi.actionSystem.* +import com.intellij.openapi.editor.Editor +import ee.carlrobert.codegpt.CodeGPTKeys +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import javax.swing.KeyStroke + +/** + * Keyboard shortcuts for Inline Edit diff feature. + */ +class AcceptAllInlineEditAction : AnAction() { + init { + val keyStroke = KeyStroke.getKeyStroke( + KeyEvent.VK_ENTER, + InputEvent.META_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK + ) + shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null)) + } + + override fun actionPerformed(e: AnActionEvent) { + val editor = e.getData(CommonDataKeys.EDITOR) ?: return + val session = + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + if (session != null) { + session.acceptAll() + } else { + acceptAll(editor) + } + } + + override fun update(e: AnActionEvent) { + val project = e.project + val editor = e.getData(CommonDataKeys.EDITOR) + + e.presentation.isEnabledAndVisible = + project != null && editor != null && + (editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null + || hasActiveInlineEdit(editor)) + } + + companion object { + fun acceptAll(editor: Editor) { + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.acceptAll() + ?: editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptAll() + } + + fun hasActiveInlineEdit(editor: Editor): Boolean = + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) != null || + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null + } +} + +class RejectAllInlineEditAction : AnAction() { + init { + val keyStroke = KeyStroke.getKeyStroke( + KeyEvent.VK_BACK_SPACE, + InputEvent.META_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK + ) + shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null)) + } + + override fun actionPerformed(e: AnActionEvent) { + val editor = e.getData(CommonDataKeys.EDITOR) ?: return + val session = + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + if (session != null) { + session.rejectAll() + } else { + rejectAll(editor) + } + } + + override fun update(e: AnActionEvent) { + val project = e.project + val editor = e.getData(CommonDataKeys.EDITOR) + + e.presentation.isEnabledAndVisible = project != null && editor != null + && AcceptAllInlineEditAction.hasActiveInlineEdit(editor) + } + + companion object { + fun rejectAll(editor: Editor) { + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.rejectAll() + ?: editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.rejectAll() + } + } +} + +class RejectInlineEditAction : AnAction() { + init { + val keyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0) + shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null)) + } + + override fun actionPerformed(e: AnActionEvent) { + val editor = e.getData(CommonDataKeys.EDITOR) ?: return + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.rejectNext() + } + + override fun update(e: AnActionEvent) { + val project = e.project + val editor = e.getData(CommonDataKeys.EDITOR) + + e.presentation.isEnabledAndVisible = project != null && editor != null && + (editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null + || AcceptAllInlineEditAction.hasActiveInlineEdit(editor)) + } +} + +class AcceptCurrentInlineEditAction : AnAction() { + init { + val keyStroke = KeyStroke.getKeyStroke( + KeyEvent.VK_ENTER, + InputEvent.META_DOWN_MASK + ) + shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null)) + } + + override fun actionPerformed(e: AnActionEvent) { + val editor = e.getData(CommonDataKeys.EDITOR) ?: return + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptNext() + } + + override fun update(e: AnActionEvent) { + val project = e.project + val editor = e.getData(CommonDataKeys.EDITOR) + + e.presentation.isEnabledAndVisible = project != null && editor != null && + (editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null + || AcceptAllInlineEditAction.hasActiveInlineEdit(editor)) + } +} + +class RejectCurrentInlineEditAction : AnAction() { + init { + val keyStroke = KeyStroke.getKeyStroke( + KeyEvent.VK_BACK_SPACE, + InputEvent.META_DOWN_MASK + ) + shortcutSet = CustomShortcutSet(KeyboardShortcut(keyStroke, null)) + } + + override fun actionPerformed(e: AnActionEvent) { + val editor = e.getData(CommonDataKeys.EDITOR) ?: return + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.rejectNext() + } + + override fun update(e: AnActionEvent) { + val project = e.project + val editor = e.getData(CommonDataKeys.EDITOR) + + e.presentation.isEnabledAndVisible = project != null && editor != null && + (editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) != null + || AcceptAllInlineEditAction.hasActiveInlineEdit(editor)) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSearchReplaceListener.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSearchReplaceListener.kt new file mode 100644 index 00000000..3631dc67 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSearchReplaceListener.kt @@ -0,0 +1,842 @@ +package ee.carlrobert.codegpt.inlineedit + +import com.intellij.diff.DiffManager +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.ScrollType +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.markup.* +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.TextRange +import com.intellij.testFramework.LightVirtualFile +import com.intellij.ui.JBColor +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.Alarm +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.CodeGPTBundle +import ee.carlrobert.codegpt.CodeGPTKeys +import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier +import ee.carlrobert.codegpt.toolwindow.chat.parser.ReplaceWaiting +import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchReplace +import ee.carlrobert.codegpt.toolwindow.chat.parser.SearchWaiting +import ee.carlrobert.codegpt.toolwindow.chat.parser.SseMessageParser +import ee.carlrobert.codegpt.ui.InlineEditPopover +import ee.carlrobert.codegpt.ui.ObservableProperties +import ee.carlrobert.codegpt.ui.OverlayUtil +import ee.carlrobert.codegpt.util.EditorDiffUtil +import ee.carlrobert.llm.client.openai.completion.ErrorDetails +import ee.carlrobert.llm.completion.CompletionEventListener +import okhttp3.sse.EventSource +import java.awt.Color +import java.awt.Font +import java.util.* +import javax.swing.JComponent +import javax.swing.JLabel +import kotlin.concurrent.schedule + +/** + * Simplified completion listener for Inline Edit feature that handles SEARCH/REPLACE blocks + * for the current editor file only. + */ +class InlineEditSearchReplaceListener( + private val editor: EditorEx, + private val observableProperties: ObservableProperties, + private val selectionTextRange: TextRange, + private val requestId: Long, + private val userPrompt: String +) : CompletionEventListener { + + private val project: Project = editor.project!! + private val logger = Logger.getInstance(InlineEditSearchReplaceListener::class.java) + private val sseMessageParser = SseMessageParser() + private val accumulatedSearchReplaceSegments = mutableListOf() + private var isStreamingComplete = false + private var previewSessionStarted = false + private var hasReceivedMessage = false + + private val searchHighlighters = mutableListOf() + private var currentSearchPattern: String? = null + private val highlightDebounceAlarm = Alarm() + private var hintComponent: JComponent? = null + private val waitingAlarm = Alarm() + + private val SEARCH_HIGHLIGHT_COLOR = JBColor( + Color(255, 235, 59, 80), + Color(255, 235, 59, 60) + ) + + private val REPLACE_READY_COLOR = JBColor( + Color(59, 255, 149, 80), + Color(59, 255, 149, 60) + ) + + enum class HighlightState { + SEARCHING, + FOUND, + REPLACING, + ERROR + } + + sealed class ValidationResult { + object Success : ValidationResult() + data class Error(val message: String) : ValidationResult() + } + + private fun validateSearchReplacePattern(search: String, replace: String): ValidationResult { + val searchText = search.trim() + val replaceText = replace.trim() + + if (searchText == replaceText) { + return ValidationResult.Error("No changes: search and replace content are identical") + } + + if (searchText.isEmpty()) { + return ValidationResult.Error("Empty search pattern") + } + + return ValidationResult.Success + } + + init { + waitingAlarm.addRequest({ + if (!hasReceivedMessage) { + showInlineHint(CodeGPTBundle.get("inlineEdit.status.waiting")) + } + }, 1500) + } + + private fun deduplicateSearchReplaceBlocks(blocks: List>): List> { + val deduplicated = mutableListOf>() + val seenPatterns = mutableSetOf() + + for ((search, replace) in blocks) { + val normalizedSearch = search.trim() + + if (seenPatterns.contains(normalizedSearch)) continue + + if (normalizedSearch == replace.trim()) continue + + val isSubsumed = deduplicated.any { (existingSearch, _) -> + existingSearch.contains(normalizedSearch) || normalizedSearch.contains( + existingSearch.trim() + ) + } + + if (!isSubsumed) { + deduplicated.add(Pair(search, replace)) + seenPatterns.add(normalizedSearch) + } + } + + return deduplicated + } + + private fun applySimpleSearchReplace( + originalContent: String, + searchReplaceBlocks: List> + ): String { + val deduplicatedBlocks = deduplicateSearchReplaceBlocks(searchReplaceBlocks) + var currentContent = originalContent + var totalReplacements = 0 + + val docEol = if (originalContent.contains("\r\n")) "\r\n" else "\n" + + for ((search, replace) in deduplicatedBlocks) { + val searchText = search.trim().replace("\r\n", "\n").replace("\n", docEol) + val replaceText = replace.trim().replace("\r\n", "\n").replace("\n", docEol) + + if (searchText.isEmpty() && originalContent.isNotEmpty()) { + continue + } + + var replacementCount = + if (searchText.isEmpty()) 0 else currentContent.split(searchText).size - 1 + + if (replacementCount == 0 && search.contains("...")) { + val searchStart = searchText.lines().firstOrNull()?.trim() ?: "" + if (searchStart.isNotEmpty() && currentContent.contains(searchStart)) { + val startIndex = currentContent.indexOf(searchStart) + if (startIndex >= 0) { + val lines = currentContent.split(docEol) + val startLine = + currentContent.substring(0, startIndex).split(docEol).size - 1 + val searchLines = searchText.split(docEol).size + val endLine = minOf(startLine + searchLines, lines.size) + + val actualPattern = lines.subList(startLine, endLine).joinToString(docEol) + + if (currentContent.contains(actualPattern)) { + currentContent = currentContent.replace(actualPattern, replaceText) + totalReplacements++ + continue + } + } + } + } + + if (replacementCount == 0) { + val tokens = searchText.split(Regex("\\s+")).filter { it.isNotEmpty() } + if (tokens.isNotEmpty()) { + val pattern = tokens.joinToString("\\s+") { Regex.escape(it) } + val regex = + Regex(pattern, setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.MULTILINE)) + val match = regex.find(currentContent) + if (match != null) { + currentContent = currentContent.replaceRange(match.range, replaceText) + totalReplacements++ + continue + } + } + continue + } + + currentContent = currentContent.replace(searchText, replaceText) + totalReplacements += replacementCount + } + + return currentContent + } + + private fun isCurrentRequest(): Boolean { + val keyValue = editor.getUserData(REQUEST_ID_KEY) + return keyValue == null || keyValue == requestId + } + + private fun clearAllHighlights() { + searchHighlighters.forEach { + editor.markupModel.removeHighlighter(it) + } + searchHighlighters.clear() + } + + private fun createHighlighter( + range: TextRange, + color: JBColor, + tooltip: String + ): RangeHighlighter = editor.markupModel.addRangeHighlighter( + range.startOffset, + range.endOffset, + HighlighterLayer.SELECTION, + TextAttributes().apply { + backgroundColor = color + effectType = EffectType.ROUNDED_BOX + effectColor = color.darker() + }, + HighlighterTargetArea.EXACT_RANGE + ).apply { + errorStripeTooltip = tooltip + } + + private fun ensureVisible(offset: Int) { + val logicalPosition = editor.offsetToLogicalPosition(offset) + editor.scrollingModel.scrollTo(logicalPosition, ScrollType.MAKE_VISIBLE) + } + + private fun targetRange(): TextRange { + return if (selectionTextRange.startOffset < selectionTextRange.endOffset) { + selectionTextRange + } else { + TextRange(0, editor.document.textLength) + } + } + + override fun onMessage(message: String, eventSource: EventSource) { + if (!isCurrentRequest()) return + hasReceivedMessage = true + + sseMessageParser.parse(message).forEachIndexed { _, segment -> + when (segment) { + is SearchReplace -> { + runInEdt { clearAllHighlights() } + + when (val validation = + validateSearchReplacePattern(segment.search, segment.replace)) { + is ValidationResult.Success -> { + processCurrentFileSearchReplace(segment) + } + + is ValidationResult.Error -> { + runInEdt { + OverlayUtil.showNotification( + "Warning: ${validation.message}", + NotificationType.WARNING + ) + } + processCurrentFileSearchReplace(segment) + } + } + } + + is ReplaceWaiting -> { + if (currentSearchPattern != null) { + runInEdt { + highlightSearchRegions(currentSearchPattern!!, true) + updateHighlightState( + HighlightState.FOUND, + CodeGPTBundle.get("inlineEdit.status.preparingReplacement") + ) + } + } + } + + is SearchWaiting -> { + currentSearchPattern = segment.search + + if (segment.search.isNotEmpty()) { + runInEdt { + highlightSearchRegions(segment.search, false) + } + } + } + + else -> { + } + } + } + } + + override fun onComplete(completionMessageBuilder: StringBuilder) { + if (!isCurrentRequest()) return + + runInEdt { + isStreamingComplete = true + + clearAllHighlights() + highlightDebounceAlarm.cancelAllRequests() + hintComponent?.let { + editor.contentComponent.remove(it) + hintComponent = null + } + + val userMessage = InlineEditConversationManager.addUserMessage(editor, userPrompt) + val assistantSummary = buildAssistantSummaryForConversation() + if (assistantSummary.isNotBlank()) { + InlineEditConversationManager.addAssistantResponse(userMessage, assistantSummary) + } + + val hadChanges = showFinalDiff() + + val popover = editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + popover?.observableProperties?.hasPendingChanges?.set(hadChanges) + popover?.setThinkingVisible(false) + popover?.setInlineEditControlsVisible(hadChanges) + + if (hadChanges) { + val statusComponent = (editor.scrollPane as JBScrollPane).statusComponent + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK)?.let { existing -> + statusComponent.remove(existing) + } + val compareLink = ActionLink("Open in Diff Viewer") { + try { + val originalDocText = runReadAction { editor.document.text } + val usedRange = targetRange() + val originalSelection = runReadAction { editor.document.getText(usedRange) } + val modifiedSelection = applyAllSearchReplaceOperations( + originalSelection, + accumulatedSearchReplaceSegments + ) + + val newContent = + if (usedRange.startOffset == 0 && usedRange.endOffset == originalDocText.length) { + modifiedSelection + } else buildString(originalDocText.length + modifiedSelection.length) { + append(originalDocText, 0, usedRange.startOffset) + append(modifiedSelection) + append(originalDocText, usedRange.endOffset, originalDocText.length) + } + + val originalVf = editor.virtualFile ?: return@ActionLink + val tempFile = LightVirtualFile(originalVf.name, newContent) + val diffRequest = + EditorDiffUtil.createDiffRequest(project, tempFile, originalVf) + DiffManager.getInstance().showDiff(project, diffRequest) + } catch (e: Exception) { + OverlayUtil.showNotification( + "Failed to open diff: ${e.message}", + NotificationType.ERROR + ) + } + }.apply { + icon = AllIcons.Actions.Diff + toolTipText = CodeGPTBundle.get("editor.diff.title") + border = JBUI.Borders.empty(0, 6) + } + + statusComponent.add(compareLink) + editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK, compareLink) + statusComponent.revalidate() + statusComponent.repaint() + } + stopLoading() + } + } + + private fun buildAssistantSummaryForConversation(): String { + val vf = editor.virtualFile + val language = vf?.extension ?: "txt" + val path = vf?.path ?: "untitled" + + if (accumulatedSearchReplaceSegments.isEmpty()) return "" + + val sb = StringBuilder() + sb.append("```$language:$path\n") + accumulatedSearchReplaceSegments.forEach { seg -> + val search = seg.search.trim() + val replace = seg.replace.trim() + if (search.isEmpty() && replace.isEmpty()) return@forEach + sb.append("SEARCH\n") + sb.append(search) + sb.append("\nREPLACE\n") + sb.append(replace) + sb.append("\n---\n") + } + sb.append("```\n") + return sb.toString() + } + + override fun onError(error: ErrorDetails, ex: Throwable) { + if (!isCurrentRequest()) return + + runInEdt { + clearAllHighlights() + highlightDebounceAlarm.cancelAllRequests() + hintComponent?.let { + editor.contentComponent.remove(it) + hintComponent = null + } + val pop = editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + pop?.setThinkingVisible(false) + pop?.setInlineEditControlsVisible(false) + } + + OverlayUtil.showNotification( + error.message, + NotificationType.ERROR, + NotificationAction.createSimpleExpiring("Upgrade plan") { + BrowserUtil.open("https://tryproxy.io/#pricing") + }, + ) + unlockEditorOnError() + stopLoading() + } + + private fun processCurrentFileSearchReplace(segment: SearchReplace) { + accumulatedSearchReplaceSegments.add(segment) + + try { + val result = filterApplicableSegments(accumulatedSearchReplaceSegments) + if (result.filteredCount > 0) { + showFilteredWarning(result) + } + val range = targetRange() + val originalContent = runReadAction { editor.document.getText(range) } + val modifiedSoFar = applySimpleSearchReplace(originalContent, result.pairs) + + val existingSession = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + + if (existingSession == null) { + val session = InlineEditSession.start( + project, + editor, + range, + originalContent, + modifiedSoFar + ) + session.setInteractive(true) + previewSessionStarted = true + } else { + existingSession.updateProposedText(modifiedSoFar, interactive = true) + } + } catch (e: Exception) { + logger.error("Error while processing segment", e) + } + } + + private fun showFinalDiff(): Boolean { + if (!isStreamingComplete) { + return false + } + + try { + val range = targetRange() + val originalContent = runReadAction { editor.document.getText(range) } + val modifiedContent = + applyAllSearchReplaceOperations(originalContent, accumulatedSearchReplaceSegments) + if (modifiedContent == originalContent) { + val noChangesMsg = CodeGPTBundle.get("inlineEdit.status.noChanges") + showInlineHint(noChangesMsg) + OverlayUtil.showNotification(noChangesMsg, NotificationType.INFORMATION) + return false + } + + val existingSession = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + if (existingSession == null) { + InlineEditSession.start(project, editor, range, originalContent, modifiedContent) + } else { + existingSession.updateProposedText(modifiedContent, interactive = true) + if (!existingSession.hasPendingHunks()) { + val noChangesMsg = CodeGPTBundle.get("inlineEdit.status.noChanges") + showInlineHint(noChangesMsg) + OverlayUtil.showNotification(noChangesMsg, NotificationType.INFORMATION) + return false + } + } + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.setInteractive(true) + } catch (e: Exception) { + logger.warn("Failed to build final diff", e) + return false + } + return true + } + + private fun applyAllSearchReplaceOperations( + originalContent: String, + searchReplaceSegments: List + ): String { + val filtered = filterApplicableSegments(searchReplaceSegments) + if (filtered.filteredCount > 0) { + showFilteredWarning(filtered) + } + return applySimpleSearchReplace(originalContent, filtered.pairs) + } + + private fun filterApplicableSegments(segments: List): FilterResult { + val vf = editor.virtualFile + val currentPath = vf?.path + val fileName = vf?.name + val fileExt = vf?.extension?.lowercase() + return InlineEditFilter.filterSegments(currentPath, fileName, fileExt, segments) + } + + private fun showFilteredWarning(result: FilterResult) { + if (result.filteredCount <= 0) return + runInEdt { + showInlineHint("Ignored ${result.filteredCount} invalid block(s)") + } + } + + private fun stopLoading() { + observableProperties.loading.set(false) + project.let { + CompletionProgressNotifier.Companion.update(it, false) + } + + val popover = editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + popover?.onCompletionFinished() + } + + private fun unlockEditorOnError() { + if (!editor.document.isWritable) { + editor.document.setReadOnly(false) + } + } + + fun dispose() { + clearAllHighlights() + highlightDebounceAlarm.cancelAllRequests() + hintComponent?.let { + editor.contentComponent.remove(it) + } + + editor.putUserData(LISTENER_KEY, null) + } + + private fun findPatternInContent( + content: String, + pattern: String, + fuzzyMatch: Boolean = true + ): List { + val matches = mutableListOf() + val cleanPattern = pattern.trim() + + if (cleanPattern.isEmpty()) { + return matches + } + + var index = content.indexOf(cleanPattern) + while (index >= 0) { + matches.add(TextRange(index, index + cleanPattern.length)) + index = content.indexOf(cleanPattern, index + 1) + } + + if (matches.isEmpty() && fuzzyMatch && (pattern.contains("...") || cleanPattern.length < pattern.length)) { + val partialMatches = findPartialMatches(content, cleanPattern) + matches.addAll(partialMatches) + } + + return matches + } + + private fun findPartialMatches(content: String, pattern: String): List { + val identifiers = extractIdentifiers(pattern) + if (identifiers.isEmpty()) { + val firstLine = pattern.lines().firstOrNull()?.trim() + if (!firstLine.isNullOrEmpty() && content.contains(firstLine)) { + val index = content.indexOf(firstLine) + return listOf(expandToLogicalBlock(content, index)) + } + return emptyList() + } + + val matches = mutableListOf() + val lines = content.lines() + + for (i in lines.indices) { + if (identifiers.all { identifier -> lines[i].contains(identifier) }) { + val blockRange = expandToLogicalBlock(content, getLineStartOffset(content, i)) + matches.add(blockRange) + } + } + + return matches + } + + private fun extractIdentifiers(pattern: String): List { + val identifierRegex = + "\\b(class|function|def|var|val|let|const|public|private)\\s+(\\w+)".toRegex() + val methodRegex = "\\b(\\w+)\\s*\\(".toRegex() + val variableRegex = "\\b[a-zA-Z_][a-zA-Z0-9_]{2,}\\b".toRegex() + + val identifiers = mutableSetOf() + + identifierRegex.findAll(pattern).forEach { match -> + identifiers.add(match.groupValues[2]) + } + + methodRegex.findAll(pattern).forEach { match -> + identifiers.add(match.groupValues[1]) + } + + variableRegex.findAll(pattern).forEach { match -> + val identifier = match.value + if (identifier.length > 2 && !identifier.matches("\\d+".toRegex())) { + identifiers.add(identifier) + } + } + + return identifiers.toList().take(3) + } + + private fun expandToLogicalBlock(content: String, charOffset: Int): TextRange { + val lines = content.lines() + val lineIndex = getLineIndex(content, charOffset) + + if (lineIndex >= lines.size) { + return TextRange(charOffset, charOffset) + } + + var startLine = lineIndex + var endLine = lineIndex + var braceCount = 0 + + for (i in lineIndex downTo 0) { + val line = lines[i] + if (line.contains("{")) braceCount++ + if (line.contains("}")) braceCount-- + + if (braceCount > 0 || isBlockStart(line)) { + startLine = i + break + } + } + + braceCount = 0 + for (i in lineIndex until lines.size) { + val line = lines[i] + if (line.contains("{")) braceCount++ + if (line.contains("}")) braceCount-- + + if (braceCount == 0 && line.contains("}") && i > lineIndex) { + endLine = i + break + } + } + + return convertLinesToTextRange(content, startLine, endLine) + } + + private fun isBlockStart(line: String): Boolean { + val trimmed = line.trim() + return trimmed.startsWith("class ") || + trimmed.startsWith("function ") || + trimmed.startsWith("def ") || + trimmed.startsWith("public ") || + trimmed.startsWith("private ") || + trimmed.contains("{") || + trimmed.endsWith(":") + } + + private fun getLineIndex(content: String, charOffset: Int): Int { + return content.substring(0, charOffset).count { it == '\n' } + } + + private fun getLineStartOffset(content: String, lineIndex: Int): Int { + val lines = content.lines() + var offset = 0 + + for (i in 0 until lineIndex) { + if (i < lines.size) { + offset += lines[i].length + 1 + } + } + + return offset + } + + private fun convertLinesToTextRange(content: String, startLine: Int, endLine: Int): TextRange { + val lines = content.lines() + + var startOffset = 0 + for (i in 0 until startLine) { + if (i < lines.size) { + startOffset += lines[i].length + 1 + } + } + + var endOffset = startOffset + for (i in startLine..endLine) { + if (i < lines.size) { + endOffset += lines[i].length + if (i < lines.size - 1) endOffset += 1 + } + } + + return TextRange(startOffset, endOffset) + } + + private fun highlightSearchRegions(pattern: String, isReplaceReady: Boolean = false) { + runInEdt { + clearAllHighlights() + + if (pattern.isEmpty()) { + return@runInEdt + } + + val hasSelection = selectionTextRange.startOffset < selectionTextRange.endOffset + val (content, baseOffset) = if (hasSelection) { + editor.document.getText(selectionTextRange) to selectionTextRange.startOffset + } else { + editor.document.text to 0 + } + + val matches = findPatternInContent(content, pattern) + if (matches.isEmpty()) { + showPatternNotFoundHint(pattern) + return@runInEdt + } + + matches.forEach { range -> + + val absoluteRange = + TextRange(baseOffset + range.startOffset, baseOffset + range.endOffset) + val color = if (isReplaceReady) REPLACE_READY_COLOR else SEARCH_HIGHLIGHT_COLOR + val tooltip = if (isReplaceReady) CodeGPTBundle.get("inlineEdit.tooltip.ready") + else CodeGPTBundle.get("inlineEdit.tooltip.searching") + + searchHighlighters.add(createHighlighter(absoluteRange, color, tooltip)) + } + + if (matches.isNotEmpty()) { + ensureVisible(baseOffset + matches.first().startOffset) + } + } + } + + private fun updateHighlightState(state: HighlightState, message: String? = null) { + val color = when (state) { + HighlightState.SEARCHING -> SEARCH_HIGHLIGHT_COLOR + HighlightState.FOUND -> REPLACE_READY_COLOR + HighlightState.REPLACING -> JBColor( + Color(59, 149, 255, 80), + Color(59, 149, 255, 60) + ) + + HighlightState.ERROR -> JBColor(Color(255, 59, 59, 80), Color(255, 59, 59, 60)) + } + + searchHighlighters.forEach { highlighter -> + highlighter.getTextAttributes(editor.colorsScheme)?.backgroundColor = color + } + + if (message != null) { + showInlineHint(message) + } + } + + private fun showPatternNotFoundHint(pattern: String) { + val shortPattern = if (pattern.length > 30) "${pattern.take(30)}..." else pattern + showInlineHint( + CodeGPTBundle.get("inlineEdit.hint.searchingFor", shortPattern) + ) + } + + private fun showInlineHint(message: String) { + runInEdt { + hintComponent?.let { + editor.contentComponent.remove(it) + } + + val hint = JLabel(message).apply { + foreground = JBColor.GRAY + font = font.deriveFont(Font.ITALIC, 12f) + border = JBUI.Borders.empty(2, 8) + background = editor.backgroundColor + isOpaque = true + } + + val targetOffset = if (searchHighlighters.isNotEmpty()) { + searchHighlighters.first().startOffset + } else { + selectionTextRange.startOffset + } + + val comp = editor.contentComponent + val point = editor.visualPositionToXY(editor.offsetToVisualPosition(targetOffset)) + val visible = comp.visibleRect + + val prefW = 300 + val prefH = 20 + var x = point.x + var y = point.y - 25 + + if (x < visible.x) x = visible.x + JBUI.scale(8) + if (y < visible.y) y = visible.y + JBUI.scale(8) + if (x + prefW > visible.x + visible.width) x = + visible.x + visible.width - prefW - JBUI.scale(8) + if (y + prefH > visible.y + visible.height) y = + visible.y + visible.height - prefH - JBUI.scale(8) + + hint.setBounds(x, y, prefW, prefH) + + comp.add(hint) + hintComponent = hint + + Timer().schedule(3000) { + runInEdt { + if (hintComponent == hint) { + comp.remove(hint) + comp.repaint() + hintComponent = null + } + } + } + } + } + + fun showHint(message: String) { + showInlineHint(message) + } + + companion object { + val LISTENER_KEY = + Key.create("InlineEditSearchReplaceListener") + val REQUEST_ID_KEY = Key.create("InlineEditRequestId") + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSession.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSession.kt new file mode 100644 index 00000000..0ba210b8 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSession.kt @@ -0,0 +1,357 @@ +package ee.carlrobert.codegpt.inlineedit + +import com.intellij.diff.comparison.ComparisonManager +import com.intellij.diff.comparison.ComparisonPolicy +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.CustomShortcutSet +import com.intellij.openapi.actionSystem.EmptyAction +import com.intellij.openapi.actionSystem.KeyboardShortcut +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 +import ee.carlrobert.codegpt.inlineedit.InlineEditInlayRenderer +import ee.carlrobert.codegpt.inlineedit.InlineEditKeyEventDispatcher +import ee.carlrobert.codegpt.ui.InlineEditPopover +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import javax.swing.KeyStroke +import kotlin.math.abs + +class InlineEditSession( + private val project: Project, + private val editor: EditorEx, + private val baseRange: TextRange, + private val initialBaseText: String, + private var proposedText: String +) : Disposable { + + data class Hunk( + val baseMarker: RangeMarker, + val proposedSlice: String, + val startOffset: Int, + val endOffset: Int, + var accepted: Boolean = false, + var rejected: Boolean = false + ) + + private val renderer = InlineEditInlayRenderer(editor, project) + private val hunks = mutableListOf() + private val lockedRanges = mutableListOf() + private val rejectedRanges = mutableListOf() + private val rootMarker: RangeMarker = runReadAction { + editor.document.createRangeMarker(baseRange.startOffset, baseRange.endOffset, true).apply { + isGreedyToLeft = true + isGreedyToRight = true + } + } + + init { + buildAndRenderHunks() + + editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION, this) + editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER, renderer) + + registerEditorScopedShortcuts() + InlineEditKeyEventDispatcher( + project, + editor, + onAccept = { acceptNearestToCaret() }, + onReject = { rejectNearestToCaret() } + ).register(this) + } + + private fun buildAndRenderHunks() { + val newHunks = computeHunks() + hunks.clear() + hunks.addAll(newHunks) + renderer.renderHunks(hunks) + } + + private fun computeHunks(): List { + val (baseNow, baseStartOffset) = runReadAction { + val start = rootMarker.startOffset.coerceAtLeast(0) + val end = + rootMarker.endOffset.coerceAtLeast(start).coerceAtMost(editor.document.textLength) + Pair(editor.document.getText(TextRange(start, end)), start) + } + + val lineFragments = ComparisonManager.getInstance() + .compareLines(baseNow, proposedText, ComparisonPolicy.DEFAULT, EmptyProgressIndicator()) + if (lineFragments.isEmpty()) return emptyList() + + val baseLineOffsets = computeLineStartOffsets(baseNow) + val proposedLineOffsets = computeLineStartOffsets(proposedText) + + val rawHunks = runReadAction { + val list = mutableListOf() + val docLength = editor.document.textLength + for (frag in lineFragments) { + val baseStart = + if (frag.startLine1 < baseLineOffsets.size) baseLineOffsets[frag.startLine1] else baseNow.length + val baseEnd = + if (frag.endLine1 < baseLineOffsets.size) baseLineOffsets[frag.endLine1] else baseNow.length + val proposedStart = + if (frag.startLine2 < proposedLineOffsets.size) proposedLineOffsets[frag.startLine2] else proposedText.length + val proposedEnd = + if (frag.endLine2 < proposedLineOffsets.size) proposedLineOffsets[frag.endLine2] else proposedText.length + + val oldSlice = safeSlice(baseNow, baseStart, baseEnd) + val newSlice = safeSlice(proposedText, proposedStart, proposedEnd) + if (oldSlice == newSlice) continue + + val rawStart = baseStartOffset + baseStart + val rawEnd = baseStartOffset + baseEnd + val start = rawStart.coerceIn(0, docLength) + val end = rawEnd.coerceIn(start, docLength) + + val marker = editor.document.createRangeMarker(start, end, true).apply { + isGreedyToLeft = true + isGreedyToRight = true + } + list.add(Hunk(marker, newSlice, start, end)) + } + list + } + return rawHunks.filter { h -> + lockedRanges.none { lock -> + rangesOverlap( + h.startOffset, + h.endOffset, + lock.startOffset, + lock.endOffset + ) + } && + rejectedRanges.none { rej -> + rangesOverlap( + h.startOffset, + h.endOffset, + rej.startOffset, + rej.endOffset + ) + } + } + } + + fun updateProposedText(newText: String, interactive: Boolean) { + this.proposedText = newText + val newHunks = computeHunks() + hunks.clear() + hunks.addAll(newHunks) + renderer.setInteractive(interactive) + renderer.replaceHunks(hunks) + } + + fun acceptNearestToCaret() { + val caret = editor.caretModel.offset + val next = hunks + .filter { !it.accepted && !it.rejected } + .minByOrNull { abs(it.startOffset - caret) } + if (next != null) acceptHunk(next) + } + + fun rejectNearestToCaret() { + val caret = editor.caretModel.offset + val next = hunks + .filter { !it.accepted && !it.rejected } + .minByOrNull { abs(it.startOffset - caret) } + if (next != null) rejectHunk(next) + } + + fun acceptAll() { + editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.markChangesAsAccepted() + + hunks + .filter { !it.accepted && !it.rejected } + .sortedByDescending { it.baseMarker.startOffset } + .forEach { acceptHunk(it) } + removeCompareLinkIfAny() + dispose() + } + + fun rejectAll() { + hunks + .filter { !it.accepted && !it.rejected } + .forEach { rejectHunk(it) } + removeCompareLinkIfAny() + + editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + ?.triggerPromptRestoration() + + dispose() + } + + private fun acceptHunk(hunk: Hunk) { + if (hunk.accepted || hunk.rejected) return + val start = hunk.baseMarker.startOffset + val end = hunk.baseMarker.endOffset + WriteCommandAction.runWriteCommandAction(project) { + editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.markChangesAsAccepted() + editor.document.replaceString(start, end, hunk.proposedSlice) + hunk.accepted = true + val newEnd = start + hunk.proposedSlice.length + val lock = editor.document.createRangeMarker(start, newEnd, true).apply { + isGreedyToLeft = true + isGreedyToRight = true + } + lockedRanges.add(lock) + val newHunks = computeHunks() + hunks.clear() + hunks.addAll(newHunks) + renderer.replaceHunks(hunks) + if (hunks.isEmpty()) { + editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + ?.setInlineEditControlsVisible(false) + removeCompareLinkIfAny() + } + } + } + + private fun rejectHunk(hunk: Hunk) { + if (hunk.accepted || hunk.rejected) return + + hunk.rejected = true + + val start = hunk.baseMarker.startOffset + val end = hunk.baseMarker.endOffset + val safeStart = start.coerceIn(0, editor.document.textLength) + val safeEnd = end.coerceIn(safeStart, editor.document.textLength) + val marker = editor.document.createRangeMarker(safeStart, safeEnd, true).apply { + isGreedyToLeft = true + isGreedyToRight = true + } + rejectedRanges.add(marker) + + val newHunks = computeHunks() + hunks.clear() + hunks.addAll(newHunks) + + renderer.replaceHunks(hunks) + if (hunks.isEmpty()) { + editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + ?.setInlineEditControlsVisible(false) + removeCompareLinkIfAny() + } + } + + fun accept(hunk: Hunk) = acceptHunk(hunk) + fun reject(hunk: Hunk) = rejectHunk(hunk) + fun setInteractive(enabled: Boolean) = renderer.setInteractive(enabled) + + fun hasPendingHunks(): Boolean { + return hunks.any { !it.accepted && !it.rejected } + } + + private fun rangesOverlap(aStart: Int, aEnd: Int, bStart: Int, bEnd: Int): Boolean { + val start = maxOf(aStart, bStart) + val end = minOf(aEnd, bEnd) + return start < end + } + + override fun dispose() { + editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION, null) + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.dispose() + editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER, null) + editor.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.setInlineEditControlsVisible(false) + + removeCompareLinkIfAny() + } + + private fun removeCompareLinkIfAny() { + val comp = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK) ?: return + val statusComponent = (editor.scrollPane as JBScrollPane).statusComponent + statusComponent.remove(comp) + statusComponent.revalidate() + statusComponent.repaint() + editor.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_COMPARE_LINK, null) + } + + private fun computeLineStartOffsets(text: String): IntArray { + val lines = text.split('\n') + val offsets = IntArray(lines.size + 1) + var sum = 0 + for (i in lines.indices) { + offsets[i] = sum + sum += lines[i].length + 1 + } + offsets[lines.size] = sum + return offsets + } + + private fun safeSlice(text: String, start: Int, end: Int): String { + val s = start.coerceIn(0, text.length) + val e = end.coerceIn(s, text.length) + return text.substring(s, e) + } + + companion object { + fun start( + project: Project, + editor: EditorEx, + baseRange: TextRange, + baseText: String, + proposedText: String + ): InlineEditSession { + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.dispose() + return InlineEditSession(project, editor, baseRange, baseText, proposedText) + } + } + + private fun registerEditorScopedShortcuts() { + val am = ActionManager.getInstance() + am.getAction("CodeGPT.AcceptCurrentInlineEdit")?.let { action -> + val ks = resolvePreferredKeyStroke("CodeGPT.AcceptCurrentInlineEdit", KeyEvent.VK_Y) + val wrapped = EmptyAction.wrap(action) + wrapped.registerCustomShortcutSet( + CustomShortcutSet(KeyboardShortcut(ks, null)), + editor.contentComponent, + this + ) + } + am.getAction("CodeGPT.RejectCurrentInlineEdit")?.let { action -> + val ks = resolvePreferredKeyStroke("CodeGPT.RejectCurrentInlineEdit", KeyEvent.VK_N) + val wrapped = EmptyAction.wrap(action) + wrapped.registerCustomShortcutSet( + CustomShortcutSet(KeyboardShortcut(ks, null)), + editor.contentComponent, + this + ) + } + + am.getAction("codegpt.acceptInlineEdit")?.let { editorAction -> + val metaEnter = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.META_DOWN_MASK) + val ctrlEnter = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK) + val shortcuts = arrayOf( + KeyboardShortcut(metaEnter, null), + KeyboardShortcut(ctrlEnter, null) + ) + val wrapped = EmptyAction.wrap(editorAction) + wrapped.registerCustomShortcutSet( + CustomShortcutSet(*shortcuts), + editor.contentComponent, + this + ) + } + } + + private fun resolvePreferredKeyStroke(actionId: String, keyCode: Int): KeyStroke { + val keymap = KeymapManager.getInstance().activeKeymap + val shortcuts = keymap.getShortcuts(actionId) + val macKs = KeyStroke.getKeyStroke(keyCode, InputEvent.META_DOWN_MASK) + val winKs = KeyStroke.getKeyStroke(keyCode, InputEvent.CTRL_DOWN_MASK) + val fromKeymap = + shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == macKs } + ?: shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == winKs } + val ks = (fromKeymap as? KeyboardShortcut)?.firstKeyStroke + if (ks != null) return ks + return if (SystemInfo.isMac) macKs else winKs + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSubmissionHandler.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSubmissionHandler.kt new file mode 100644 index 00000000..09ba209a --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditSubmissionHandler.kt @@ -0,0 +1,175 @@ +package ee.carlrobert.codegpt.inlineedit + +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.ex.EditorEx +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.ReferencedFile +import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier +import ee.carlrobert.codegpt.completions.CompletionRequestService +import ee.carlrobert.codegpt.completions.InlineEditCompletionParameters +import ee.carlrobert.codegpt.conversations.Conversation +import ee.carlrobert.codegpt.ui.InlineEditPopover +import ee.carlrobert.codegpt.ui.ObservableProperties +import ee.carlrobert.codegpt.ui.OverlayUtil +import okhttp3.sse.EventSource +import java.util.concurrent.atomic.AtomicReference + +class InlineEditSubmissionHandler( + private val editor: Editor, + private val observableProperties: ObservableProperties, +) { + + private val previousSourceRef = AtomicReference(null) + private val previousPromptRef = AtomicReference(null) + private val currentEventSourceRef = AtomicReference(null) + private val logger = Logger.getInstance(InlineEditSubmissionHandler::class.java) + + fun handleSubmit( + userPrompt: String, + referencedFiles: List? = null, + gitDiff: String? = null, + conversationHistory: List? = null, + diagnosticsInfo: String? = null + ) { + 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() } + + val file = FileDocumentManager.getInstance().getFile(editor.document) + val editorEx = editor as? EditorEx ?: return + val parameters = InlineEditCompletionParameters( + userPrompt, + runReadAction { editor.selectionModel.selectedText }, + file?.path, + file?.extension, + editor.project?.basePath, + referencedFiles, + gitDiff, + InlineEditConversationManager.getOrCreate(editorEx), + conversationHistory, + diagnosticsInfo + ) + + val requestId = System.nanoTime() + editorEx.putUserData(InlineEditSearchReplaceListener.REQUEST_ID_KEY, requestId) + + val listener = InlineEditSearchReplaceListener( + editorEx, + observableProperties, + TextRange( + runReadAction { editor.selectionModel.selectionStart }, + runReadAction { editor.selectionModel.selectionEnd }, + ), + requestId, + userPrompt + ) + + editorEx.putUserData(InlineEditSearchReplaceListener.LISTENER_KEY, listener) + + listener.showHint("Submitting inline edit…") + + editorEx.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.apply { + setInlineEditControlsVisible(false) + setThinkingVisible(true) + } + + try { + currentEventSourceRef.getAndSet(null)?.cancel() + + val eventSource = service().getInlineEditCompletionAsync( + parameters, + listener + ) + currentEventSourceRef.set(eventSource) + + } catch (ex: Exception) { + logger.warn("InlineEdit: request dispatch failed", ex) + runInEdt { + OverlayUtil.showNotification( + ex.message ?: "Inline Edit request failed", + NotificationType.ERROR + ) + observableProperties.loading.set(false) + observableProperties.submitted.set(false) + editorEx.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + ?.setThinkingVisible(false) + } + } + } + + fun handleReject(clearConversation: Boolean = false) { + cancelActiveRequest() + (editor as? EditorEx)?.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.setThinkingVisible(false) + val prevSource = previousSourceRef.get() + if (!observableProperties.accepted.get() && prevSource != null) { + revertAllChanges(prevSource) + } + + restorePreviousPrompt() + runInEdt { + val editorEx = editor as? EditorEx + editorEx?.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.dispose() + editorEx?.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION, null) + editorEx?.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.dispose() + editorEx?.putUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER, null) + if (clearConversation) { + editorEx?.let { InlineEditConversationManager.clear(it) } + } + editorEx?.putUserData(InlineEditSearchReplaceListener.LISTENER_KEY, null) + + observableProperties.loading.set(false) + observableProperties.submitted.set(false) + editor.project?.let { project -> + CompletionProgressNotifier.Companion.update(project, false) + } + editorEx?.getUserData(InlineEditPopover.Companion.POPOVER_KEY)?.onCompletionFinished() + } + } + + private fun cancelActiveRequest() { + val editorEx = editor as? EditorEx + val newRequestId = System.nanoTime() + editorEx?.putUserData(InlineEditSearchReplaceListener.REQUEST_ID_KEY, newRequestId) + + currentEventSourceRef.getAndSet(null)?.cancel() + } + + private fun revertAllChanges(prevSource: String) { + editor.project?.let { project -> + WriteCommandAction.runWriteCommandAction(project) { + editor.document.replaceString( + 0, + editor.document.textLength, + StringUtil.convertLineSeparators(prevSource) + ) + } + } + } + + fun restorePreviousPrompt() { + val prevPrompt = previousPromptRef.get() + if (prevPrompt != null) { + (editor as? EditorEx) + ?.getUserData(InlineEditPopover.Companion.POPOVER_KEY) + ?.restorePromptAndFocus(prevPrompt) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/migration/LegacySettingsMigration.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/migration/LegacySettingsMigration.kt index b1140530..ad118cbe 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/migration/LegacySettingsMigration.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/migration/LegacySettingsMigration.kt @@ -4,7 +4,6 @@ import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger import ee.carlrobert.codegpt.settings.GeneralSettings import ee.carlrobert.codegpt.settings.models.ModelRegistry -import ee.carlrobert.codegpt.settings.models.ModelSettings import ee.carlrobert.codegpt.settings.models.ModelSettingsState import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ServiceType @@ -45,7 +44,7 @@ object LegacySettingsMigration { setModelSelection(FeatureType.CHAT, chatModel, selectedService) setModelSelection(FeatureType.AUTO_APPLY, chatModel, selectedService) setModelSelection(FeatureType.COMMIT_MESSAGE, chatModel, selectedService) - setModelSelection(FeatureType.EDIT_CODE, chatModel, selectedService) + setModelSelection(FeatureType.INLINE_EDIT, chatModel, selectedService) setModelSelection(FeatureType.LOOKUP, chatModel, selectedService) val codeModel = getLegacyCodeModelForService(selectedService) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelRegistry.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelRegistry.kt index a6216d18..2df3b07b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelRegistry.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelRegistry.kt @@ -48,7 +48,7 @@ class ModelRegistry { FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE, - FeatureType.EDIT_CODE, + FeatureType.INLINE_EDIT, FeatureType.NEXT_EDIT, FeatureType.LOOKUP ) @@ -57,49 +57,49 @@ class ModelRegistry { ServiceType.OPENAI, setOf( FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY, - FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP + FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP ) ), ServiceType.ANTHROPIC to ModelCapability( ServiceType.ANTHROPIC, setOf( FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE, - FeatureType.EDIT_CODE, FeatureType.LOOKUP + FeatureType.INLINE_EDIT, FeatureType.LOOKUP ) ), ServiceType.GOOGLE to ModelCapability( ServiceType.GOOGLE, setOf( FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE, - FeatureType.EDIT_CODE, FeatureType.LOOKUP + FeatureType.INLINE_EDIT, FeatureType.LOOKUP ) ), ServiceType.MISTRAL to ModelCapability( ServiceType.MISTRAL, setOf( FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY, - FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP + FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP ) ), ServiceType.OLLAMA to ModelCapability( ServiceType.OLLAMA, setOf( FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY, - FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP + FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP ) ), ServiceType.LLAMA_CPP to ModelCapability( ServiceType.LLAMA_CPP, setOf( FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY, - FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP + FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP ) ), ServiceType.CUSTOM_OPENAI to ModelCapability( ServiceType.CUSTOM_OPENAI, setOf( FeatureType.CHAT, FeatureType.CODE_COMPLETION, FeatureType.AUTO_APPLY, - FeatureType.COMMIT_MESSAGE, FeatureType.EDIT_CODE, FeatureType.LOOKUP + FeatureType.COMMIT_MESSAGE, FeatureType.INLINE_EDIT, FeatureType.LOOKUP ) ) ) @@ -121,7 +121,7 @@ class ModelRegistry { GPT_5_MINI, "GPT-5 Mini" ), - FeatureType.EDIT_CODE to ModelSelection( + FeatureType.INLINE_EDIT to ModelSelection( ServiceType.PROXYAI, GPT_5_MINI, "GPT-5 Mini" @@ -150,7 +150,7 @@ class ModelRegistry { QWEN3_CODER, "Qwen3 Coder" ), - FeatureType.EDIT_CODE to ModelSelection( + FeatureType.INLINE_EDIT to ModelSelection( ServiceType.PROXYAI, QWEN3_CODER, "Qwen3 Coder" @@ -171,7 +171,7 @@ class ModelRegistry { ), FeatureType.AUTO_APPLY to ModelSelection(ServiceType.PROXYAI, GPT_5, "GPT-5"), FeatureType.COMMIT_MESSAGE to ModelSelection(ServiceType.PROXYAI, GPT_5, "GPT-5"), - FeatureType.EDIT_CODE to ModelSelection( + FeatureType.INLINE_EDIT to ModelSelection( ServiceType.PROXYAI, CLAUDE_4_SONNET, "Claude 4 Sonnet" @@ -202,7 +202,7 @@ class ModelRegistry { GPT_5_MINI, "GPT-5 Mini" ), - FeatureType.EDIT_CODE to ModelSelection(ServiceType.PROXYAI, GPT_5_MINI, "GPT-5 Mini"), + FeatureType.INLINE_EDIT to ModelSelection(ServiceType.PROXYAI, GPT_5_MINI, "GPT-5 Mini"), FeatureType.LOOKUP to ModelSelection(ServiceType.PROXYAI, GPT_5_MINI, "GPT-5 Mini"), FeatureType.CODE_COMPLETION to ModelSelection( ServiceType.PROXYAI, @@ -215,7 +215,7 @@ class ModelRegistry { fun getAllModelsForFeature(featureType: FeatureType): List { return when (featureType) { FeatureType.CHAT, FeatureType.AUTO_APPLY, FeatureType.COMMIT_MESSAGE, - FeatureType.EDIT_CODE, FeatureType.LOOKUP -> getAllChatModels() + FeatureType.INLINE_EDIT, FeatureType.LOOKUP -> getAllChatModels() FeatureType.CODE_COMPLETION -> getAllCodeModels() FeatureType.NEXT_EDIT -> getNextEditModels() diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettings.kt index 914b779f..5dbc5d04 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettings.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettings.kt @@ -2,8 +2,6 @@ package ee.carlrobert.codegpt.settings.models import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.* -import com.intellij.openapi.diagnostic.thisLogger -import ee.carlrobert.codegpt.settings.migration.LegacySettingsMigration import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ModelChangeNotifier import ee.carlrobert.codegpt.settings.service.ServiceType @@ -32,7 +30,7 @@ class ModelSettings : SimplePersistentStateComponent(ModelSe FeatureType.COMMIT_MESSAGE to PublisherMethod { publisher, model, serviceType -> publisher.commitMessageModelChanged(model, serviceType) }, - FeatureType.EDIT_CODE to PublisherMethod { publisher, model, serviceType -> + FeatureType.INLINE_EDIT to PublisherMethod { publisher, model, serviceType -> publisher.editCodeModelChanged(model, serviceType) }, FeatureType.NEXT_EDIT to PublisherMethod { publisher, model, serviceType -> diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsForm.kt index fc1fe2fb..82a9dc62 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsForm.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsForm.kt @@ -48,7 +48,7 @@ class ModelSettingsForm( FeatureType.COMMIT_MESSAGE, "settings.models.commitMessages.label" ), - FeatureConfig(FeatureType.EDIT_CODE, "settings.models.editCode.label"), + FeatureConfig(FeatureType.INLINE_EDIT, "settings.models.editCode.label"), FeatureConfig(FeatureType.LOOKUP, "settings.models.nameLookups.label") ) ), @@ -90,7 +90,7 @@ class ModelSettingsForm( } override fun editCodeModelChanged(newModel: String, serviceType: ServiceType) { - modelChanged(FeatureType.EDIT_CODE, newModel, serviceType) + modelChanged(FeatureType.INLINE_EDIT, newModel, serviceType) } override fun nextEditModelChanged(newModel: String, serviceType: ServiceType) { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteMessageParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteMessageParser.kt index 7659be2f..1d63df68 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteMessageParser.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteMessageParser.kt @@ -9,7 +9,10 @@ class CompleteMessageParser : MessageParser { private val CODE_BLOCK_PATTERN: Pattern = Pattern.compile("```([a-zA-Z0-9_+-]*)(?::([^\\n]*))?\\n(.*?)```", Pattern.DOTALL) private val SEARCH_REPLACE_PATTERN: Pattern = - Pattern.compile("<<<<<<< SEARCH\\n(.*?)\\n=======\\n(.*?)\\n>>>>>>> REPLACE", Pattern.DOTALL) + Pattern.compile( + "<<<<<<< SEARCH\\n(.*?)\\n=======\\n(.*?)\\n>>>>>>> REPLACE", + Pattern.DOTALL + ) private val INCOMPLETE_SEARCH_REPLACE_PATTERN: Pattern = Pattern.compile("<<<<<<< SEARCH\\n(.*?)(?:\\n=======\\n(.*?))?$", Pattern.DOTALL) @@ -20,6 +23,12 @@ class CompleteMessageParser : MessageParser { private const val CODE_CONTENT_GROUP_INDEX = 3 private const val SEARCH_CONTENT_GROUP_INDEX = 1 private const val REPLACE_CONTENT_GROUP_INDEX = 2 + + private val TOLERANT_SEARCH_START = + Regex("""^\s*<{3,}(\s*SEARCH.*)?$""", RegexOption.IGNORE_CASE) + private val TOLERANT_SEPARATOR = Regex("""^\s*={3,}\s*$""") + private val TOLERANT_REPLACE_END = + Regex("""^\s*>{3,}(\s*REPLACE.*)?$""", RegexOption.IGNORE_CASE) } var extractedThought: String? = null @@ -137,12 +146,25 @@ class CompleteMessageParser : MessageParser { while (searchReplaceMatcher.find()) { foundSearchReplace = true - addCodeSegmentIfExists(codeContent, lastProcessedIndex, searchReplaceMatcher.start(), language, filePath) + addCodeSegmentIfExists( + codeContent, + lastProcessedIndex, + searchReplaceMatcher.start(), + language, + filePath + ) addSearchReplaceSegment(searchReplaceMatcher, language, filePath) lastProcessedIndex = searchReplaceMatcher.end() } if (!foundSearchReplace) { + val tolerantResult = tolerantScanSearchReplace(codeContent, language, filePath) + if (tolerantResult != null) { + addAll(tolerantResult.segments) + lastProcessedIndex = tolerantResult.lastIndex + foundSearchReplace = true + } + val incompleteMatch = findIncompleteSearchReplace(codeContent, language, filePath) if (incompleteMatch != null) { addAll(incompleteMatch.segments) @@ -152,7 +174,13 @@ class CompleteMessageParser : MessageParser { } if (foundSearchReplace) { - addCodeSegmentIfExists(codeContent, lastProcessedIndex, codeContent.length, language, filePath) + addCodeSegmentIfExists( + codeContent, + lastProcessedIndex, + codeContent.length, + language, + filePath + ) } } @@ -185,12 +213,14 @@ class CompleteMessageParser : MessageParser { val searchContent = matcher.group(SEARCH_CONTENT_GROUP_INDEX).orEmpty() val replaceContent = matcher.group(REPLACE_CONTENT_GROUP_INDEX).orEmpty() - add(SearchReplace( - search = searchContent, - replace = replaceContent, - language = language, - filePath = filePath - )) + add( + SearchReplace( + search = searchContent, + replace = replaceContent, + language = language, + filePath = filePath + ) + ) } /** @@ -215,12 +245,14 @@ class CompleteMessageParser : MessageParser { val searchContent = incompleteMatcher.group(SEARCH_CONTENT_GROUP_INDEX).orEmpty() val replaceContent = incompleteMatcher.group(REPLACE_CONTENT_GROUP_INDEX).orEmpty() - add(SearchReplace( - search = searchContent, - replace = replaceContent, - language = language, - filePath = filePath - )) + add( + SearchReplace( + search = searchContent, + replace = replaceContent, + language = language, + filePath = filePath + ) + ) } IncompleteSearchReplaceResult(segments, incompleteMatcher.end()) @@ -236,4 +268,96 @@ class CompleteMessageParser : MessageParser { val segments: List, val endIndex: Int ) -} \ No newline at end of file + + /** + * Tolerant scan for blocks with markers like <<<, ===, >>> (>=3 symbols). + * Returns segments in order and the last processed index, so trailing code can be appended by caller. + */ + private fun tolerantScanSearchReplace( + codeContent: String, + language: String, + filePath: String? + ): TolerantScanResult? { + val segments = mutableListOf() + var cursor = 0 + var progressed = false + + fun findNextLineMatching(regex: Regex, startPos: Int): Pair { + var pos = startPos + val len = codeContent.length + while (pos <= len) { + val lineStart = pos + if (pos >= len) return -1 to -1 + val nl = codeContent.indexOf('\n', pos) + val lineEndExclusive = if (nl == -1) len else nl + 1 + val rawLine = codeContent.substring(lineStart, lineEndExclusive).trimEnd('\n', '\r') + val trimmed = rawLine.trim() + if (regex.matches(trimmed)) return lineStart to lineEndExclusive + pos = lineEndExclusive + } + return -1 to -1 + } + + while (cursor < codeContent.length) { + val (startLineStart, startLineEnd) = findNextLineMatching(TOLERANT_SEARCH_START, cursor) + if (startLineStart == -1) break + + segments.addCodeSegmentIfExists(codeContent, cursor, startLineStart, language, filePath) + + val (sepLineStart, sepLineEnd) = findNextLineMatching(TOLERANT_SEPARATOR, startLineEnd) + if (sepLineStart == -1) { + val search = codeContent.substring(startLineEnd, codeContent.length) + if (search.isNotEmpty()) { + segments.add( + SearchReplace( + search = search, + replace = "", + language = language, + filePath = filePath + ) + ) + cursor = codeContent.length + progressed = true + } + break + } + + val (endLineStart, endLineEnd) = findNextLineMatching(TOLERANT_REPLACE_END, sepLineEnd) + if (endLineStart == -1) { + val search = codeContent.substring(startLineEnd, sepLineStart) + val replace = codeContent.substring(sepLineEnd, codeContent.length) + segments.add( + SearchReplace( + search = search, + replace = replace, + language = language, + filePath = filePath + ) + ) + cursor = codeContent.length + progressed = true + break + } + + val search = codeContent.substring(startLineEnd, sepLineStart) + val replace = codeContent.substring(sepLineEnd, endLineStart) + segments.add( + SearchReplace( + search = search, + replace = replace, + language = language, + filePath = filePath + ) + ) + cursor = endLineEnd + progressed = true + } + + return if (progressed) TolerantScanResult(segments, cursor) else null + } + + private data class TolerantScanResult( + val segments: List, + val lastIndex: Int + ) +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/SseMessageParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/SseMessageParser.kt index 8d31433c..d6e44aaa 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/SseMessageParser.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/SseMessageParser.kt @@ -12,6 +12,10 @@ class SseMessageParser : MessageParser { const val NEWLINE = "\n" const val HEADER_DELIMITER = ":" const val HEADER_PARTS_LIMIT = 2 + + val SEARCH_START_REGEX = Regex("""^\s*<{3,}(\s*SEARCH.*)?$""", RegexOption.IGNORE_CASE) + val SEPARATOR_REGEX = Regex("""^\s*={3,}\s*$""") + val REPLACE_END_REGEX = Regex("""^\s*>{3,}(\s*REPLACE.*)?$""", RegexOption.IGNORE_CASE) } private var parserState: ParserState = ParserState.Outside @@ -119,7 +123,7 @@ class SseMessageParser : MessageParser { true } - line.trimStart().startsWith(SEARCH_MARKER) -> { + isSearchStartLine(line) -> { // Emit accumulated code content before transitioning if (state.content.isNotEmpty()) { segments.add(Code(state.content, state.header.language, state.header.filePath)) @@ -148,7 +152,7 @@ class SseMessageParser : MessageParser { val line = buffer.substring(0, nlIdx) consumeFromBuffer(nlIdx + 1) - return if (line.trim() == SEPARATOR_MARKER) { + return if (isSeparatorLine(line)) { segments.add( ReplaceWaiting( state.searchContent, @@ -179,7 +183,7 @@ class SseMessageParser : MessageParser { consumeFromBuffer(nlIdx + 1) return when { - line.trim().startsWith(REPLACE_MARKER) -> { + isReplaceEndLine(line) -> { segments.add( SearchReplace( search = state.searchContent, @@ -318,11 +322,26 @@ class SseMessageParser : MessageParser { return if (parts.isNotEmpty()) { CodeHeader( language = parts.getOrNull(0) ?: "", - filePath = parts.getOrNull(1) + filePath = parts.getOrNull(1)?.trim()?.ifEmpty { null } ) } else null } + private fun isSearchStartLine(line: String): Boolean { + val trimmed = line.trim() + return trimmed.startsWith(SEARCH_MARKER) || SEARCH_START_REGEX.matches(trimmed) + } + + private fun isSeparatorLine(line: String): Boolean { + val trimmed = line.trim() + return trimmed == SEPARATOR_MARKER || SEPARATOR_REGEX.matches(trimmed) + } + + private fun isReplaceEndLine(line: String): Boolean { + val trimmed = line.trim() + return trimmed.startsWith(REPLACE_MARKER) || REPLACE_END_REGEX.matches(trimmed) + } + private sealed class ParserState { object Outside : ParserState() diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/EditCodePopover.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/EditCodePopover.kt deleted file mode 100644 index bf0db230..00000000 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/EditCodePopover.kt +++ /dev/null @@ -1,196 +0,0 @@ -package ee.carlrobert.codegpt.ui - -import com.intellij.ide.IdeBundle -import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI -import com.intellij.openapi.actionSystem.ActionPlaces -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.event.SelectionEvent -import com.intellij.openapi.editor.event.SelectionListener -import com.intellij.openapi.observable.properties.AtomicBooleanProperty -import com.intellij.openapi.observable.properties.ObservableProperty -import com.intellij.openapi.observable.util.not -import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.openapi.ui.popup.util.MinimizeButton -import com.intellij.ui.DocumentAdapter -import com.intellij.ui.components.JBTextField -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.Cell -import com.intellij.ui.dsl.builder.Row -import com.intellij.ui.dsl.builder.panel -import com.intellij.ui.layout.ComponentPredicate -import com.intellij.util.ui.AsyncProcessIcon -import com.intellij.util.ui.JBUI -import ee.carlrobert.codegpt.CodeGPTBundle -import ee.carlrobert.codegpt.actions.editor.EditCodeSubmissionHandler -import ee.carlrobert.codegpt.settings.models.ModelSettings -import ee.carlrobert.codegpt.settings.models.SettingsModelComboBoxAction -import ee.carlrobert.codegpt.settings.service.FeatureType -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import java.awt.event.KeyAdapter -import java.awt.event.KeyEvent -import javax.swing.JButton -import javax.swing.JPanel -import javax.swing.event.DocumentEvent - -data class ObservableProperties( - val submitted: AtomicBooleanProperty = AtomicBooleanProperty(false), - val accepted: AtomicBooleanProperty = AtomicBooleanProperty(false), - val loading: AtomicBooleanProperty = AtomicBooleanProperty(false), -) - -class EditCodePopover(private val editor: Editor) { - - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val observableProperties = ObservableProperties() - private val submissionHandler = EditCodeSubmissionHandler(editor, observableProperties) - private val promptTextField = JBTextField("", 40).apply { - emptyText.appendText(CodeGPTBundle.get("editCodePopover.textField.emptyText")) - addKeyListener(object : KeyAdapter() { - override fun keyPressed(e: KeyEvent) { - if (e.keyCode == KeyEvent.VK_ENTER) { - e.consume() - handleSubmit() - } - } - }) - } - private val popup = JBPopupFactory.getInstance() - .createComponentPopupBuilder( - createPopupPanel(), - promptTextField - ) - .setTitle(CodeGPTBundle.get("editCodePopover.title")) - .setMovable(true) - .setCancelKeyEnabled(true) - .setCancelOnClickOutside(false) - .setCancelOnWindowDeactivation(false) - .setRequestFocus(true) - .setCancelButton(MinimizeButton(IdeBundle.message("tooltip.hide"))) - .setCancelCallback { - submissionHandler.handleReject() - true - } - .createPopup() - - fun show() { - popup.showInBestPositionFor(editor) - } - - private fun createPopupPanel(): JPanel { - return panel { - row { - cell(promptTextField) - } - row { - comment(CodeGPTBundle.get("editCodePopover.textField.comment")) - } - row { - button( - CodeGPTBundle.get("editCodePopover.submitButton.title"), - observableProperties.submitted.not(), - ) - button( - CodeGPTBundle.get("editCodePopover.followUpButton.title"), - observableProperties.submitted, - ) - button(CodeGPTBundle.get("editCodePopover.acceptButton.title")) { - submissionHandler.handleAccept() - popup.cancel() - } - .visibleIf(observableProperties.submitted) - .enabledIf(observableProperties.loading.not()) - cell(AsyncProcessIcon("edit_code_spinner")).visibleIf(observableProperties.loading) - link(CodeGPTBundle.get("shared.discard")) { - submissionHandler.handleReject() - popup.cancel() - } - .align(AlignX.RIGHT) - .visibleIf(observableProperties.submitted) - } - separator() - row { - text(CodeGPTBundle.get("shared.escToCancel")) - .applyToComponent { - font = JBUI.Fonts.smallFont() - } - cell( - SettingsModelComboBoxAction( - FeatureType.EDIT_CODE, - ModelSettings.getInstance().getModelSelection(FeatureType.EDIT_CODE), - {} - ).createCustomComponent(ActionPlaces.UNKNOWN) - ).align(AlignX.RIGHT) - } - }.apply { - border = JBUI.Borders.empty(8, 8, 2, 8) - } - } - - private fun Row.button(title: String, visibleIf: ObservableProperty): Cell { - val button = JButton(title).apply { - putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) - addActionListener { - handleSubmit() - } - } - return cell(button) - .visibleIf(visibleIf) - .enabledIf( - EnabledButtonComponentPredicate( - button, - editor, - promptTextField, - observableProperties - ) - ) - } - - private fun handleSubmit() { - serviceScope.launch { - submissionHandler.handleSubmit(promptTextField.text) - promptTextField.text = "" - promptTextField.emptyText.text = - CodeGPTBundle.get("editCodePopover.textField.followUp.emptyText") - } - } - - private class EnabledButtonComponentPredicate( - private val button: JButton, - private val editor: Editor, - private val promptTextField: JBTextField, - private val observableProperties: ObservableProperties - ) : ComponentPredicate() { - override fun invoke(): Boolean { - if (!editor.selectionModel.hasSelection()) { - button.toolTipText = "Please select code to continue" - } - if (promptTextField.text.isEmpty()) { - button.toolTipText = "Please enter a prompt to continue" - } - - return editor.selectionModel.hasSelection() - && promptTextField.text.isNotEmpty() - && observableProperties.loading.get().not() - } - - override fun addListener(listener: (Boolean) -> Unit) { - promptTextField.document.addDocumentListener(object : DocumentAdapter() { - override fun textChanged(e: DocumentEvent) { - runInEdt { listener(invoke()) } - } - }) - editor.selectionModel.addSelectionListener(object : SelectionListener { - override fun selectionChanged(e: SelectionEvent) { - runInEdt { listener(invoke()) } - } - }) - observableProperties.loading.afterSet { - runInEdt { listener(invoke()) } - } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/InlineEditPopover.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/InlineEditPopover.kt new file mode 100644 index 00000000..fd036a50 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/InlineEditPopover.kt @@ -0,0 +1,348 @@ +package ee.carlrobert.codegpt.ui + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.application.runInEdt +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 +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerEvent +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.observable.properties.AtomicBooleanProperty +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key +import com.intellij.ui.awt.RelativePoint +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import ee.carlrobert.codegpt.CodeGPTKeys +import ee.carlrobert.codegpt.ReferencedFile +import ee.carlrobert.codegpt.inlineedit.InlineEditConversationManager +import ee.carlrobert.codegpt.inlineedit.InlineEditSubmissionHandler +import ee.carlrobert.codegpt.conversations.Conversation +import ee.carlrobert.codegpt.conversations.message.Message +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.textarea.TotalTokensPanel +import ee.carlrobert.codegpt.ui.textarea.UserInputPanel +import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorTagDetails +import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails +import ee.carlrobert.codegpt.ui.textarea.header.tag.HistoryTagDetails +import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails +import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager +import ee.carlrobert.codegpt.ui.textarea.header.tag.DiagnosticsTagDetails +import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor +import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory +import ee.carlrobert.codegpt.util.GitUtil +import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers +import kotlinx.coroutines.* +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Dimension +import java.awt.Graphics +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingUtilities + +data class ObservableProperties( + val submitted: AtomicBooleanProperty = AtomicBooleanProperty(false), + val accepted: AtomicBooleanProperty = AtomicBooleanProperty(false), + val loading: AtomicBooleanProperty = AtomicBooleanProperty(false), + val hasPendingChanges: AtomicBooleanProperty = AtomicBooleanProperty(false), +) + +class InlineEditPopover(private var editor: Editor) : Disposable { + + companion object { + val POPOVER_KEY: Key = Key.create("InlineEditPopover") + private val logger = thisLogger() + } + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val observableProperties = ObservableProperties() + private val tagManager = TagManager(this) + private var changesAccepted = false + private var submissionHandler = InlineEditSubmissionHandler(editor, observableProperties) + + private val project = editor.project!! + + private val psiStructureRepository = PsiStructureRepository( + this, + editor.project!!, + tagManager, + PsiStructureProvider(), + CoroutineDispatchers() + ) + + private val dummyTokensPanel = TotalTokensPanel( + Conversation(), + null, + this, + psiStructureRepository + ) + + private val userInputPanel = UserInputPanel( + project = editor.project!!, + totalTokensPanel = dummyTokensPanel, + parentDisposable = this, + featureType = FeatureType.INLINE_EDIT, + tagManager = tagManager, + onSubmit = { text -> + handleSubmit(text) + }, + onStop = { + submissionHandler.handleReject(clearConversation = false) + }, + onAcceptAll = { + editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION)?.acceptAll() + ?: editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER)?.acceptAll() + }, + onRejectAll = { + val session = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_SESSION) + val renderer = editor.getUserData(CodeGPTKeys.EDITOR_INLINE_EDIT_RENDERER) + + if (session != null) { + session.rejectAll() + submissionHandler.restorePreviousPrompt() + } else if (renderer != null) { + renderer.rejectAll() + submissionHandler.restorePreviousPrompt() + } + }, + showModeSelector = false, + withRemovableSelectedEditorTag = false + ).apply { + isOpaque = true + setInlineEditControlsVisible(false) + setThinkingVisible(false) + } + + private val userInputWrapper = BorderLayoutPanel().apply { + isOpaque = false + border = JBUI.Borders.empty() + add(userInputPanel, BorderLayout.CENTER) + }.andTransparent() + + private val mainContainer = object : JPanel() { + init { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + background = null + add(userInputWrapper) + } + + override fun paintComponent(g: Graphics?) { + } + } + + private val popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(mainContainer, userInputPanel) + .setMovable(true) + .setResizable(true) + .setCancelKeyEnabled(true) + .setCancelOnClickOutside(false) + .setCancelOnWindowDeactivation(false) + .setRequestFocus(true) + .setMinSize(Dimension(600, 80)) + .setShowBorder(false) + .setShowShadow(false) + .setAdText("") + .setCancelCallback { + if (!changesAccepted) { + submissionHandler.handleReject(clearConversation = true) + } + true + } + .createPopup() + + init { + Disposer.register(popup, this) + userInputPanel.requestFocus() + + editor.putUserData(POPOVER_KEY, this) + } + + fun show() { + val point = computePopupPoint(editor) + popup.show(point) + + invokeLater { + userInputPanel.requestFocus() + + SwingUtilities.getWindowAncestor(popup.content)?.let { window -> + try { + window.background = Color(0, 0, 0, 0) + + if (window is javax.swing.JWindow) { + window.contentPane.background = Color(0, 0, 0, 0) + if (window.contentPane is JComponent) { + (window.contentPane as JComponent).isOpaque = false + } + } + } catch (e: Exception) { + logger.error("Failed to make window transparent: ${e.message}") + } + } + } + + project.messageBus.connect(this).subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + object : FileEditorManagerListener { + override fun selectionChanged(event: FileEditorManagerEvent) { + val newEditor = + FileEditorManager.getInstance(project).selectedTextEditor ?: return + if (newEditor === this@InlineEditPopover.editor) return + attachToEditor(newEditor) + } + } + ) + } + + override fun dispose() { + serviceScope.cancel() + editor.putUserData(POPOVER_KEY, null) + } + + fun onCompletionFinished() { + runInEdt { + userInputPanel.setSubmitEnabled(true) + observableProperties.submitted.set(false) + setThinkingVisible(false) + } + } + + fun markChangesAsAccepted() { + changesAccepted = true + observableProperties.accepted.set(true) + FileDocumentManager.getInstance().saveDocument(editor.document) + } + + fun setInlineEditControlsVisible(visible: Boolean) { + userInputPanel.setInlineEditControlsVisible(visible) + } + + fun setThinkingVisible(visible: Boolean, text: String = "Thinking…") { + userInputPanel.setThinkingVisible(visible, text) + } + + fun restorePromptAndFocus(promptText: String) { + runInEdt { + userInputPanel.setTextAndFocus(promptText) + } + } + + fun triggerPromptRestoration() { + submissionHandler.restorePreviousPrompt() + } + + private fun handleSubmit(text: String) { + if (text.isNotEmpty()) { + observableProperties.submitted.set(true) + userInputPanel.setSubmitEnabled(false) + + serviceScope.launch { + try { + val refs = collectSelectedReferencedFiles() + val diff = try { + GitUtil.getCurrentChanges(editor.project!!) + } catch (_: Exception) { + null + } + val conversationHistory = collectConversationHistory() + val diagnosticsInfo = collectDiagnosticsInfo() + submissionHandler.handleSubmit( + text, + refs, + diff, + conversationHistory, + diagnosticsInfo + ) + } catch (e: Exception) { + logger.error("Error submitting inline edit", e) + runInEdt { + userInputPanel.setSubmitEnabled(true) + observableProperties.submitted.set(false) + } + } + } + } + } + + private fun collectConversationHistory(): List { + val tags: Set = tagManager.getTags() + return tags + .filter { it.selected && it is HistoryTagDetails } + .map { (it as HistoryTagDetails).conversationId } + .mapNotNull { ConversationTagProcessor.getConversation(it) } + .distinct() + } + + private fun collectSelectedReferencedFiles(): List { + val tags: Set = tagManager.getTags() + val currentPath = editor.virtualFile?.path + val selectedVfs = tags + .filter { it.selected } + .mapNotNull { + when (it) { + is FileTagDetails -> it.virtualFile + is EditorTagDetails -> it.virtualFile + else -> null + } + } + .filter { vf -> vf.path != currentPath } + .distinctBy { it.path } + + return selectedVfs.mapNotNull { v -> + try { + ReferencedFile.from(v) + } catch (_: Exception) { + null + } + } + } + + private fun collectDiagnosticsInfo(): String? { + val tags: Set = tagManager.getTags() + val diagnosticsTag = + tags.firstOrNull { it.selected && it is DiagnosticsTagDetails } as? DiagnosticsTagDetails + ?: return null + + val processor = TagProcessorFactory.getProcessor(project, diagnosticsTag) + val stringBuilder = StringBuilder() + processor.process(Message("", ""), stringBuilder) + return stringBuilder.toString().takeIf { it.isNotBlank() } + } + + private fun computePopupPoint(targetEditor: Editor): RelativePoint { + val editorComponent = targetEditor.component + val popupWidth = 600 + val popupHeight = mainContainer.preferredSize.height.coerceAtLeast(150) + + val editorWidth = editorComponent.width + var x = (editorWidth - popupWidth) / 2 + if (x < 0) x = 0 + val paddingFromBottom = 20 + val y = editorComponent.height - popupHeight - paddingFromBottom + return RelativePoint(editorComponent, java.awt.Point(x, y)) + } + + private fun attachToEditor(newEditor: Editor) { + val oldEx = this.editor as? EditorEx + val newEx = newEditor as? EditorEx + + this.editor.putUserData(POPOVER_KEY, null) + this.editor = newEditor + this.editor.putUserData(POPOVER_KEY, this) + + InlineEditConversationManager.moveConversation(oldEx, newEx) + + submissionHandler = InlineEditSubmissionHandler(newEditor, observableProperties) + + val point = computePopupPoint(newEditor) + popup.setLocation(point.screenPoint) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/components/BadgeChip.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/components/BadgeChip.kt new file mode 100644 index 00000000..c9e2c384 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/components/BadgeChip.kt @@ -0,0 +1,58 @@ +package ee.carlrobert.codegpt.ui.components + +import com.intellij.ui.JBColor +import com.intellij.util.ui.JBUI +import java.awt.* +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JComponent + +/** + * A small rounded "pill" button used in inline edit UIs. + * Matches the visual feel of the Y/N badges shown in diff blocks. + */ +class BadgeChip( + private val text: String, + private val backgroundColor: JBColor, + private val onClick: () -> Unit, + private val fixedHeight: Int = JBUI.scale(18), + private val horizontalPadding: Int = JBUI.scale(8), + private val cornerRadius: Int = JBUI.scale(8), + private val textColor: JBColor = JBColor(Color(0xDF, 0xE1, 0xE5), Color(0xDF, 0xE1, 0xE5)) +) : JComponent() { + + init { + cursor = Cursor(Cursor.HAND_CURSOR) + toolTipText = text + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent?) { + onClick() + } + }) + isOpaque = false + } + + override fun getPreferredSize(): Dimension { + val fm = getFontMetrics(font) + val w = fm.stringWidth(text) + horizontalPadding * 2 + return Dimension(w, fixedHeight) + } + + override fun paintComponent(g: Graphics) { + val g2 = g as Graphics2D + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val r = Rectangle(0, 0, width, height) + + g2.color = backgroundColor + g2.fillRoundRect(r.x, r.y, r.width - 1, r.height - 1, cornerRadius, cornerRadius) + g2.color = backgroundColor.darker() + g2.drawRoundRect(r.x, r.y, r.width - 1, r.height - 1, cornerRadius, cornerRadius) + + g2.font = font + g2.color = textColor + val fm = g2.fontMetrics + val tx = (width - fm.stringWidth(text)) / 2 + val ty = (height - fm.height) / 2 + fm.ascent + g2.drawString(text, tx, ty) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/components/InlineEditChips.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/components/InlineEditChips.kt new file mode 100644 index 00000000..0c26bf53 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/components/InlineEditChips.kt @@ -0,0 +1,96 @@ +package ee.carlrobert.codegpt.ui.components + +import com.intellij.openapi.actionSystem.KeyboardShortcut +import com.intellij.openapi.keymap.KeymapManager +import com.intellij.openapi.util.SystemInfo +import com.intellij.ui.JBColor +import ee.carlrobert.codegpt.CodeGPTBundle +import java.awt.Color +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import javax.swing.KeyStroke + +object InlineEditChips { + + val GREEN = JBColor(Color(0x00, 0x99, 0x00), Color(0x00, 0x99, 0x00)) + val RED = JBColor(Color(0xD0, 0x36, 0x36), Color(0xD0, 0x36, 0x36)) + val TEXT = JBColor(Color(0xDF, 0xE1, 0xE5), Color(0xDF, 0xE1, 0xE5)) + + fun keyY(onClick: () -> Unit) = BadgeChip( + currentShortcutLabel( + actionId = "CodeGPT.AcceptCurrentInlineEdit", + preferredKeyCode = KeyEvent.VK_Y, + macFallback = "⌘Y", + otherFallback = "Ctrl+Y" + ), + GREEN, + onClick, + textColor = TEXT + ) + + fun keyN(onClick: () -> Unit) = BadgeChip( + currentShortcutLabel( + actionId = "CodeGPT.RejectCurrentInlineEdit", + preferredKeyCode = KeyEvent.VK_N, + macFallback = "⌘N", + otherFallback = "Ctrl+N" + ), + RED, + onClick, + textColor = TEXT + ) + + private fun currentShortcutLabel( + actionId: String, + preferredKeyCode: Int, + macFallback: String, + otherFallback: String + ): String { + val fb = if (SystemInfo.isMac) macFallback else otherFallback + return try { + val keymap = KeymapManager.getInstance().activeKeymap + val shortcuts = keymap.getShortcuts(actionId) + val preferred = preferredShortcut(shortcuts, preferredKeyCode) + ?: shortcuts.firstOrNull() as? KeyboardShortcut + if (preferred != null) keyStrokeToLabel(preferred.firstKeyStroke) else fb + } catch (_: Exception) { + fb + } + } + + private fun preferredShortcut( + shortcuts: Array, + keyCode: Int + ): KeyboardShortcut? { + val macKs = KeyStroke.getKeyStroke(keyCode, InputEvent.META_DOWN_MASK) + val winKs = KeyStroke.getKeyStroke(keyCode, InputEvent.CTRL_DOWN_MASK) + return shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == macKs } as? KeyboardShortcut + ?: shortcuts.firstOrNull { (it as? KeyboardShortcut)?.firstKeyStroke == winKs } as? KeyboardShortcut + } + + private fun keyStrokeToLabel(ks: KeyStroke): String { + return if (SystemInfo.isMac) { + buildString { + if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) append('⌘') + if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) append('⇧') + if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) append('⌥') + if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) append('⌃') + append(KeyEvent.getKeyText(ks.keyCode).uppercase()) + } + } else { + val parts = mutableListOf() + if (ks.modifiers and InputEvent.CTRL_DOWN_MASK != 0) parts.add("Ctrl") + if (ks.modifiers and InputEvent.SHIFT_DOWN_MASK != 0) parts.add("Shift") + if (ks.modifiers and InputEvent.ALT_DOWN_MASK != 0) parts.add("Alt") + if (ks.modifiers and InputEvent.META_DOWN_MASK != 0) parts.add("Meta") + parts.add(KeyEvent.getKeyText(ks.keyCode).uppercase()) + parts.joinToString("+") + } + } + + fun acceptAll(onClick: () -> Unit) = + BadgeChip(CodeGPTBundle.get("shared.acceptAll"), GREEN, onClick, textColor = TEXT) + + fun rejectAll(onClick: () -> Unit) = + BadgeChip(CodeGPTBundle.get("shared.rejectAll"), RED, onClick, textColor = TEXT) +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt index c1657149..a0403519 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt @@ -20,6 +20,7 @@ import com.intellij.openapi.wm.ToolWindowManager import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.EditorTextField import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.CodeGPTKeys.IS_PROMPT_TEXT_FIELD_DOCUMENT import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager @@ -60,6 +61,8 @@ class PromptTextField( isOneLineMode = false IS_PROMPT_TEXT_FIELD_DOCUMENT.set(document, true) setPlaceholder(CodeGPTBundle.get("toolwindow.chat.textArea.emptyText")) + + putClientProperty(UIUtil.HIDE_EDITOR_FROM_DATA_CONTEXT_PROPERTY, true) } override fun onEditorAdded(editor: Editor) { @@ -85,6 +88,13 @@ class PromptTextField( } } + fun setTextAndFocus(text: String) { + runInEdt { + this.text = text + requestFocusInWindow() + } + } + suspend fun showGroupLookup() { val lookupItems = searchManager.getDefaultGroups() .map { it.createLookupElement() } @@ -349,6 +359,11 @@ class PromptTextField( } private fun adjustHeight(editor: EditorEx) { + val toolWindow = project.service().getToolWindow("ProxyAI") + if (toolWindow == null || !toolWindow.component.isAncestorOf(this)) { + return + } + val contentHeight = editor.contentComponent.preferredSize.height + PromptTextFieldConstants.HEIGHT_PADDING val maxHeight = JBUI.scale(getToolWindowHeight() / 2) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt index f806d7f5..f0692b13 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldEventDispatcher.kt @@ -23,17 +23,25 @@ class PromptTextFieldEventDispatcher( override fun dispatch(e: AWTEvent): Boolean { if ((e is KeyEvent || e is MouseEvent) && findParent() is PromptTextField) { if (e is KeyEvent) { - if (e.id == KeyEvent.KEY_PRESSED && e.keyCode == KeyEvent.VK_BACK_SPACE) { - onBackSpace() - } + if (e.id == KeyEvent.KEY_PRESSED) { + when (e.keyCode) { + KeyEvent.VK_BACK_SPACE -> { + if (!handleBackspace(e)) { + onBackSpace() + } + } - if (e.id == KeyEvent.KEY_PRESSED && e.keyCode == KeyEvent.VK_ENTER) { - if (e.isShiftDown) { - handleShiftEnter(e) - } else if (e.modifiersEx and InputEvent.ALT_DOWN_MASK == 0 - && e.modifiersEx and InputEvent.CTRL_DOWN_MASK == 0 - ) { - onSubmit(e) + KeyEvent.VK_DELETE -> handleDelete(e) + KeyEvent.VK_A -> if (e.isControlDown || e.isMetaDown) handleSelectAll(e) + KeyEvent.VK_ENTER -> { + if (e.isShiftDown) { + handleShiftEnter(e) + } else if (e.modifiersEx and InputEvent.ALT_DOWN_MASK == 0 + && e.modifiersEx and InputEvent.CTRL_DOWN_MASK == 0 + ) { + onSubmit(e) + } + } } } @@ -72,4 +80,82 @@ class PromptTextFieldEventDispatcher( e.consume() } } + + private fun handleSelectAll(e: KeyEvent) { + val parent = findParent() + if (parent is PromptTextField) { + parent.editor?.let { editor -> + editor.selectionModel.setSelection(0, editor.document.textLength) + e.consume() + } + } + } + + private fun handleDelete(e: KeyEvent) { + val parent = findParent() + if (parent is PromptTextField) { + parent.editor?.let { editor -> + runUndoTransparentWriteAction { + val document = editor.document + val caretModel = editor.caretModel + val selectionModel = editor.selectionModel + + if (selectionModel.hasSelection()) { + document.deleteString( + selectionModel.selectionStart, + selectionModel.selectionEnd + ) + } else { + val offset = caretModel.offset + if (offset < document.textLength) { + document.deleteString(offset, offset + 1) + } + } + } + e.consume() + } + } + } + + private fun handleBackspace(e: KeyEvent): Boolean { + val parent = findParent() + if (parent is PromptTextField) { + parent.editor?.let { editor -> + val selectionModel = editor.selectionModel + if (selectionModel.hasSelection()) { + runUndoTransparentWriteAction { + editor.document.deleteString( + selectionModel.selectionStart, + selectionModel.selectionEnd + ) + } + e.consume() + return true + } else if (e.isControlDown || e.isMetaDown) { + runUndoTransparentWriteAction { + val document = editor.document + val caretModel = editor.caretModel + val offset = caretModel.offset + if (offset > 0) { + val text = document.text + var wordStart = offset - 1 + + while (wordStart > 0 && Character.isWhitespace(text[wordStart])) { + wordStart-- + } + + while (wordStart > 0 && !Character.isWhitespace(text[wordStart - 1])) { + wordStart-- + } + + document.deleteString(wordStart, offset) + } + } + e.consume() + return true + } + } + } + return false + } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/TagProcessorFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/TagProcessorFactory.kt index b7fb86fa..7d344cc9 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/TagProcessorFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/TagProcessorFactory.kt @@ -369,4 +369,4 @@ class DiagnosticsTagProcessor( else -> 4 } } -} \ 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 85f7fd85..7ff527dc 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt @@ -18,7 +18,9 @@ import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.RightGap import com.intellij.ui.dsl.builder.panel import com.intellij.util.IconUtil +import com.intellij.util.ui.AsyncProcessIcon import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.Icons import ee.carlrobert.codegpt.ReferencedFile @@ -30,6 +32,7 @@ import ee.carlrobert.codegpt.settings.service.ServiceType 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.dnd.FileDragAndDrop import ee.carlrobert.codegpt.ui.textarea.header.UserInputHeaderPanel import ee.carlrobert.codegpt.ui.textarea.header.tag.* @@ -41,16 +44,46 @@ import java.awt.* import java.awt.geom.Area import java.awt.geom.Rectangle2D import java.awt.geom.RoundRectangle2D +import javax.swing.JComponent import javax.swing.JPanel -class UserInputPanel( +class UserInputPanel @JvmOverloads constructor( private val project: Project, private val totalTokensPanel: TotalTokensPanel, parentDisposable: Disposable, + featureType: FeatureType, private val tagManager: TagManager, private val onSubmit: (String) -> Unit, - private val onStop: () -> Unit -) : JPanel(BorderLayout()) { + private val onStop: () -> Unit, + private val onAcceptAll: (() -> Unit)? = null, + private val onRejectAll: (() -> Unit)? = null, + private val showModeSelector: Boolean = true, + withRemovableSelectedEditorTag: Boolean = false, +) : BorderLayoutPanel() { + + constructor( + project: Project, + totalTokensPanel: TotalTokensPanel, + parentDisposable: Disposable, + featureType: FeatureType, + tagManager: TagManager, + onSubmit: (String) -> Unit, + onStop: () -> Unit, + showModeSelector: Boolean, + withRemovableSelectedEditorTag: Boolean + ) : this( + project, + totalTokensPanel, + parentDisposable, + featureType, + tagManager, + onSubmit, + onStop, + null, + null, + showModeSelector, + withRemovableSelectedEditorTag + ) companion object { private const val CORNER_RADIUS = 16 @@ -60,12 +93,12 @@ class UserInputPanel( private val disposableCoroutineScope = DisposableCoroutineScope() private val promptTextField = PromptTextField( - project, - tagManager, - ::updateUserTokens, - ::handleBackSpace, - ::handleLookupAdded, - ::handleSubmit, + project = project, + tagManager = tagManager, + onTextChanged = ::updateUserTokens, + onBackSpace = ::handleBackSpace, + onLookupAdded = ::handleLookupAdded, + onSubmit = ::handleSubmit, onFilesDropped = { files -> includeFiles(files.toMutableList()) totalTokensPanel.updateReferencedFilesTokens(files.map { ReferencedFile.from(it).fileContent() }) @@ -76,8 +109,28 @@ class UserInputPanel( project, tagManager, totalTokensPanel, - promptTextField + promptTextField, + withRemovableSelectedEditorTag ) + + private val acceptChip = + InlineEditChips.acceptAll { onAcceptAll?.invoke() }.apply { isVisible = false } + private val rejectChip = + InlineEditChips.rejectAll { onRejectAll?.invoke() }.apply { isVisible = false } + 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 { + foreground = service().globalScheme.defaultForeground + isVisible = false + } + private val thinkingPanel = + JPanel(FlowLayout(FlowLayout.LEFT, 4, 0)).apply { + isOpaque = false + add(thinkingIcon) + add(thinkingLabel) + isVisible = false + } private val submitButton = IconActionButton( object : AnAction( CodeGPTBundle.get("smartTextPane.submitButton.title"), @@ -104,6 +157,9 @@ class UserInputPanel( ).apply { isEnabled = false } private val imageActionSupported = AtomicBooleanProperty(isImageActionSupported()) + private lateinit var modelComboBoxComponent: JComponent + private var searchReplaceToggleComponent: JComponent? = null + val text: String get() = promptTextField.text @@ -115,7 +171,7 @@ class UserInputPanel( init { setupDisposables(parentDisposable) - setupLayout() + setupLayout(featureType) addSelectedEditorContent() FileDragAndDrop.install(this) { files -> includeFiles(files.toMutableList()) @@ -128,21 +184,18 @@ class UserInputPanel( Disposer.register(parentDisposable, promptTextField) } - private fun setupLayout() { + private fun setupLayout(featureType: FeatureType) { background = service().globalScheme.defaultBackground - add(userInputHeaderPanel, BorderLayout.NORTH) - add(promptTextField, BorderLayout.CENTER) - add(createFooterPanel(), BorderLayout.SOUTH) + addToTop(userInputHeaderPanel) + addToCenter(promptTextField) + addToBottom(createFooterPanel(featureType)) } private fun addSelectedEditorContent() { EditorUtil.getSelectedEditor(project)?.let { editor -> if (EditorUtil.hasSelection(editor)) { tagManager.addTag( - EditorSelectionTagDetails( - editor.virtualFile, - editor.selectionModel - ) + EditorSelectionTagDetails(editor.virtualFile, editor.selectionModel) ) } } @@ -222,6 +275,10 @@ class UserInputPanel( } } + fun setTextAndFocus(text: String) { + promptTextField.setTextAndFocus(text) + } + override fun paintComponent(g: Graphics) { val g2 = g.create() as Graphics2D try { @@ -295,7 +352,11 @@ class UserInputPanel( private fun handleBackSpace() { if (text.isEmpty()) { - userInputHeaderPanel.getLastTag()?.let { tagManager.remove(it) } + userInputHeaderPanel.getLastTag()?.let { last -> + if (last.isRemovable) { + tagManager.remove(last) + } + } } } @@ -313,16 +374,25 @@ class UserInputPanel( } } - private fun createFooterPanel(): JPanel { - val currentService = ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT) + private fun createFooterPanel(featureType: FeatureType): JPanel { + val currentService = + ModelSelectionService.getInstance().getServiceForFeature(featureType) val modelComboBox = ModelComboBoxAction( project, { imageActionSupported.set(isImageActionSupported()) }, - currentService + currentService, + ServiceType.entries, + true, + featureType ).createCustomComponent(ActionPlaces.UNKNOWN) + modelComboBoxComponent = modelComboBox - val searchReplaceToggle = + val searchReplaceToggle = if (showModeSelector) { SearchReplaceToggleAction(this).createCustomComponent(ActionPlaces.UNKNOWN) + } else { + null + } + searchReplaceToggleComponent = searchReplaceToggle return panel { twoColumnsRow( @@ -330,8 +400,13 @@ class UserInputPanel( panel { row { cell(modelComboBox).gap(RightGap.SMALL) - cell(createToolbarSeparator()).gap(RightGap.SMALL) - cell(searchReplaceToggle) + cell(thinkingPanel).gap(RightGap.SMALL) + cell(acceptChip).gap(RightGap.SMALL) + cell(rejectChip).gap(RightGap.SMALL) + if (showModeSelector) { + cell(createToolbarSeparator()).gap(RightGap.SMALL) + cell(searchReplaceToggle!!) + } } }.align(AlignX.LEFT) }, @@ -346,10 +421,27 @@ class UserInputPanel( }.andTransparent() } + fun setInlineEditControlsVisible(visible: Boolean) { + inlineEditControls.forEach { it.isVisible = visible } + revalidate() + repaint() + } + + + fun setThinkingVisible(visible: Boolean, text: String = "Thinking…") { + thinkingLabel.text = text + thinkingIcon.isVisible = visible + thinkingLabel.isVisible = visible + thinkingPanel.isVisible = visible + revalidate() + repaint() + } + private fun isImageActionSupported(): Boolean { val currentModel = ModelSelectionService.getInstance().getModelForFeature(FeatureType.CHAT) - val currentService = ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT) - + val currentService = + ModelSelectionService.getInstance().getServiceForFeature(FeatureType.CHAT) + return when (currentService) { ServiceType.CUSTOM_OPENAI, ServiceType.ANTHROPIC, 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 9ec2e3e2..651abcf4 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 @@ -40,7 +40,8 @@ class UserInputHeaderPanel( private val project: Project, private val tagManager: TagManager, private val totalTokensPanel: TotalTokensPanel, - private val promptTextField: PromptTextField + private val promptTextField: PromptTextField, + private val withRemovableSelectedEditorTag: Boolean ) : JPanel(WrapLayout(FlowLayout.LEFT, 4, 4)), TagManagerListener { companion object { @@ -171,7 +172,7 @@ class UserInputHeaderPanel( private fun addInitialTags() { val selectedFile = getSelectedEditor(project)?.virtualFile if (selectedFile != null) { - tagManager.addTag(EditorTagDetails(selectedFile)) + tagManager.addTag(EditorTagDetails(selectedFile, isRemovable = withRemovableSelectedEditorTag)) } EditorUtil.getOpenLocalFiles(project) @@ -226,7 +227,8 @@ class UserInputHeaderPanel( } override fun paintComponent(g: Graphics) { - PaintUtil.drawRoundedBackground(g, this, true) + val selectedVisually = isEnabled + PaintUtil.drawRoundedBackground(g, this, selectedVisually) super.paintComponent(g) } } @@ -260,12 +262,27 @@ class UserInputHeaderPanel( private inner class FileSelectionListener : FileEditorManagerListener { override fun selectionChanged(event: FileEditorManagerEvent) { event.newFile?.let { newFile -> - val containsTag = tagManager.getTags() - .none { it is EditorTagDetails && it.virtualFile == newFile } - if (containsTag) { - tagManager.addTag(EditorTagDetails(newFile).apply { selected = false }) + val hasTag = tagManager.getTags() + .any { it is EditorTagDetails && it.virtualFile == newFile } + if (!hasTag) { + tagManager.addTag(EditorTagDetails(newFile, isRemovable = false)) + } else { + tagManager.getTags() + .filterIsInstance() + .firstOrNull { it.virtualFile == newFile && it.isRemovable }?.let { existing -> + tagManager.remove(existing) + tagManager.addTag(EditorTagDetails(newFile, isRemovable = false)) + } } + tagManager.getTags() + .filterIsInstance() + .filter { it.virtualFile != newFile && !it.isRemovable } + .forEach { prev -> + tagManager.remove(prev) + tagManager.addTag(EditorTagDetails(prev.virtualFile).apply { selected = false }) + } + emptyText.isVisible = false } } @@ -340,6 +357,7 @@ class UserInputHeaderPanel( override fun show(invoker: Component, x: Int, y: Int) { if (invoker is TagPanel) { + if (!invoker.isEnabled) return val components = this@UserInputHeaderPanel.components.filterIsInstance() val currentIndex = components.indexOf(invoker) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagDetails.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagDetails.kt index 08ed3c94..a3d1d9ea 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagDetails.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagDetails.kt @@ -14,7 +14,8 @@ sealed class TagDetails( val name: String, val icon: Icon? = null, val id: UUID = UUID.randomUUID(), - val createdOn: Long = System.currentTimeMillis() + val createdOn: Long = System.currentTimeMillis(), + val isRemovable: Boolean = true ) { var selected: Boolean = true @@ -30,8 +31,8 @@ sealed class TagDetails( } } -class EditorTagDetails(val virtualFile: VirtualFile) : - TagDetails(virtualFile.name, virtualFile.fileType.icon) { +class EditorTagDetails(val virtualFile: VirtualFile, isRemovable: Boolean = true) : + TagDetails(virtualFile.name, virtualFile.fileType.icon, isRemovable = isRemovable) { private val type: String = "EditorTagDetails" @@ -134,4 +135,4 @@ class EmptyTagDetails : TagDetails("") class CodeAnalyzeTagDetails : TagDetails("Code Analyze", AllIcons.Actions.DependencyAnalyzer) data class DiagnosticsTagDetails(val virtualFile: VirtualFile) : - TagDetails("${virtualFile.name} Problems", AllIcons.General.InspectionsEye) \ No newline at end of file + TagDetails("${virtualFile.name} Problems", AllIcons.General.InspectionsEye) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagPanel.kt index 645c65de..b0747714 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagPanel.kt @@ -27,7 +27,7 @@ abstract class TagPanel( private val label = TagLabel(tagDetails.name, tagDetails.icon, tagDetails.selected) private val closeButton = CloseButton { - isVisible = isSelected + isVisible = isSelected && tagDetails.isRemovable onClose() } private var isRevertingSelection = false @@ -41,7 +41,7 @@ abstract class TagPanel( abstract fun onClose() fun update(text: String, icon: Icon? = null) { - closeButton.isVisible = isSelected + closeButton.isVisible = isSelected && tagDetails.isRemovable label.update(text, icon, isSelected) revalidate() repaint() @@ -66,7 +66,7 @@ abstract class TagPanel( border = JBUI.Borders.empty(2, 6) cursor = Cursor(Cursor.HAND_CURSOR) isSelected = tagDetails.selected - closeButton.isVisible = isSelected + closeButton.isVisible = isSelected && tagDetails.isRemovable val gbc = GridBagConstraints().apply { gridx = 0 @@ -90,7 +90,7 @@ abstract class TagPanel( isRevertingSelection = false } - closeButton.isVisible = isSelected + closeButton.isVisible = isSelected && tagDetails.isRemovable tagDetails.selected = isSelected tagManager.notifySelectionChanged(tagDetails) label.update(isSelected) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 92819767..3b68b48e 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -107,7 +107,8 @@ - + + messages.codegpt @@ -133,6 +134,13 @@ + + + + + id="CodeGPT.ContextMenuInlineEditAction" + text="Inline Edit" + description="Edit code inline from editor's context menu" + class="ee.carlrobert.codegpt.actions.editor.InlineEditContextMenuAction"> + id="CodeGPT.FloatingMenuInlineEditAction" + text="Inline Edit" + description="Edit code inline from editor's floating menu" + class="ee.carlrobert.codegpt.actions.editor.InlineEditFloatingMenuAction"> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index 4dd7d614..a2cb8549 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -255,6 +255,7 @@ notification.compilationError.okLabel=Resolve errors notification.completionError.description=Completion failed:
%s statusBar.widget.tooltip=ProxyAI: Status shared.acceptAll=Accept All +shared.rejectAll=Reject All shared.promptTemplate=Prompt template: shared.infillPromptTemplate=Infill template: shared.apiVersion=API version: @@ -404,3 +405,9 @@ conversation.status.sortedBy=Sorted by: {0} conversation.deleteConfirmation.message=Are you sure you want to delete this conversation? conversation.deleteConfirmation.title=Delete Conversation chat.message.welcome=Hi {0}, I'm ProxyAI! You can ask me anything, but most people request help with their code. Here are a few examples of what you can ask me: +inlineEdit.status.preparingReplacement=Preparing replacement... +inlineEdit.tooltip.ready=Ready to apply changes +inlineEdit.tooltip.searching=Searching for pattern... +inlineEdit.hint.searchingFor=Searching for: {0} +inlineEdit.status.waiting=Waiting for model response… +inlineEdit.status.noChanges=No applicable changes found diff --git a/src/main/resources/prompts/core/edit-code.txt b/src/main/resources/prompts/core/edit-code.txt index 6a8cfecd..a97e3a1e 100644 --- a/src/main/resources/prompts/core/edit-code.txt +++ b/src/main/resources/prompts/core/edit-code.txt @@ -1,17 +1,63 @@ -You are a code modification assistant. Your task is to modify the provided code based on the user's instructions. +You are a code modification assistant. Generate SEARCH/REPLACE blocks to modify the specified file. -Rules: -1. Return only the modified code, with no additional text or explanations. -2. The first character of your response must be the first character of the code. -3. The last character of your response must be the last character of the code. -4. NEVER use triple backticks (```) or any other markdown formatting in your response. -5. Do not use any code block indicators, syntax highlighting markers, or any other formatting characters. -6. Present the code exactly as it would appear in a plain text editor, preserving all whitespace, indentation, and line breaks. -7. Maintain the original code structure and only make changes as specified by the user's instructions. -8. Ensure that the modified code is syntactically and semantically correct for the given programming language. -9. Use consistent indentation and follow language-specific style guidelines. -10. If the user's request cannot be translated into code changes, respond only with the word NULL (without quotes or any formatting). -11. Do not include any comments or explanations within the code unless specifically requested. -12. Assume that any necessary dependencies or libraries are already imported or available. +{{PROJECT_CONTEXT}} -IMPORTANT: Your response must NEVER begin or end with triple backticks, single backticks, or any other formatting characters. \ No newline at end of file +## Current File (Editable) +{{CURRENT_FILE_CONTEXT}} + +{{EXTERNAL_CONTEXT}} + +## Task +Analyze the user's request and generate modifications for the current file using SEARCH/REPLACE blocks. + +## Format +Use SEARCH/REPLACE blocks to specify exact code changes: + +Single change in the same file: +```language:filepath +<<<<<<< SEARCH +[exact code to find] +======= +[replacement code] +>>>>>>> REPLACE +``` + +Multiple changes in the same file: +```language:filepath +<<<<<<< SEARCH +[first section] +======= +[first replacement] +>>>>>>> REPLACE + +<<<<<<< SEARCH +[second section] +======= +[second replacement] +>>>>>>> REPLACE +``` + +## Requirements +• Match indentation and formatting exactly +• Include 2-4 lines of context for unique pattern matching +• Generate blocks only for the current file path +• Use complete code sections without truncation +• Ensure search and replacement content differ +• Include file path after language identifier using colon +• Make search patterns unique within the file + +## Example +```java:src/Example.java +<<<<<<< SEARCH +public int calculate(int x, int y) { + return x + y; +} +======= +public int calculate(int x, int y) { + if (x < 0 || y < 0) { + throw new IllegalArgumentException("Values must be non-negative"); + } + return x + y; +} +>>>>>>> REPLACE +``` \ No newline at end of file diff --git a/src/test/kotlin/ee/carlrobert/codegpt/completions/OpenAIRequestFactoryIntegrationTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/completions/OpenAIRequestFactoryIntegrationTest.kt index 3a7c3078..06aaf5d0 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/completions/OpenAIRequestFactoryIntegrationTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/completions/OpenAIRequestFactoryIntegrationTest.kt @@ -1,16 +1,22 @@ package ee.carlrobert.codegpt.completions import com.intellij.openapi.components.service +import ee.carlrobert.codegpt.ReferencedFile import ee.carlrobert.codegpt.completions.factory.OpenAIRequestFactory +import ee.carlrobert.codegpt.conversations.Conversation import ee.carlrobert.codegpt.conversations.ConversationService import ee.carlrobert.codegpt.conversations.message.Message import ee.carlrobert.codegpt.settings.configuration.ChatMode import ee.carlrobert.codegpt.settings.prompts.PersonaPromptDetailsState import ee.carlrobert.codegpt.settings.prompts.PersonasState import ee.carlrobert.codegpt.settings.prompts.PromptsSettings +import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.util.file.FileUtil.getResourceContent import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel +import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage import org.assertj.core.api.Assertions.assertThat import testsupport.IntegrationTest +import java.io.File class OpenAIRequestFactoryIntegrationTest : IntegrationTest() { @@ -297,86 +303,367 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() { .map { it.content } assertThat(systemMessages).isNotEmpty() val systemContent = systemMessages.first() - assertThat(systemContent).isEqualTo("You are an AI programming assistant integrated into a JetBrains IDE plugin. Your role is to answer coding questions, suggest new code, and perform refactoring or editing tasks. You have access to the following project information:\n" + - "\n" + - "Before we proceed with the main instructions, here is the content of relevant files in the project:\n" + - "\n" + - "\n" + - "UNDEFINED\n" + - "\n" + - "\n" + - "Instructions:\n" + - "\n" + - "1. Detect the intent behind the user's query:\n" + - " - New code suggestion\n" + - " - Technical explanation\n" + - " - Code refactoring or editing\n" + - "\n" + - "2. For queries not related to the codebase or for new files, provide a standard code or text block response.\n" + - "\n" + - "3. For refactoring or editing an existing file, always generate a SEARCH/REPLACE block.\n" + - "\n" + - "4. For any code generation, refactoring, or editing task:\n" + - " a. First, outline an implementation plan describing the steps to address the user's request.\n" + - " b. As you generate code or SEARCH/REPLACE blocks, reference the relevant step(s) from your plan, explaining your approach for each change.\n" + - " c. For complex tasks, break down the plan and code changes into smaller steps, presenting each with its rationale and code diff together.\n" + - " d. If the user's intent is unclear, ask clarifying questions before proceeding.\n" + - "\n" + - "5. When generating SEARCH/REPLACE blocks:\n" + - " a. Ensure each block represents an atomic, non-overlapping change that can be applied independently.\n" + - " b. Provide sufficient context in the SEARCH part to uniquely locate the change.\n" + - " c. Keep SEARCH blocks concise while including necessary surrounding lines.\n" + - "\n" + - "Formatting Guidelines:\n" + - "\n" + - "1. Begin with a brief, impersonal acknowledgment.\n" + - "\n" + - "2. Use the following format for code blocks:\n" + - " ```[language]:[full_file_path]\n" + - " [code content]\n" + - " ```\n" + - "\n" + - " Example:\n" + - " ```java:/path/to/Main.java\n" + - " public class Main {\n" + - " public static void main(String[] args) {\n" + - " System.out.println(\"Hello, World!\");\n" + - " }\n" + - " }\n" + - " ```\n" + - "\n" + - "3. For new files, show the entire file content in a single code fence.\n" + - "\n" + - "4. For editing existing files, use this SEARCH/REPLACE structure:\n" + - " ```[language]:[full_file_path]\n" + - " <<<<<<< SEARCH\n" + - " [exact lines from the file, including whitespace/comments]\n" + - " =======\n" + - " [replacement lines]\n" + - " >>>>>>> REPLACE\n" + - " ```\n" + - "\n" + - " Example:\n" + - " ```java:/path/to/Calculator.java\n" + - " <<<<<<< SEARCH\n" + - " public int add(int a, int b) {\n" + - " return a + b;\n" + - " }\n" + - " =======\n" + - " public int add(int a, int b) {\n" + - " // Added input validation\n" + - " if (a < 0 || b < 0) {\n" + - " throw new IllegalArgumentException(\"Negative numbers not allowed\");\n" + - " }\n" + - " return a + b;\n" + - " }\n" + - " >>>>>>> REPLACE\n" + - " ```\n" + - "\n" + - "5. Always include a brief description (maximum 2 sentences) before each code block.\n" + - "\n" + - "6. Do not provide an implementation plan for pure explanations or general questions.\n" + - "\n" + - "7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required.\n") + assertThat(systemContent).isEqualTo( + "You are an AI programming assistant integrated into a JetBrains IDE plugin. Your role is to answer coding questions, suggest new code, and perform refactoring or editing tasks. You have access to the following project information:\n" + + "\n" + + "Before we proceed with the main instructions, here is the content of relevant files in the project:\n" + + "\n" + + "\n" + + "UNDEFINED\n" + + "\n" + + "\n" + + "Instructions:\n" + + "\n" + + "1. Detect the intent behind the user's query:\n" + + " - New code suggestion\n" + + " - Technical explanation\n" + + " - Code refactoring or editing\n" + + "\n" + + "2. For queries not related to the codebase or for new files, provide a standard code or text block response.\n" + + "\n" + + "3. For refactoring or editing an existing file, always generate a SEARCH/REPLACE block.\n" + + "\n" + + "4. For any code generation, refactoring, or editing task:\n" + + " a. First, outline an implementation plan describing the steps to address the user's request.\n" + + " b. As you generate code or SEARCH/REPLACE blocks, reference the relevant step(s) from your plan, explaining your approach for each change.\n" + + " c. For complex tasks, break down the plan and code changes into smaller steps, presenting each with its rationale and code diff together.\n" + + " d. If the user's intent is unclear, ask clarifying questions before proceeding.\n" + + "\n" + + "5. When generating SEARCH/REPLACE blocks:\n" + + " a. Ensure each block represents an atomic, non-overlapping change that can be applied independently.\n" + + " b. Provide sufficient context in the SEARCH part to uniquely locate the change.\n" + + " c. Keep SEARCH blocks concise while including necessary surrounding lines.\n" + + "\n" + + "Formatting Guidelines:\n" + + "\n" + + "1. Begin with a brief, impersonal acknowledgment.\n" + + "\n" + + "2. Use the following format for code blocks:\n" + + " ```[language]:[full_file_path]\n" + + " [code content]\n" + + " ```\n" + + "\n" + + " Example:\n" + + " ```java:/path/to/Main.java\n" + + " public class Main {\n" + + " public static void main(String[] args) {\n" + + " System.out.println(\"Hello, World!\");\n" + + " }\n" + + " }\n" + + " ```\n" + + "\n" + + "3. For new files, show the entire file content in a single code fence.\n" + + "\n" + + "4. For editing existing files, use this SEARCH/REPLACE structure:\n" + + " ```[language]:[full_file_path]\n" + + " <<<<<<< SEARCH\n" + + " [exact lines from the file, including whitespace/comments]\n" + + " =======\n" + + " [replacement lines]\n" + + " >>>>>>> REPLACE\n" + + " ```\n" + + "\n" + + " Example:\n" + + " ```java:/path/to/Calculator.java\n" + + " <<<<<<< SEARCH\n" + + " public int add(int a, int b) {\n" + + " return a + b;\n" + + " }\n" + + " =======\n" + + " public int add(int a, int b) {\n" + + " // Added input validation\n" + + " if (a < 0 || b < 0) {\n" + + " throw new IllegalArgumentException(\"Negative numbers not allowed\");\n" + + " }\n" + + " return a + b;\n" + + " }\n" + + " >>>>>>> REPLACE\n" + + " ```\n" + + "\n" + + "5. Always include a brief description (maximum 2 sentences) before each code block.\n" + + "\n" + + "6. Do not provide an implementation plan for pure explanations or general questions.\n" + + "\n" + + "7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required.\n" + ) + } + + fun testInlineEditSingleRequestNoHistory() { + useOpenAIService(OpenAIChatCompletionModel.GPT_4_1.code, FeatureType.INLINE_EDIT) + val testFileContent = getResourceContent("/inline/TestClass.java") + val tempFile = File.createTempFile("TestClass", ".java") + tempFile.writeText(testFileContent) + tempFile.deleteOnExit() + val parameters = InlineEditCompletionParameters( + prompt = "add logging to this method", + selectedText = "myTestMethod()", + filePath = tempFile.absolutePath, + fileExtension = "java", + projectBasePath = project.basePath, + referencedFiles = null, + gitDiff = null, + conversation = null, + conversationHistory = null, + diagnosticsInfo = null + ) + + val request = OpenAIRequestFactory().createInlineEditRequest(parameters) + + val systemMessage = request.messages[0] as OpenAIChatCompletionStandardMessage + assertThat(systemMessage.role).isEqualTo("system") + assertThat(systemMessage.content).isEqualTo( + """ +You are a code modification assistant. Generate SEARCH/REPLACE blocks to modify the specified file. + +Project Context: +Project root: ${project.basePath} +All file paths should be relative to this project root. + +## Current File (Editable) +```java:${tempFile.absolutePath} +$testFileContent +``` + +## External Context + +No external context selected. + +## Task +Analyze the user's request and generate modifications for the current file using SEARCH/REPLACE blocks. + +## Format +Use SEARCH/REPLACE blocks to specify exact code changes: + +Single change in the same file: +```language:filepath +<<<<<<< SEARCH +[exact code to find] +======= +[replacement code] +>>>>>>> REPLACE +``` + +Multiple changes in the same file: +```language:filepath +<<<<<<< SEARCH +[first section] +======= +[first replacement] +>>>>>>> REPLACE + +<<<<<<< SEARCH +[second section] +======= +[second replacement] +>>>>>>> REPLACE +``` + +## Requirements +• Match indentation and formatting exactly +• Include 2-4 lines of context for unique pattern matching +• Generate blocks only for the current file path +• Use complete code sections without truncation +• Ensure search and replacement content differ +• Include file path after language identifier using colon +• Make search patterns unique within the file + +## Example +```java:src/Example.java +<<<<<<< SEARCH +public int calculate(int x, int y) { + return x + y; +} +======= +public int calculate(int x, int y) { + if (x < 0 || y < 0) { + throw new IllegalArgumentException("Values must be non-negative"); + } + return x + y; +} +>>>>>>> REPLACE +``` +""".trimIndent() + ) + val userMessage = request.messages[1] as OpenAIChatCompletionStandardMessage + assertThat(userMessage.role).isEqualTo("user") + assertThat(userMessage.content).isEqualTo( + """ + Selected code: + ```java + myTestMethod() + ``` + + Request: add logging to this method + """.trimIndent() + ) + } + + fun testInlineEditFollowUpWithHistory() { + useOpenAIService(OpenAIChatCompletionModel.GPT_4_1.code, FeatureType.INLINE_EDIT) + val testFileContent = getResourceContent("/inline/TestClass.java") + val tempFile = File.createTempFile("TestClass", ".java") + tempFile.writeText(testFileContent) + tempFile.deleteOnExit() + val parameters = InlineEditCompletionParameters( + prompt = "TEST_FOLLOW_UP_PROMPT", + selectedText = "myTestMethod()", + filePath = tempFile.absolutePath, + fileExtension = "java", + projectBasePath = project.basePath, + referencedFiles = mutableListOf( + ReferencedFile( + "TEST_FILE_NAME_1.java", + "/path/to/TEST_FILE_NAME_1.java", + "TEST_FILE_CONTENT_1" + ), + ReferencedFile( + "TEST_FILE_NAME_2.java", + "/path/to/TEST_FILE_NAME_2.java", + "TEST_FILE_CONTENT_2" + ) + ), + gitDiff = "TEST_GIT_DIFF", + conversation = Conversation().apply { + messages = mutableListOf( + Message("PREV_PROMPT").apply { + response = "PREV_RESPONSE" + } + ) + }, + conversationHistory = listOf( + Conversation().apply { + messages = mutableListOf( + Message("HISTORY_PROMPT_1").apply { + response = "HISTORY_RESPONSE_1" + } + ) + }, + Conversation().apply { + messages = mutableListOf( + Message("HISTORY_PROMPT_2").apply { + response = "HISTORY_RESPONSE_2" + } + ) + } + ), + diagnosticsInfo = null + ) + + val request = OpenAIRequestFactory().createInlineEditRequest(parameters) + + val systemMessage = request.messages[0] as OpenAIChatCompletionStandardMessage + assertThat(systemMessage.role).isEqualTo("system") + assertThat(systemMessage.content).isEqualTo( + """ +You are a code modification assistant. Generate SEARCH/REPLACE blocks to modify the specified file. + +Project Context: +Project root: ${project.basePath} +All file paths should be relative to this project root. + +## Current File (Editable) +```java:${tempFile.absolutePath} +$testFileContent +``` + +## External Context + +### Referenced Files + +```java:/path/to/TEST_FILE_NAME_1.java +TEST_FILE_CONTENT_1 +``` + +```java:/path/to/TEST_FILE_NAME_2.java +TEST_FILE_CONTENT_2 +``` + +### Git Diff + +```diff +TEST_GIT_DIFF +``` + +### Conversation History + +**User:** HISTORY_PROMPT_1 +**Assistant:** HISTORY_RESPONSE_1 +**User:** HISTORY_PROMPT_2 +**Assistant:** HISTORY_RESPONSE_2 + +## Task +Analyze the user's request and generate modifications for the current file using SEARCH/REPLACE blocks. + +## Format +Use SEARCH/REPLACE blocks to specify exact code changes: + +Single change in the same file: +```language:filepath +<<<<<<< SEARCH +[exact code to find] +======= +[replacement code] +>>>>>>> REPLACE +``` + +Multiple changes in the same file: +```language:filepath +<<<<<<< SEARCH +[first section] +======= +[first replacement] +>>>>>>> REPLACE + +<<<<<<< SEARCH +[second section] +======= +[second replacement] +>>>>>>> REPLACE +``` + +## Requirements +• Match indentation and formatting exactly +• Include 2-4 lines of context for unique pattern matching +• Generate blocks only for the current file path +• Use complete code sections without truncation +• Ensure search and replacement content differ +• Include file path after language identifier using colon +• Make search patterns unique within the file + +## Example +```java:src/Example.java +<<<<<<< SEARCH +public int calculate(int x, int y) { + return x + y; +} +======= +public int calculate(int x, int y) { + if (x < 0 || y < 0) { + throw new IllegalArgumentException("Values must be non-negative"); + } + return x + y; +} +>>>>>>> REPLACE +``` +""".trimIndent() + ) + val prevUserMessage = request.messages[1] as OpenAIChatCompletionStandardMessage + assertThat(prevUserMessage.role).isEqualTo("user") + assertThat(prevUserMessage.content).isEqualTo("PREV_PROMPT") + val prevResponse = request.messages[2] as OpenAIChatCompletionStandardMessage + assertThat(prevResponse.role).isEqualTo("assistant") + assertThat(prevResponse.content).isEqualTo("PREV_RESPONSE") + val followUpMessage = request.messages[3] as OpenAIChatCompletionStandardMessage + assertThat(followUpMessage.role).isEqualTo("user") + assertThat(followUpMessage.content).isEqualTo( + """ + Selected code: + ```java + myTestMethod() + ``` + + Request: TEST_FOLLOW_UP_PROMPT + """.trimIndent() + ) } } \ No newline at end of file diff --git a/src/test/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsTest.kt index 57b38977..0bf47497 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/settings/models/ModelSettingsTest.kt @@ -6,7 +6,6 @@ import com.intellij.util.messages.MessageBusConnection import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ModelChangeNotifier import ee.carlrobert.codegpt.settings.service.ServiceType -import ee.carlrobert.llm.client.codegpt.PricingPlan import org.assertj.core.api.Assertions.assertThat import testsupport.IntegrationTest import java.util.concurrent.atomic.AtomicReference @@ -42,7 +41,7 @@ class ModelSettingsTest : IntegrationTest() { lastNotification.set(NotificationData(FeatureType.COMMIT_MESSAGE, newModel, serviceType, "commitMessage")) } override fun editCodeModelChanged(newModel: String, serviceType: ServiceType) { - lastNotification.set(NotificationData(FeatureType.EDIT_CODE, newModel, serviceType, "editCode")) + lastNotification.set(NotificationData(FeatureType.INLINE_EDIT, newModel, serviceType, "editCode")) } override fun nextEditModelChanged(newModel: String, serviceType: ServiceType) { lastNotification.set(NotificationData(FeatureType.NEXT_EDIT, newModel, serviceType, "nextEdit")) @@ -121,10 +120,10 @@ class ModelSettingsTest : IntegrationTest() { fun `test setModelWithProvider with edit code triggers edit code notification`() { lastNotification.set(null) - modelSettings.setModelWithProvider(FeatureType.EDIT_CODE, "claude-4-sonnet", ServiceType.PROXYAI) + modelSettings.setModelWithProvider(FeatureType.INLINE_EDIT, "claude-4-sonnet", ServiceType.PROXYAI) val notification = lastNotification.get() - assertThat(notification!!.featureType).isEqualTo(FeatureType.EDIT_CODE) + assertThat(notification!!.featureType).isEqualTo(FeatureType.INLINE_EDIT) assertThat(notification.model).isEqualTo("claude-4-sonnet") assertThat(notification.serviceType).isEqualTo(ServiceType.PROXYAI) } diff --git a/src/test/kotlin/testsupport/mixin/ShortcutsTestMixin.kt b/src/test/kotlin/testsupport/mixin/ShortcutsTestMixin.kt index 0edd5075..031298f5 100644 --- a/src/test/kotlin/testsupport/mixin/ShortcutsTestMixin.kt +++ b/src/test/kotlin/testsupport/mixin/ShortcutsTestMixin.kt @@ -27,19 +27,16 @@ interface ShortcutsTestMixin { } } - fun useOpenAIService(chatModel: String? = "gpt-4o", role: FeatureType = FeatureType.CHAT) { + fun useOpenAIService(chatModel: String? = "gpt-4o", featureType: FeatureType = FeatureType.CHAT) { setCredential(OpenaiApiKey, "TEST_API_KEY") val modelSettings = service() - when (role) { - FeatureType.CHAT -> { - modelSettings.setModel(FeatureType.CHAT, chatModel ?: "gpt-4o", ServiceType.OPENAI) - } + when (featureType) { FeatureType.CODE_COMPLETION -> { modelSettings.setModel(FeatureType.CODE_COMPLETION, "gpt-3.5-turbo-instruct", ServiceType.OPENAI) } else -> { - modelSettings.setModel(FeatureType.CHAT, chatModel ?: "gpt-4o", ServiceType.OPENAI) + modelSettings.setModel(featureType, chatModel ?: "gpt-4o", ServiceType.OPENAI) modelSettings.setModel(FeatureType.CODE_COMPLETION, "gpt-3.5-turbo-instruct", ServiceType.OPENAI) } } diff --git a/src/test/resources/inline/TestClass.java b/src/test/resources/inline/TestClass.java new file mode 100644 index 00000000..e8ee21e6 --- /dev/null +++ b/src/test/resources/inline/TestClass.java @@ -0,0 +1,6 @@ +public class TestClass { + + public static void myTestMethod() { + System.out.println("Hello world"); + } +} \ No newline at end of file