From 4ed74a31c1d226a4306a5e567a893e2fce83452f Mon Sep 17 00:00:00 2001 From: Carl-Robert Linnupuu Date: Sun, 11 Feb 2024 01:26:55 +0200 Subject: [PATCH] feat: second set of autocomplete improvements - support typing as suggested functionality - do not fetch completions on cursor change - other minor fixes --- .../ee/carlrobert/codegpt/CodeGPTKeys.java | 2 + .../codegpt/actions/OpenSettingsAction.java | 4 +- .../CodeCompletionEventListener.java | 26 +++++-- .../CodeCompletionListenerBinder.java | 68 +++++++++++++---- .../CodeCompletionService.java | 73 +++++++++++++------ .../codecompletions/TypeOverHandler.java | 37 ++++++++++ src/main/resources/META-INF/plugin.xml | 14 +++- .../resources/messages/codegpt.properties | 6 +- .../CodeCompletionServiceTest.java | 5 +- 9 files changed, 179 insertions(+), 56 deletions(-) create mode 100644 src/main/java/ee/carlrobert/codegpt/codecompletions/TypeOverHandler.java diff --git a/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java b/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java index 89c70747..57083705 100644 --- a/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java +++ b/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java @@ -8,6 +8,8 @@ import java.util.List; public class CodeGPTKeys { + public static final Key PREVIOUS_INLAY_TEXT = + Key.create("codegpt.editor.inlay.prev-value"); public static final Key> SINGLE_LINE_INLAY = Key.create("codegpt.editor.inlay.single-line"); public static final Key> MULTI_LINE_INLAY = diff --git a/src/main/java/ee/carlrobert/codegpt/actions/OpenSettingsAction.java b/src/main/java/ee/carlrobert/codegpt/actions/OpenSettingsAction.java index 04d82265..7f4629da 100644 --- a/src/main/java/ee/carlrobert/codegpt/actions/OpenSettingsAction.java +++ b/src/main/java/ee/carlrobert/codegpt/actions/OpenSettingsAction.java @@ -11,8 +11,8 @@ import org.jetbrains.annotations.NotNull; public class OpenSettingsAction extends AnAction { public OpenSettingsAction() { - super(CodeGPTBundle.get("action.opensettings.title"), - CodeGPTBundle.get("action.opensettings.description"), + super(CodeGPTBundle.get("action.openSettings.title"), + CodeGPTBundle.get("action.openSettings.description"), General.Settings); } diff --git a/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionEventListener.java b/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionEventListener.java index 0886e1b5..1c67c8a6 100644 --- a/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionEventListener.java +++ b/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionEventListener.java @@ -1,10 +1,10 @@ package ee.carlrobert.codegpt.codecompletions; +import static ee.carlrobert.codegpt.CodeGPTKeys.PREVIOUS_INLAY_TEXT; import static java.util.Objects.requireNonNull; import com.intellij.notification.NotificationType; import com.intellij.notification.Notifications; -import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.progress.impl.BackgroundableProcessIndicator; @@ -13,7 +13,9 @@ import ee.carlrobert.codegpt.actions.OpenSettingsAction; import ee.carlrobert.codegpt.ui.OverlayUtil; import ee.carlrobert.llm.client.openai.completion.ErrorDetails; import ee.carlrobert.llm.completion.CompletionEventListener; +import java.io.IOException; import javax.annotation.ParametersAreNonnullByDefault; +import javax.swing.SwingUtilities; import org.jetbrains.annotations.Nullable; @ParametersAreNonnullByDefault @@ -40,18 +42,26 @@ class CodeCompletionEventListener implements CompletionEventListener { progressIndicator.processFinish(); } + PREVIOUS_INLAY_TEXT.set(editor, messageBuilder.toString()); CodeGPTEditorManager.getInstance().disposeEditorInlays(editor); - - var inlayText = messageBuilder.toString(); - if (!inlayText.isEmpty()) { - ApplicationManager.getApplication().invokeLater(() -> + SwingUtilities.invokeLater(() -> { + if (editor.getCaretModel().getOffset() == caretOffset) { + var inlayText = messageBuilder.toString(); + if (!inlayText.isEmpty()) { CodeCompletionService.getInstance(requireNonNull(editor.getProject())) - .addInlays(editor, caretOffset, inlayText)); - } + .addInlays(editor, caretOffset, inlayText); + } + } + }); } @Override public void onError(ErrorDetails error, Throwable ex) { + // TODO: temp fix + if (ex instanceof IOException && "Canceled".equals(error.getMessage())) { + return; + } + LOG.error(error.getMessage(), ex); if (progressIndicator != null) { progressIndicator.processFinish(); @@ -66,7 +76,7 @@ class CodeCompletionEventListener implements CompletionEventListener { @Override public void onCancelled(StringBuilder messageBuilder) { - LOG.info("Completion cancelled"); + LOG.debug("Completion cancelled"); if (progressIndicator != null) { progressIndicator.processFinish(); } diff --git a/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionListenerBinder.java b/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionListenerBinder.java index e40b2f9c..c11a24a9 100644 --- a/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionListenerBinder.java +++ b/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionListenerBinder.java @@ -1,7 +1,10 @@ package ee.carlrobert.codegpt.codecompletions; +import static ee.carlrobert.codegpt.CodeGPTKeys.PREVIOUS_INLAY_TEXT; + import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.command.CommandProcessor; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.editor.event.BulkAwareDocumentListener; import com.intellij.openapi.editor.event.CaretEvent; @@ -11,6 +14,9 @@ import com.intellij.openapi.editor.event.SelectionEvent; import com.intellij.openapi.editor.event.SelectionListener; import ee.carlrobert.codegpt.actions.CodeCompletionEnabledListener; import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings; +import java.util.List; +import java.util.regex.PatternSyntaxException; +import javax.swing.SwingUtilities; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -31,16 +37,12 @@ public class CodeCompletionListenerBinder implements Disposable { ApplicationManager.getApplication() .getMessageBus() - .connect() + .connect(this) .subscribe( CodeCompletionEnabledListener.TOPIC, (CodeCompletionEnabledListener) (completionsEnabled) -> { if (completionsEnabled) { addListeners(); - if (editor.getProject() != null) { - CodeCompletionService.getInstance(editor.getProject()) - .handleCompletions(editor, editor.getCaretModel().getOffset()); - } } else { removeListeners(); } @@ -94,14 +96,12 @@ public class CodeCompletionListenerBinder implements Disposable { @Override public void caretPositionChanged(@NotNull CaretEvent event) { - var project = editor.getProject(); - if (event.getCaret() == null || project == null) { - return; + if (!"Typing".equals(CommandProcessor.getInstance().getCurrentCommandName())) { + CodeGPTEditorManager.getInstance().disposeEditorInlays(editor); + if (editor.getProject() != null) { + CodeCompletionService.getInstance(editor.getProject()).cancelPreviousCall(); + } } - - CodeGPTEditorManager.getInstance().disposeEditorInlays(editor); - CodeCompletionService.getInstance(project) - .handleCompletions(editor, event.getCaret().getOffset()); } } @@ -109,12 +109,48 @@ public class CodeCompletionListenerBinder implements Disposable { @Override public void documentChangedNonBulk(@NotNull DocumentEvent event) { - var project = editor.getProject(); - if (project != null) { + ApplicationManager.getApplication().executeOnPooledThread(() -> { CodeGPTEditorManager.getInstance().disposeEditorInlays(editor); - CodeCompletionService.getInstance(project) - .handleCompletions(editor, editor.getCaretModel().getOffset()); + + var commandName = CommandProcessor.getInstance().getCurrentCommandName(); + if (CommandProcessor.getInstance().isUndoTransparentActionInProgress() + || isCommandExcluded(commandName)) { + return; + } + + var project = editor.getProject(); + if (project != null) { + var codeCompletionService = CodeCompletionService.getInstance(project); + var caretOffset = event.getOffset() + event.getNewLength(); + var charTyped = event.getNewFragment().toString().trim(); + SwingUtilities.invokeLater(() -> { + if (isTypingAsSuggested(charTyped)) { + try { + var previousInlayText = PREVIOUS_INLAY_TEXT.get(editor).replaceFirst(charTyped, ""); + codeCompletionService.addInlays(editor, caretOffset, previousInlayText); + } catch (PatternSyntaxException e) { + // ignore + } + } else { + codeCompletionService.handleCompletions(editor, caretOffset); + } + }); + } + }); + } + + private boolean isTypingAsSuggested(String charTyped) { + if (charTyped.isEmpty()) { + return false; } + + var prevInlay = PREVIOUS_INLAY_TEXT.get(editor); + return prevInlay != null && prevInlay.startsWith(charTyped); + } + + private boolean isCommandExcluded(String commandName) { + return commandName != null + && List.of("Up", "Down", "Left", "Right", "Move Caret").contains(commandName); } } } diff --git a/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionService.java b/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionService.java index 8b8ee100..3f1f8947 100644 --- a/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionService.java +++ b/src/main/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionService.java @@ -2,8 +2,8 @@ package ee.carlrobert.codegpt.codecompletions; import static com.intellij.openapi.components.Service.Level.PROJECT; import static ee.carlrobert.codegpt.CodeGPTKeys.MULTI_LINE_INLAY; +import static ee.carlrobert.codegpt.CodeGPTKeys.PREVIOUS_INLAY_TEXT; import static ee.carlrobert.codegpt.CodeGPTKeys.SINGLE_LINE_INLAY; -import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.toList; import com.intellij.codeInsight.lookup.LookupManager; @@ -31,6 +31,7 @@ import com.intellij.psi.PsiFile; import com.intellij.util.concurrency.annotations.RequiresEdt; import com.intellij.util.concurrency.annotations.RequiresReadLock; import com.intellij.util.concurrency.annotations.RequiresWriteLock; +import ee.carlrobert.codegpt.CodeGPTKeys; import ee.carlrobert.codegpt.actions.CodeCompletionEnabledListener; import ee.carlrobert.codegpt.completions.CompletionRequestService; import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings; @@ -39,6 +40,8 @@ import java.awt.event.KeyEvent; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Stream; import javax.annotation.ParametersAreNonnullByDefault; import javax.swing.KeyStroke; import org.jetbrains.annotations.NotNull; @@ -51,30 +54,29 @@ public final class CodeCompletionService implements Disposable { private static final Logger LOG = Logger.getInstance(CodeCompletionService.class); + private final Project project; private final CallDebouncer callDebouncer; private CodeCompletionService(Project project) { + this.project = project; this.callDebouncer = new CallDebouncer(project); - ApplicationManager.getApplication() - .getMessageBus() - .connect() - .subscribe( - CodeCompletionEnabledListener.TOPIC, - (CodeCompletionEnabledListener) (completionsEnabled) -> { - if (!completionsEnabled) { - callDebouncer.cancelPreviousCall(); - } - }); + + subscribeToFeatureToggleEvents(); } public static CodeCompletionService getInstance(Project project) { return project.getService(CodeCompletionService.class); } + public void cancelPreviousCall() { + callDebouncer.cancelPreviousCall(); + } + public void handleCompletions(Editor editor, int offset) { - Project project = editor.getProject(); - if (project == null - || project.isDisposed() + PREVIOUS_INLAY_TEXT.set(editor, null); + + if (project.isDisposed() + || TypeOverHandler.getPendingTypeOverAndReset(editor) || !ConfigurationSettings.getCurrentState().isCodeCompletionsEnabled() || !EditorUtil.isSelectedEditor(editor) || LookupManager.getActiveLookup(editor) != null @@ -90,6 +92,11 @@ public final class CodeCompletionService implements Disposable { } var request = InfillRequestDetails.fromDocumentWithMaxOffset(document, offset); + if (Stream.of(request.getSuffix(), request.getPrefix()) + .anyMatch(item -> item == null || item.isEmpty())) { + return; + } + callDebouncer.debounce( Void.class, (progressIndicator) -> CompletionRequestService.getInstance().getCodeCompletionAsync( @@ -101,9 +108,15 @@ public final class CodeCompletionService implements Disposable { @RequiresEdt public void addInlays(Editor editor, int caretOffset, String inlayText) { + PREVIOUS_INLAY_TEXT.set(editor, inlayText); + + if (LookupManager.getActiveLookup(editor) != null || inlayText.isBlank()) { + return; + } + List linesList = inlayText.lines().collect(toList()); - String firstLine = linesList.get(0); - String restOfLines = linesList.size() > 1 + var firstLine = linesList.get(0); + var restOfLines = linesList.size() > 1 ? String.join("\n", linesList.subList(1, linesList.size())) : null; InlayModel inlayModel = editor.getInlayModel(); @@ -126,7 +139,7 @@ public final class CodeCompletionService implements Disposable { } registerApplyCompletionAction(() -> WriteCommandAction.runWriteCommandAction( - editor.getProject(), + project, () -> applyCompletion(editor, inlayText))); } @@ -146,19 +159,20 @@ public final class CodeCompletionService implements Disposable { return; } } + editor.putUserData(CodeGPTKeys.PREVIOUS_INLAY_TEXT, null); } @RequiresWriteLock private void applyCompletion(Editor editor, String text, int offset) { Document document = editor.getDocument(); - document.insertString(offset, text); + try { + document.insertString(offset, text); + } catch (PatternSyntaxException e) { + // ignore + } editor.getCaretModel().moveToOffset(offset + text.length()); if (ConfigurationSettings.getCurrentState().isAutoFormattingEnabled()) { - EditorUtil.reformatDocument( - requireNonNull(editor.getProject()), - document, - offset, - offset + text.length()); + EditorUtil.reformatDocument(project, document, offset, offset + text.length()); } } @@ -200,4 +214,17 @@ public final class CodeCompletionService implements Disposable { APPLY_INLAY_ACTION_ID, new KeyboardShortcut(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, 0), null)); } + + private void subscribeToFeatureToggleEvents() { + ApplicationManager.getApplication() + .getMessageBus() + .connect(this) + .subscribe( + CodeCompletionEnabledListener.TOPIC, + (CodeCompletionEnabledListener) (completionsEnabled) -> { + if (!completionsEnabled) { + cancelPreviousCall(); + } + }); + } } diff --git a/src/main/java/ee/carlrobert/codegpt/codecompletions/TypeOverHandler.java b/src/main/java/ee/carlrobert/codegpt/codecompletions/TypeOverHandler.java new file mode 100644 index 00000000..b372186b --- /dev/null +++ b/src/main/java/ee/carlrobert/codegpt/codecompletions/TypeOverHandler.java @@ -0,0 +1,37 @@ +package ee.carlrobert.codegpt.codecompletions; + +import com.intellij.codeInsight.editorActions.TypedHandlerDelegate; +import com.intellij.openapi.command.CommandProcessor; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.fileTypes.FileType; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Key; +import com.intellij.psi.PsiFile; +import java.util.stream.Stream; +import org.jetbrains.annotations.NotNull; + +public class TypeOverHandler extends TypedHandlerDelegate { + + private static final Key TYPE_OVER_STAMP = Key.create("codegpt.typeOverStamp"); + + public static boolean getPendingTypeOverAndReset(@NotNull Editor editor) { + Long stamp = TYPE_OVER_STAMP.get(editor); + if (stamp == null) { + return false; + } + TYPE_OVER_STAMP.set(editor, null); + return stamp == editor.getDocument().getModificationStamp(); + } + + @NotNull + public TypedHandlerDelegate.@NotNull Result beforeCharTyped(char c, @NotNull Project project, + @NotNull Editor editor, @NotNull PsiFile file, @NotNull FileType fileType) { + boolean validTypeOver = Stream.of(')', '}', ']', '"', '\'', '>', ';').anyMatch(it -> it == c); + if (validTypeOver && CommandProcessor.getInstance().getCurrentCommand() != null) { + TYPE_OVER_STAMP.set(editor, editor.getDocument().getModificationStamp()); + } else { + TYPE_OVER_STAMP.set(editor, null); + } + return Result.CONTINUE; + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 3b950884..758d3c2c 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -39,6 +39,8 @@ + @@ -74,17 +76,23 @@ - - - diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index cd1781de..358a6c67 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -9,8 +9,10 @@ action.includeFilesInContext.dialog.title=Include In Context action.includeFilesInContext.dialog.description=Choose the files that you wish to include in the final prompt action.includeFilesInContext.dialog.repeatableContext.label=Repeatable context: action.includeFilesInContext.dialog.restoreToDefaults.label=Restore to Defaults -action.opensettings.title=Open Settings -action.opensettings.description=Open CodeGPT settings +action.openSettings.title=Open Settings +action.openSettings.description=Open CodeGPT settings +action.statusbar.enableCompletions=Enable Completions +action.statusbar.disableCompletions=Disable Completions settings.displayName=CodeGPT: Settings settings.openaiQuotaExceeded=OpenAI quota exceeded. settingsConfigurable.displayName.label=Display name: diff --git a/src/test/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionServiceTest.java b/src/test/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionServiceTest.java index 8f0dfabb..86eef8be 100644 --- a/src/test/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionServiceTest.java +++ b/src/test/java/ee/carlrobert/codegpt/codecompletions/CodeCompletionServiceTest.java @@ -29,7 +29,7 @@ public class CodeCompletionServiceTest extends IntegrationTest { getResourceContent("/codecompletions/code-completion-file.txt")); Editor editor = myFixture.getEditor(); var expectedCompletion = "TEST_SINGLE_LINE_OUTPUT\nTEST_MULTI_LINE_OUTPUT"; - var prefix = "z".repeat(247) + "\n[INPUT]\n"; // 128 tokens + var prefix = "z".repeat(245) + "\n[INPUT]\nc"; // 128 tokens var suffix = "\n[\\INPUT]\n" + "z".repeat(247); // 128 tokens expectLlama((StreamHttpExchange) request -> { assertThat(request.getUri().getPath()).isEqualTo("/completion"); @@ -39,9 +39,10 @@ public class CodeCompletionServiceTest extends IntegrationTest { .isEqualTo(InfillPromptTemplate.LLAMA.buildPrompt(prefix, suffix)); return List.of(jsonMapResponse(e("content", expectedCompletion), e("stop", true))); }); - editor.getCaretModel().moveToVisualPosition(cursorPosition); + myFixture.type('c'); + PlatformTestUtil.waitWithEventsDispatching( "Editor inlay assertions failed", () -> {