diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97afd607..12d23ee0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ jsoup = "1.19.1" jtokkit = "1.1.0" junit = "5.12.1" kotlin = "2.1.20" -llm-client = "0.8.40" +llm-client = "0.8.41" okio = "3.10.2" tree-sitter = "0.24.5" grpc = "1.71.0" diff --git a/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java b/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java index eb663d2c..114ece74 100644 --- a/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java +++ b/src/main/java/ee/carlrobert/codegpt/CodeGPTKeys.java @@ -1,6 +1,7 @@ package ee.carlrobert.codegpt; import com.intellij.openapi.util.Key; +import com.intellij.openapi.vfs.VirtualFile; import ee.carlrobert.codegpt.predictions.CodeSuggestionDiffViewer; import ee.carlrobert.llm.client.codegpt.CodeGPTUserDetails; @@ -18,4 +19,6 @@ public class CodeGPTKeys { Key.create("codegpt.isPromptTextFieldDocument"); public static final Key EDITOR_PREDICTION_DIFF_VIEWER = Key.create("codegpt.editorPredictionDiffViewer"); + public static final Key TOOLWINDOW_EDITOR_VIRTUAL_FILE = + Key.create("proxyai.toolwindowEditorVirtualFile"); } diff --git a/src/main/java/ee/carlrobert/codegpt/Icons.java b/src/main/java/ee/carlrobert/codegpt/Icons.java index 0a8d4e30..8a24ad3d 100644 --- a/src/main/java/ee/carlrobert/codegpt/Icons.java +++ b/src/main/java/ee/carlrobert/codegpt/Icons.java @@ -11,6 +11,10 @@ public final class Icons { IconLoader.getIcon("/icons/codegpt-small.svg", Icons.class); public static final Icon CodeGPTModel = IconLoader.getIcon("/icons/codegpt-model.svg", Icons.class); + public static final Icon CollapseAll = + IconLoader.getIcon("/icons/collapseAll.svg", Icons.class); + public static final Icon ExpandAll = + IconLoader.getIcon("/icons/expandAll.svg", Icons.class); public static final Icon Anthropic = IconLoader.getIcon("/icons/anthropic.svg", Icons.class); public static final Icon Azure = IconLoader.getIcon("/icons/azure.svg", Icons.class); public static final Icon DeepSeek = IconLoader.getIcon("/icons/deepseek.png", Icons.class); diff --git a/src/main/java/ee/carlrobert/codegpt/actions/editor/AskQuestionAction.java b/src/main/java/ee/carlrobert/codegpt/actions/editor/AskQuestionAction.java index 8c99d679..f0c0f2a2 100644 --- a/src/main/java/ee/carlrobert/codegpt/actions/editor/AskQuestionAction.java +++ b/src/main/java/ee/carlrobert/codegpt/actions/editor/AskQuestionAction.java @@ -9,10 +9,10 @@ import com.intellij.util.ui.FormBuilder; import com.intellij.util.ui.JBUI; import com.intellij.util.ui.UI; import ee.carlrobert.codegpt.Icons; +import ee.carlrobert.codegpt.completions.CompletionRequestUtil; import ee.carlrobert.codegpt.conversations.message.Message; import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager; import ee.carlrobert.codegpt.ui.UIUtil; -import ee.carlrobert.codegpt.util.file.FileUtil; import java.awt.event.ActionEvent; import javax.swing.AbstractAction; import javax.swing.JComponent; @@ -31,12 +31,12 @@ public class AskQuestionAction extends BaseEditorAction { @Override protected void actionPerformed(Project project, Editor editor, String selectedText) { if (selectedText != null && !selectedText.isEmpty()) { - var fileExtension = FileUtil.getFileExtension(editor.getVirtualFile().getName()); var dialog = new CustomPromptDialog(previousUserPrompt); if (dialog.showAndGet()) { previousUserPrompt = dialog.getUserPrompt(); - var message = new Message( - format("%s%n```%s%n%s%n```", previousUserPrompt, fileExtension, selectedText)); + var formattedCode = + CompletionRequestUtil.formatCode(selectedText, editor.getVirtualFile().getPath()); + var message = new Message(format("%s\n\n%s", previousUserPrompt, formattedCode)); SwingUtilities.invokeLater(() -> project.getService(ChatToolWindowContentManager.class).sendMessage(message)); } diff --git a/src/main/java/ee/carlrobert/codegpt/actions/editor/EditorActionsUtil.java b/src/main/java/ee/carlrobert/codegpt/actions/editor/EditorActionsUtil.java index 0e85afa6..8640788f 100644 --- a/src/main/java/ee/carlrobert/codegpt/actions/editor/EditorActionsUtil.java +++ b/src/main/java/ee/carlrobert/codegpt/actions/editor/EditorActionsUtil.java @@ -1,7 +1,5 @@ package ee.carlrobert.codegpt.actions.editor; -import static java.lang.String.format; - import com.intellij.openapi.actionSystem.ActionManager; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.actionSystem.DefaultActionGroup; @@ -9,10 +7,10 @@ import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.extensions.PluginId; import com.intellij.openapi.project.Project; +import ee.carlrobert.codegpt.completions.CompletionRequestUtil; import ee.carlrobert.codegpt.conversations.message.Message; import ee.carlrobert.codegpt.settings.prompts.PromptsSettings; import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager; -import ee.carlrobert.codegpt.util.file.FileUtil; import java.util.LinkedHashMap; import java.util.Map; import org.apache.commons.text.CaseUtils; @@ -46,13 +44,13 @@ public class EditorActionsUtil { project.getService(ChatToolWindowContentManager.class); toolWindowContentManager.getToolWindow().show(); - var fileExtension = FileUtil.getFileExtension(editor.getVirtualFile().getName()); var prompt = promptDetails.getInstructions() == null ? "" : promptDetails.getInstructions(); - var message = new Message(prompt.replace( - "{SELECTION}", - format("%n```%s%n%s%n```", fileExtension, selectedText))); - toolWindowContentManager.sendMessage(message); + var formattedCode = CompletionRequestUtil.formatCode( + selectedText, + editor.getVirtualFile().getPath()); + toolWindowContentManager.sendMessage( + new Message(prompt.replace("{SELECTION}", formattedCode))); } }; group.add(action); 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 773c90d3..0ff13013 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java @@ -1,7 +1,6 @@ package ee.carlrobert.codegpt.toolwindow.chat; import static ee.carlrobert.codegpt.ui.UIUtil.createScrollPaneWithSmartScroller; -import static java.lang.String.format; import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; @@ -16,6 +15,7 @@ import ee.carlrobert.codegpt.ReferencedFile; import ee.carlrobert.codegpt.actions.ActionType; import ee.carlrobert.codegpt.completions.ChatCompletionParameters; import ee.carlrobert.codegpt.completions.CompletionRequestService; +import ee.carlrobert.codegpt.completions.CompletionRequestUtil; import ee.carlrobert.codegpt.completions.ConversationType; import ee.carlrobert.codegpt.completions.ToolwindowChatCompletionRequestHandler; import ee.carlrobert.codegpt.conversations.Conversation; @@ -47,7 +47,6 @@ import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails; import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager; import ee.carlrobert.codegpt.util.EditorUtil; import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers; -import ee.carlrobert.codegpt.util.file.FileUtil; import git4idea.GitCommit; import java.awt.BorderLayout; import java.util.HashSet; @@ -434,10 +433,10 @@ public class ChatToolWindowTabPanel implements Disposable { return Unit.INSTANCE; } - var fileExtension = FileUtil.getFileExtension(editor.getVirtualFile().getName()); - var message = new Message(action.getPrompt().replace( - "{SELECTION}", - format("%n```%s%n%s%n```", fileExtension, editor.getSelectionModel().getSelectedText()))); + var formattedCode = CompletionRequestUtil.formatCode( + editor.getSelectionModel().getSelectedText(), + editor.getVirtualFile().getPath()); + var message = new Message(action.getPrompt().replace("{SELECTION}", formattedCode)); sendMessage(message, ConversationType.DEFAULT); return Unit.INSTANCE; }); diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/StreamParser.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/StreamParser.java deleted file mode 100644 index 84b3dc4f..00000000 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/StreamParser.java +++ /dev/null @@ -1,57 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.chat; - -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class StreamParser { - - private static final String CODE_BLOCK_STARTING_REGEX = "```[a-zA-Z]*\n"; - private final StringBuilder messageBuilder = new StringBuilder(); - private boolean isProcessingCode; - - public List parse(String message) { - message = message.replace("\r", ""); - messageBuilder.append(message); - - Pattern pattern = Pattern.compile(CODE_BLOCK_STARTING_REGEX); - Matcher matcher = pattern.matcher(messageBuilder.toString()); - if (!isProcessingCode && matcher.find()) { - isProcessingCode = true; - - var startingIndex = messageBuilder.indexOf(matcher.group()); - var prevMessage = messageBuilder.substring(0, startingIndex); - messageBuilder.delete(0, messageBuilder.indexOf(matcher.group())); - - return List.of( - new StreamParseResponse(StreamResponseType.TEXT, prevMessage), - new StreamParseResponse(StreamResponseType.CODE, messageBuilder.toString())); - } - - var endingIndex = messageBuilder.indexOf("```\n", 1); - if (isProcessingCode && endingIndex > 0) { - isProcessingCode = false; - - var codeResponse = messageBuilder.substring(0, endingIndex + 3); - messageBuilder.delete(0, endingIndex + 3); - - return List.of( - new StreamParseResponse(StreamResponseType.CODE, codeResponse), - new StreamParseResponse(StreamResponseType.TEXT, messageBuilder.toString())); - } - - return List.of(new StreamParseResponse( - isProcessingCode - ? StreamResponseType.CODE - : StreamResponseType.TEXT, - messageBuilder.toString())); - } - - public void clear() { - messageBuilder.setLength(0); - isProcessingCode = false; - } - - public record StreamParseResponse(StreamResponseType type, String response) { - } -} diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/StreamResponseType.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/StreamResponseType.java deleted file mode 100644 index 3982aa5a..00000000 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/StreamResponseType.java +++ /dev/null @@ -1,6 +0,0 @@ -package ee.carlrobert.codegpt.toolwindow.chat; - -public enum StreamResponseType { - CODE, - TEXT -} diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.java index caaf28e6..8b037552 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/editor/ResponseEditorPanel.java @@ -3,50 +3,46 @@ package ee.carlrobert.codegpt.toolwindow.chat.editor; import static ee.carlrobert.codegpt.util.file.FileUtil.findLanguageExtensionMapping; import static java.lang.String.format; -import com.intellij.icons.AllIcons.General; import com.intellij.openapi.Disposable; import com.intellij.openapi.actionSystem.ActionGroup; import com.intellij.openapi.actionSystem.ActionManager; -import com.intellij.openapi.actionSystem.ActionToolbar; import com.intellij.openapi.actionSystem.AnAction; -import com.intellij.openapi.actionSystem.AnActionEvent; import com.intellij.openapi.actionSystem.DefaultActionGroup; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.editor.EditorFactory; import com.intellij.openapi.editor.colors.EditorColorsManager; +import com.intellij.openapi.editor.event.BulkAwareDocumentListener; +import com.intellij.openapi.editor.event.DocumentEvent; import com.intellij.openapi.editor.ex.EditorEx; import com.intellij.openapi.editor.impl.ContextMenuPopupHandler; import com.intellij.openapi.project.Project; -import com.intellij.openapi.ui.JBMenuItem; -import com.intellij.openapi.ui.JBPopupMenu; import com.intellij.openapi.util.Disposer; import com.intellij.openapi.util.text.StringUtil; import com.intellij.ui.ColorUtil; import com.intellij.ui.IdeBorderFactory; +import com.intellij.ui.JBColor; import com.intellij.ui.components.ActionLink; import com.intellij.util.ui.JBUI; import ee.carlrobert.codegpt.CodeGPTBundle; +import ee.carlrobert.codegpt.Icons; import ee.carlrobert.codegpt.actions.toolwindow.ReplaceCodeInMainEditorAction; -import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.AutoApplyAction; -import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction; -import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.DiffAction; -import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.EditAction; -import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.InsertAtCaretAction; -import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.NewFileAction; -import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.ReplaceSelectionAction; +import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse; import ee.carlrobert.codegpt.util.EditorUtil; import java.awt.BorderLayout; +import java.awt.FlowLayout; import javax.swing.JPanel; +import javax.swing.SwingConstants; import org.jetbrains.annotations.NotNull; public class ResponseEditorPanel extends JPanel implements Disposable { private final Editor editor; + private final JPanel expandLinkPanel; + private boolean expandLinkAdded = false; public ResponseEditorPanel( Project project, - String code, - String markdownLanguage, + StreamParseResponse item, boolean readOnly, Disposable disposableParent) { super(new BorderLayout()); @@ -55,8 +51,8 @@ public class ResponseEditorPanel extends JPanel implements Disposable { editor = EditorUtil.createEditor( project, - findLanguageExtensionMapping(markdownLanguage).getValue(), - StringUtil.convertLineSeparators(code)); + findLanguageExtensionMapping(item.getLanguage()).getValue(), + StringUtil.convertLineSeparators(item.getContent())); var group = new DefaultActionGroup(); group.add(new ReplaceCodeInMainEditorAction()); String originalGroupId = ((EditorEx) editor).getContextMenuGroupId(); @@ -67,17 +63,46 @@ public class ResponseEditorPanel extends JPanel implements Disposable { group.addAll(((ActionGroup) originalGroup).getChildren(null, actionManager)); } } + configureEditor( project, (EditorEx) editor, readOnly, new ContextMenuPopupHandler.Simple(group), - findLanguageExtensionMapping(markdownLanguage).getValue()); + item.getFilePath(), + findLanguageExtensionMapping(item.getLanguage()).getKey()); add(editor.getComponent(), BorderLayout.CENTER); + expandLinkPanel = new JPanel(new FlowLayout(FlowLayout.CENTER)); + expandLinkPanel.setOpaque(false); + expandLinkPanel.setBorder( + JBUI.Borders.compound( + JBUI.Borders.customLine(ColorUtil.fromHex("#48494b"), 0, 1, 1, 1), + JBUI.Borders.empty(0))); + expandLinkPanel.add(createExpandLink((EditorEx) editor)); + + editor.getDocument().addDocumentListener(new BulkAwareDocumentListener.Simple() { + @Override + public void documentChanged(@NotNull DocumentEvent event) { + checkLineCountAndUpdateUI(); + } + }); + + checkLineCountAndUpdateUI(); + Disposer.register(disposableParent, this); } + private void checkLineCountAndUpdateUI() { + int lineCount = editor.getDocument().getLineCount(); + if (lineCount > 8 && !expandLinkAdded) { + add(expandLinkPanel, BorderLayout.SOUTH); + expandLinkAdded = true; + revalidate(); + repaint(); + } + } + @Override public void dispose() { EditorFactory.getInstance().releaseEditor(editor); @@ -92,7 +117,8 @@ public class ResponseEditorPanel extends JPanel implements Disposable { EditorEx editorEx, boolean readOnly, ContextMenuPopupHandler popupHandler, - String extension) { + String filePath, + String language) { if (readOnly) { editorEx.setOneLineMode(true); editorEx.setHorizontalScrollbarVisible(false); @@ -115,28 +141,10 @@ public class ResponseEditorPanel extends JPanel implements Disposable { editorEx.setVerticalScrollbarVisible(false); editorEx.getContentComponent().setBorder(JBUI.Borders.emptyLeft(4)); editorEx.setBorder(IdeBorderFactory.createBorder(ColorUtil.fromHex("#48494b"))); - editorEx.setPermanentHeaderComponent( - createHeaderComponent(project, editorEx, extension, readOnly)); - editorEx.setHeaderComponent(null); - } - private JPanel createHeaderComponent( - Project project, - EditorEx editorEx, - String extension, - boolean readOnly) { - var headerPanel = new JPanel(new BorderLayout()); - headerPanel.setBorder( - JBUI.Borders.compound( - JBUI.Borders.customLine(ColorUtil.fromHex("#48494b"), 1, 1, 0, 1), - JBUI.Borders.empty(4))); - headerPanel.add(createExpandLink(editorEx), BorderLayout.LINE_START); - if (!readOnly) { - headerPanel.add( - createHeaderActions(project, extension, editorEx, headerPanel).getComponent(), - BorderLayout.LINE_END); - } - return headerPanel; + editorEx.setPermanentHeaderComponent( + new HeaderPanel(project, editorEx, filePath, language, readOnly)); + editorEx.setHeaderComponent(null); } private String getLinkText(boolean expanded) { @@ -155,48 +163,17 @@ public class ResponseEditorPanel extends JPanel implements Disposable { var oneLineMode = editorEx.isOneLineMode(); var source = (ActionLink) event.getSource(); source.setText(getLinkText(!oneLineMode)); - source.setIcon(oneLineMode ? General.ArrowDown : General.ArrowRight); + source.setIcon(oneLineMode ? Icons.CollapseAll : Icons.ExpandAll); editorEx.setOneLineMode(!oneLineMode); editorEx.setHorizontalScrollbarVisible(oneLineMode); editorEx.getContentComponent().revalidate(); editorEx.getContentComponent().repaint(); }); - expandLink.setIcon(editorEx.isOneLineMode() ? General.ArrowRight : General.ArrowDown); + expandLink.setIcon(editorEx.isOneLineMode() ? Icons.ExpandAll : Icons.CollapseAll); + expandLink.setFont(JBUI.Fonts.smallFont()); + expandLink.setForeground(JBColor.GRAY); + expandLink.setHorizontalAlignment(SwingConstants.CENTER); return expandLink; } - - private ActionToolbar createHeaderActions( - Project project, - String extension, - EditorEx editorEx, - JPanel headerPanel) { - var actionGroup = new DefaultActionGroup("EDITOR_TOOLBAR_ACTION_GROUP", false); - actionGroup.add(new AutoApplyAction(project, editorEx, headerPanel)); - actionGroup.add(new InsertAtCaretAction(editorEx)); - actionGroup.add(new CopyAction(editorEx)); - actionGroup.addSeparator(); - - var menu = new JBPopupMenu(); - menu.add(new JBMenuItem(new DiffAction(editorEx, menu.getLocation()))); - menu.add(new JBMenuItem(new ReplaceSelectionAction(editorEx, menu.getLocation()))); - menu.add(new JBMenuItem(new EditAction(editorEx))); - menu.add(new JBMenuItem(new NewFileAction(editorEx, extension))); - - var toolbar = ActionManager.getInstance() - .createActionToolbar("NAVIGATION_BAR_TOOLBAR", actionGroup, true); - actionGroup.add(new AnAction("Editor Actions", "Editor Actions", General.GearPlain) { - @Override - public void actionPerformed(@NotNull AnActionEvent e) { - var inputEvent = e.getInputEvent(); - if (inputEvent != null) { - menu.show(inputEvent.getComponent(), 0, 0); - } - } - }); - toolbar.setLayoutPolicy(ActionToolbar.NOWRAP_LAYOUT_POLICY); - toolbar.setTargetComponent(editorEx.getComponent()); - toolbar.getComponent().setBorder(JBUI.Borders.empty()); - return toolbar; - } -} +} \ No newline at end of file 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 41bd23c9..cab1d07e 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 @@ -154,7 +154,7 @@ class PsiStructureRepository( try { PsiManager.getInstance(project).findFile(virtualFile) } catch (exc: Exception) { - logger.error("Failed to find file {}", virtualFile.name) + logger.warn("Failed to find file ${virtualFile.name}", exc) null } } diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java index 0a45984b..78d1c27a 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java @@ -1,6 +1,5 @@ package ee.carlrobert.codegpt.toolwindow.chat.ui; -import static ee.carlrobert.codegpt.toolwindow.chat.StreamResponseType.CODE; import static ee.carlrobert.codegpt.util.MarkdownUtil.convertMdToHtml; import static java.lang.String.format; import static javax.swing.event.HyperlinkEvent.EventType.ACTIVATED; @@ -24,8 +23,6 @@ import com.intellij.openapi.vfs.VirtualFile; import com.intellij.ui.PopupHandler; import com.intellij.ui.components.JBLabel; import com.intellij.util.ui.JBUI; -import com.vladsch.flexmark.ast.FencedCodeBlock; -import com.vladsch.flexmark.parser.Parser; import ee.carlrobert.codegpt.CodeGPTBundle; import ee.carlrobert.codegpt.Icons; import ee.carlrobert.codegpt.actions.ActionType; @@ -38,16 +35,17 @@ import ee.carlrobert.codegpt.settings.GeneralSettings; import ee.carlrobert.codegpt.settings.GeneralSettingsConfigurable; import ee.carlrobert.codegpt.settings.service.ServiceType; import ee.carlrobert.codegpt.telemetry.TelemetryAction; -import ee.carlrobert.codegpt.toolwindow.chat.StreamParser; -import ee.carlrobert.codegpt.toolwindow.chat.ThinkingOutputParser; import ee.carlrobert.codegpt.toolwindow.chat.editor.ResponseEditorPanel; import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction; +import ee.carlrobert.codegpt.toolwindow.chat.parser.CompleteOutputParser; +import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamOutputParser; +import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse; +import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse.StreamResponseType; import ee.carlrobert.codegpt.toolwindow.ui.ResponseBodyProgressPanel; import ee.carlrobert.codegpt.toolwindow.ui.WebpageList; import ee.carlrobert.codegpt.ui.ThoughtProcessPanel; import ee.carlrobert.codegpt.ui.UIUtil; import ee.carlrobert.codegpt.util.EditorUtil; -import ee.carlrobert.codegpt.util.MarkdownUtil; import java.awt.BorderLayout; import java.util.Objects; import java.util.stream.Stream; @@ -64,8 +62,7 @@ public class ChatMessageResponseBody extends JPanel { private final Project project; private final Disposable parentDisposable; - private final StreamParser streamParser; - private final ThinkingOutputParser thinkingOutputParser; + private final StreamOutputParser streamOutputParser; private final boolean readOnly; private final DefaultListModel webpageListModel = new DefaultListModel<>(); private final WebpageList webpageList = new WebpageList(webpageListModel); @@ -86,8 +83,7 @@ public class ChatMessageResponseBody extends JPanel { Disposable parentDisposable) { this.project = project; this.parentDisposable = parentDisposable; - this.streamParser = new StreamParser(); - this.thinkingOutputParser = new ThinkingOutputParser(); + this.streamOutputParser = new StreamOutputParser(); this.readOnly = readOnly; setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); setOpaque(false); @@ -106,8 +102,8 @@ public class ChatMessageResponseBody extends JPanel { public ChatMessageResponseBody withResponse(@NotNull String response) { try { - for (var message : MarkdownUtil.splitCodeBlocks(response)) { - processResponse(message, message.startsWith("```"), false); + for (var item : new CompleteOutputParser().parse(response)) { + processResponse(item, false); currentlyProcessedTextPane = null; currentlyProcessedEditorPanel = null; @@ -123,13 +119,9 @@ public class ChatMessageResponseBody extends JPanel { return; } - var processedPartialMessage = processThinkingOutput(partialMessage); - if (processedPartialMessage.isEmpty()) { - return; - } - - for (var item : streamParser.parse(processedPartialMessage)) { - processResponse(item.response(), CODE.equals(item.type()), true); + var parsedResponse = streamOutputParser.parse(partialMessage); + for (StreamParseResponse item : parsedResponse) { + processResponse(item, true); } } @@ -231,7 +223,7 @@ public class ChatMessageResponseBody extends JPanel { public void clear() { removeAll(); - streamParser.clear(); + streamOutputParser.clear(); // TODO: First message might be code block prepareProcessingText(true); currentlyProcessedTextPane.setText( @@ -241,26 +233,17 @@ public class ChatMessageResponseBody extends JPanel { revalidate(); } - private String processThinkingOutput(String partialMessage) { - var processedChunk = thinkingOutputParser.processChunk(partialMessage); + private void processThinkingOutput(String thoughtProcess) { + progressPanel.setVisible(false); + var thoughtProcessPanel = getExistingThoughtProcessPanel(); - - if (thinkingOutputParser.isThinking()) { - progressPanel.setVisible(false); - - if (thoughtProcessPanel == null) { - thoughtProcessPanel = new ThoughtProcessPanel(); - add(thoughtProcessPanel); - } else { - thoughtProcessPanel.updateText(thinkingOutputParser.getThoughtProcess()); - } + if (thoughtProcessPanel == null) { + thoughtProcessPanel = new ThoughtProcessPanel(); + thoughtProcessPanel.updateText(thoughtProcess); + add(thoughtProcessPanel); + } else { + thoughtProcessPanel.updateText(thoughtProcess); } - - if (thoughtProcessPanel != null && thinkingOutputParser.isFinished()) { - thoughtProcessPanel.setFinished(); - } - - return processedChunk; } private ThoughtProcessPanel getExistingThoughtProcessPanel() { @@ -270,26 +253,32 @@ public class ChatMessageResponseBody extends JPanel { .orElse(null); } - private void processResponse(String markdownInput, boolean codeResponse, boolean caretVisible) { - if (codeResponse) { - processCode(markdownInput); + private void processResponse(StreamParseResponse item, boolean caretVisible) { + if (item.getType() == StreamResponseType.THINKING) { + processThinkingOutput(item.getContent()); + return; + } + + var thoughtProcessPanel = getExistingThoughtProcessPanel(); + if (thoughtProcessPanel != null && !thoughtProcessPanel.isFinished()) { + thoughtProcessPanel.setFinished(); + } + + if (item.getType() == StreamResponseType.CODE_CONTENT + || item.getType() == StreamResponseType.CODE_HEADER) { + processCode(item); } else { - processText(markdownInput, caretVisible); + processText(item.getContent(), caretVisible); } } - private void processCode(String markdownCode) { - var document = Parser.builder().build().parse(markdownCode); - var child = document.getChildOfType(FencedCodeBlock.class); - if (child != null) { - var codeBlock = ((FencedCodeBlock) child); - var code = codeBlock.getContentChars().toString(); - if (!code.isEmpty()) { - if (currentlyProcessedEditorPanel == null) { - prepareProcessingCode(code, codeBlock.getInfo().toString()); - } - EditorUtil.updateEditorDocument(currentlyProcessedEditorPanel.getEditor(), code); + private void processCode(StreamParseResponse item) { + var content = item.getContent(); + if (!content.isEmpty()) { + if (currentlyProcessedEditorPanel == null) { + prepareProcessingCode(item); } + EditorUtil.updateEditorDocument(currentlyProcessedEditorPanel.getEditor(), content); } } @@ -307,11 +296,11 @@ public class ChatMessageResponseBody extends JPanel { add(currentlyProcessedTextPane); } - private void prepareProcessingCode(String code, String markdownLanguage) { + private void prepareProcessingCode(StreamParseResponse item) { hideCaret(); currentlyProcessedTextPane = null; currentlyProcessedEditorPanel = - new ResponseEditorPanel(project, code, markdownLanguage, readOnly, parentDisposable); + new ResponseEditorPanel(project, item, readOnly, parentDisposable); add(currentlyProcessedEditorPanel); } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/BaseCommitWorkflowAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/BaseCommitWorkflowAction.kt index 13d89076..e9475110 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/actions/BaseCommitWorkflowAction.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/BaseCommitWorkflowAction.kt @@ -20,7 +20,7 @@ import com.intellij.vcs.commit.CommitWorkflowUi import ee.carlrobert.codegpt.EncodingManager import ee.carlrobert.codegpt.codecompletions.CompletionProgressNotifier import ee.carlrobert.codegpt.completions.CompletionRequestService -import ee.carlrobert.codegpt.toolwindow.chat.ThinkingOutputParser +import ee.carlrobert.codegpt.util.ThinkingOutputParser import ee.carlrobert.codegpt.ui.OverlayUtil import ee.carlrobert.codegpt.util.CommitWorkflowChanges import ee.carlrobert.codegpt.util.GitUtil.getProjectRepository diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeCompletionListener.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeCompletionListener.kt index 26a662d7..5f017528 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeCompletionListener.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/editor/EditCodeCompletionListener.kt @@ -15,7 +15,7 @@ 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.toolwindow.chat.ThinkingOutputParser +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 diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt index f77ffa91..eaa22f80 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt @@ -5,6 +5,7 @@ import ee.carlrobert.codegpt.ReferencedFile import ee.carlrobert.codegpt.psistructure.ClassStructureSerializer import ee.carlrobert.codegpt.psistructure.models.ClassStructure import ee.carlrobert.codegpt.settings.IncludedFilesSettings +import ee.carlrobert.codegpt.util.file.FileUtil import org.jetbrains.kotlin.utils.addToStdlib.ifNotEmpty import java.util.stream.Collectors @@ -19,6 +20,19 @@ object CompletionRequestUtil { try to take it out of context, if necessary for the response. """.trimIndent() + @JvmStatic + fun formatCode(code: String, filePath: String?): String { + val language = filePath?.let { "${FileUtil.getFileExtension(it)}:$it" } ?: "" + + return """ + + ```$language + $code + ``` + + """.trimIndent() + } + @JvmStatic fun getPromptWithContext( referencedFiles: List, @@ -32,29 +46,33 @@ object CompletionRequestUtil { repeatableContext .replace("{FILE_PATH}", item.filePath()) .replace( - "{FILE_CONTENT}", String.format( - "```%s%n%s%n```", - item.fileExtension, - item.fileContent().trim { it <= ' ' }) + "{FILE_CONTENT}", + formatCode(item.fileContent(), item.filePath()) ) } .collect(Collectors.joining("\n\n")) val structureContext = psiStructure ?.map { structure: ClassStructure -> - val fileExtension = structure.virtualFile.extension ?: "" + formatCode( + psiStructureSerializer.serialize(structure), + structure.virtualFile.path + ) repeatableContext .replace("{FILE_PATH}", structure.virtualFile.path) .replace( - "{FILE_CONTENT}", String.format( - "```%s%n%s%n```", - fileExtension, - psiStructureSerializer.serialize(structure) + "{FILE_CONTENT}", + formatCode( + psiStructureSerializer.serialize(structure), + structure.virtualFile.path ) ) } ?.ifNotEmpty { - joinToString(prefix = "\n\n" + PSI_STRUCTURE_TITLE + "\n\n", separator = "\n\n") { it } + joinToString( + prefix = "\n\n" + PSI_STRUCTURE_TITLE + "\n\n", + separator = "\n\n" + ) { it } } return includedFilesSettings.promptTemplate 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 05b981cc..aab1ce8f 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CodeGPTRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/CodeGPTRequestFactory.kt @@ -58,7 +58,7 @@ class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructure processFolder(it, children) children } else { - listOf(ContextFile(file.fileName(), file.fileContent())) + listOf(ContextFile(file.fileName(), file.filePath(), file.fileContent())) } } } @@ -69,6 +69,7 @@ class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructure val psiContext = params.psiStructure?.map { classStructure -> ContextFile( classStructure.virtualFile.name, + classStructure.virtualFile.path, classStructureSerializer.serialize(classStructure) ) }.orEmpty() @@ -85,7 +86,13 @@ class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructure folder.children.forEach { child -> when { child.isDirectory -> processFolder(child, contextFiles) - else -> contextFiles.add(ContextFile(child.name, FileUtil.readContent(child))) + else -> contextFiles.add( + ContextFile( + child.name, + child.path, + FileUtil.readContent(child) + ) + ) } } } 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 6bffa3fc..9434e00c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/LlamaRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/LlamaRequestFactory.kt @@ -8,6 +8,7 @@ import ee.carlrobert.codegpt.completions.llama.LlamaModel import ee.carlrobert.codegpt.completions.llama.PromptTemplate import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.settings.prompts.PromptsSettings +import ee.carlrobert.codegpt.settings.prompts.addProjectPath import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings import ee.carlrobert.llm.client.llama.completion.LlamaCompletionRequest @@ -20,7 +21,7 @@ class LlamaRequestFactory : BaseRequestFactory() { service().state.coreActions.fixCompileErrors.instructions } else { service().state.personas.selectedPersona.let { - if (it.disabled) null else it.instructions + if (it.disabled) null else it.instructions?.addProjectPath() } } 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 775a4d66..83d1fd3a 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt @@ -1,35 +1,22 @@ package ee.carlrobert.codegpt.completions.factory +import com.intellij.ide.impl.ProjectUtil import com.intellij.openapi.components.service +import com.intellij.openapi.project.guessProjectDir import ee.carlrobert.codegpt.EncodingManager import ee.carlrobert.codegpt.ReferencedFile -import ee.carlrobert.codegpt.completions.ChatCompletionParameters -import ee.carlrobert.codegpt.completions.CommitMessageCompletionParameters -import ee.carlrobert.codegpt.completions.CompletionRequestFactory -import ee.carlrobert.codegpt.completions.CompletionRequestUtil -import ee.carlrobert.codegpt.completions.ConversationType -import ee.carlrobert.codegpt.completions.EditCodeCompletionParameters -import ee.carlrobert.codegpt.completions.LookupCompletionParameters -import ee.carlrobert.codegpt.completions.TotalUsageExceededException +import ee.carlrobert.codegpt.completions.* import ee.carlrobert.codegpt.conversations.ConversationsState import ee.carlrobert.codegpt.psistructure.models.ClassStructure import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings.Companion.getState import ee.carlrobert.codegpt.settings.prompts.CoreActionsState import ee.carlrobert.codegpt.settings.prompts.PromptsSettings +import ee.carlrobert.codegpt.settings.prompts.addProjectPath import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings import ee.carlrobert.codegpt.util.file.FileUtil.getImageMediaType -import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.O_1_MINI -import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.O_1_PREVIEW -import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.O_3_MINI -import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.findByCode -import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionDetailedMessage -import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionMessage -import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionRequest -import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage -import ee.carlrobert.llm.client.openai.completion.request.OpenAIImageUrl -import ee.carlrobert.llm.client.openai.completion.request.OpenAIMessageImageURLContent -import ee.carlrobert.llm.client.openai.completion.request.OpenAIMessageTextContent +import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.* +import ee.carlrobert.llm.client.openai.completion.request.* import java.io.IOException import java.nio.file.Files import java.nio.file.Path @@ -182,13 +169,16 @@ class OpenAIRequestFactory : CompletionRequestFactory { val sessionPersonaDetails = callParameters.personaDetails if (sessionPersonaDetails == null) { messages.add( - OpenAIChatCompletionStandardMessage(role, selectedPersona.instructions) + OpenAIChatCompletionStandardMessage( + role, + selectedPersona.instructions?.addProjectPath() + ) ) } else { messages.add( OpenAIChatCompletionStandardMessage( role, - sessionPersonaDetails.instructions + sessionPersonaDetails.instructions.addProjectPath() ) ) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/predictions/PredictionService.kt b/src/main/kotlin/ee/carlrobert/codegpt/predictions/PredictionService.kt index f7080ff7..905ba12d 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/predictions/PredictionService.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/predictions/PredictionService.kt @@ -47,7 +47,7 @@ class PredictionService { fun openDirectPrediction(editor: Editor, nextRevision: String) { val project: Project = editor.project ?: return val tempDiffFile = LightVirtualFile(editor.virtualFile.name, nextRevision) - val diffRequest = createDiffRequest(project, tempDiffFile, editor) + val diffRequest = createDiffRequest(project, tempDiffFile, editor.virtualFile) runInEdt { service().showDiff(project, diffRequest) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonaSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonaSettings.kt index 8c62c718..c1465700 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonaSettings.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonaSettings.kt @@ -17,7 +17,7 @@ class PersonaSettingsState : BaseState() { } class PersonaDetailsState : BaseState() { - var id by property(1L) - var name by string("CodeGPT Default") - var instructions by string(PersonasState.DEFAULT_PERSONA_PROMPT) + var id by property(PersonasState.DEFAULT_PERSONA.id) + var name by string(PersonasState.DEFAULT_PERSONA.name) + var instructions by string(PersonasState.DEFAULT_PERSONA.instructions) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/PromptsSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/PromptsSettings.kt index 0b9e1fbf..adc1ed4d 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/PromptsSettings.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/PromptsSettings.kt @@ -1,9 +1,16 @@ package ee.carlrobert.codegpt.settings.prompts +import com.intellij.ide.impl.ProjectUtil import com.intellij.openapi.components.* +import com.intellij.openapi.project.guessProjectDir import ee.carlrobert.codegpt.actions.editor.EditorActionsUtil +import ee.carlrobert.codegpt.codecompletions.InfillPromptTemplate +import ee.carlrobert.codegpt.credentials.CredentialsStore import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.settings.persona.PersonaSettings +import ee.carlrobert.codegpt.settings.service.custom.CustomServiceSettings +import ee.carlrobert.codegpt.settings.service.custom.CustomServiceSettingsState +import ee.carlrobert.codegpt.settings.service.custom.template.CustomServiceTemplate import ee.carlrobert.codegpt.util.file.FileUtil.getResourceContent @Service @@ -16,9 +23,22 @@ class PromptsSettings : companion object { @JvmStatic fun getSelectedPersonaSystemPrompt(): String { - return service().state.personas.selectedPersona.instructions ?: "" + return (service().state.personas.selectedPersona.instructions ?: "") + .addProjectPath() } } + + override fun initializeComponent() { + super.initializeComponent() + + val selectedPersona = state.personas.selectedPersona + if (selectedPersona.id == 1L) { + state.personas.selectedPersona = PersonasState.DEFAULT_PERSONA + } + state.personas.prompts = state.personas.prompts.map { + if (it.id == 1L) PersonasState.DEFAULT_PERSONA else it + }.toMutableList() + } } class PromptsSettingsState : BaseState() { @@ -73,8 +93,8 @@ class PersonasState : BaseState() { companion object { val DEFAULT_PERSONA_PROMPT = getResourceContent("/prompts/persona/default-persona.txt") val DEFAULT_PERSONA = PersonaPromptDetailsState().apply { - id = 1L - name = "CodeGPT Default" + id = 1L + name = "Default Persona" instructions = DEFAULT_PERSONA_PROMPT } } @@ -194,3 +214,9 @@ class PersonaPromptDetailsState : PromptDetailsState() { @JvmRecord data class PersonaDetails(val id: Long, val name: String, val instructions: String) + +fun String.addProjectPath(): String = replace( + "{{project_path}}", + ProjectUtil.getActiveProject()?.guessProjectDir()?.path + ?: "UNDEFINED" +) \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/PromptsForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/PromptsForm.kt index 9571b992..c92d439f 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/PromptsForm.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/form/PromptsForm.kt @@ -312,7 +312,7 @@ class PromptsForm { val selectedNode = tree.selectionPath?.lastPathComponent selectedNode is PromptDetailsTreeNode && selectedNode.category != PromptCategory.CORE_ACTIONS - && selectedNode.details.name != "CodeGPT Default" + && selectedNode.details.name != "Default Persona" } .addExtraAction(object : AnAction("Duplicate", "Duplicate prompt", AllIcons.Actions.Copy) { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/HeaderPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/HeaderPanel.kt new file mode 100644 index 00000000..4d66b27e --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/HeaderPanel.kt @@ -0,0 +1,153 @@ +package ee.carlrobert.codegpt.toolwindow.chat.editor + +import com.intellij.icons.AllIcons.General +import com.intellij.ide.actions.OpenFileAction +import com.intellij.openapi.actionSystem.* +import com.intellij.openapi.actionSystem.toolbarLayout.ToolbarLayoutStrategy +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.JBMenuItem +import com.intellij.openapi.ui.JBPopupMenu +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.ColorUtil +import com.intellij.ui.JBColor +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.CodeGPTKeys +import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.* +import java.awt.BorderLayout +import javax.swing.JPanel + +class HeaderPanel( + private val project: Project, + private val editorEx: EditorEx, + filePath: String?, + private val extension: String, + private val readOnly: Boolean +) : JPanel(BorderLayout()) { + + private var actionToolbar: ActionToolbar? = null + + init { + setupPanelAppearance() + setupFilePathOrLanguageLabel(filePath) + + if (!readOnly) { + actionToolbar = createHeaderActions() + add(actionToolbar!!.component, BorderLayout.LINE_END) + } + } + + private fun setupPanelAppearance() { + border = JBUI.Borders.compound( + JBUI.Borders.customLine(ColorUtil.fromHex("#48494b"), 1, 1, 0, 1), + JBUI.Borders.empty(4) + ) + } + + private fun setupFilePathOrLanguageLabel(filePath: String?) { + val application = ApplicationManager.getApplication() + if (filePath != null) { + application.executeOnPooledThread { + val virtualFile = LocalFileSystem.getInstance().findFileByPath(filePath) + if (virtualFile == null) { + runInEdt { + add(createLanguageLabel(extension), BorderLayout.LINE_START) + } + } else { + runInEdt { + add(createFileLink(virtualFile), BorderLayout.LINE_START) + CodeGPTKeys.TOOLWINDOW_EDITOR_VIRTUAL_FILE.set(editorEx, virtualFile) + } + } + } + } else { + runInEdt { + add(createLanguageLabel(extension), BorderLayout.LINE_START) + } + } + } + + private fun createFileLink(virtualFile: VirtualFile): ActionLink { + val name = virtualFile.name + val fileActionLink = ActionLink(name) + fileActionLink.setExternalLinkIcon() + fileActionLink.addActionListener { OpenFileAction.openFile(virtualFile, project) } + return fileActionLink + } + + private fun createLanguageLabel(language: String): JBLabel { + val label = JBLabel(language) + label.border = JBUI.Borders.emptyLeft(4) + label.foreground = JBColor.GRAY + return label + } + + private fun createHeaderActions(): ActionToolbar { + val actionGroup = DefaultActionGroup("EDITOR_TOOLBAR_ACTION_GROUP", false) + actionGroup.add(AutoApplyAction(project, editorEx, this)) + actionGroup.add(InsertAtCaretAction(editorEx)) + actionGroup.add(CopyAction(editorEx)) + actionGroup.addSeparator() + actionGroup.add(createGearAction()) + + val toolbar = ActionManager.getInstance() + .createActionToolbar("NAVIGATION_BAR_TOOLBAR", actionGroup, true) + toolbar.layoutStrategy = ToolbarLayoutStrategy.NOWRAP_STRATEGY + toolbar.targetComponent = this + toolbar.component.border = JBUI.Borders.empty() + toolbar.updateActionsAsync() + return toolbar + } + + private fun createGearActionsMenu(): JBPopupMenu { + val menu = JBPopupMenu() + menu.add(JBMenuItem(DiffAction(editorEx, menu.location))) + menu.add(JBMenuItem(ReplaceSelectionAction(editorEx, menu.location))) + menu.add(JBMenuItem(EditAction(editorEx))) + menu.add(JBMenuItem(NewFileAction(editorEx, extension))) + return menu + } + + private fun createGearAction(): AnAction { + return object : AnAction("Editor Actions", "Editor actions", General.GearPlain) { + override fun actionPerformed(e: AnActionEvent) { + val inputEvent = e.inputEvent + if (inputEvent != null) { + createGearActionsMenu().show(inputEvent.component, 0, 0) + } + } + } + } + + fun setRightComponent(component: JPanel) { + if (!readOnly) { + remove(actionToolbar!!.component) + add(component, BorderLayout.LINE_END) + revalidate() + repaint() + } + } + + fun restoreActionToolbar() { + if (!readOnly) { + val components = components + for (component in components) { + if (layout is BorderLayout && + (layout as BorderLayout).getConstraints(component) == BorderLayout.LINE_END + ) { + remove(component) + } + } + + add(actionToolbar!!.component, BorderLayout.LINE_END) + actionToolbar!!.updateActionsAsync() + revalidate() + repaint() + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt index f6260ea1..6e9fdc12 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/actions/AutoApplyAction.kt @@ -3,11 +3,16 @@ package ee.carlrobert.codegpt.toolwindow.chat.editor.actions import com.intellij.diff.DiffManager import com.intellij.diff.chains.SimpleDiffRequestChain import com.intellij.diff.editor.ChainDiffVirtualFile +import com.intellij.diff.util.DiffUserDataKeys +import com.intellij.icons.AllIcons import com.intellij.notification.NotificationType import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.ex.CustomComponentAction import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.components.service import com.intellij.openapi.editor.Editor import com.intellij.openapi.fileEditor.FileEditorManager @@ -16,33 +21,37 @@ import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project import com.intellij.openapi.util.Key +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.readText import com.intellij.testFramework.LightVirtualFile +import com.intellij.ui.JBColor import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import ee.carlrobert.codegpt.CodeGPTBundle -import ee.carlrobert.codegpt.EncodingManager +import ee.carlrobert.codegpt.CodeGPTKeys import ee.carlrobert.codegpt.Icons import ee.carlrobert.codegpt.actions.ActionType import ee.carlrobert.codegpt.actions.TrackableAction import ee.carlrobert.codegpt.completions.CompletionClientProvider import ee.carlrobert.codegpt.settings.GeneralSettings import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.codegpt.toolwindow.chat.editor.HeaderPanel import ee.carlrobert.codegpt.ui.OverlayUtil import ee.carlrobert.codegpt.util.EditorDiffUtil.createDiffRequest -import ee.carlrobert.codegpt.util.EditorUtil -import ee.carlrobert.codegpt.util.EditorUtil.getSelectedEditor import ee.carlrobert.llm.client.codegpt.request.AutoApplyRequest import ee.carlrobert.llm.client.codegpt.response.CodeGPTException import java.awt.FlowLayout import java.util.* +import javax.swing.Icon import javax.swing.JButton +import javax.swing.JComponent import javax.swing.JPanel class AutoApplyAction( private val project: Project, private val toolwindowEditor: Editor, - private val headerPanel: JPanel, + private val headerPanel: HeaderPanel, ) : TrackableAction( CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.title"), CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.description"), @@ -50,53 +59,66 @@ class AutoApplyAction( ActionType.AUTO_APPLY ) { private lateinit var diffRequestId: UUID + private var linksPanel: JPanel? = null companion object { private val DIFF_REQUEST_KEY = Key.create("codegpt.autoApply.diffRequest") } override fun update(e: AnActionEvent) { - val isCodeGPTSelected = GeneralSettings.getSelectedService() == ServiceType.CODEGPT - if (isCodeGPTSelected) { - validateAndUpdatePresentation(e) - } else { + if (GeneralSettings.getSelectedService() != ServiceType.CODEGPT) { e.presentation.disableAction(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.disabledTitle")) + return + } + + val editorVirtualFile = CodeGPTKeys.TOOLWINDOW_EDITOR_VIRTUAL_FILE.get(toolwindowEditor) + if (editorVirtualFile == null) { + e.presentation.disableAction(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.notApplicable")) } } override fun handleAction(event: AnActionEvent) { - val mainEditor = getSelectedEditor(project) - ?: throw IllegalStateException("Unable to find active editor") + val editorVirtualFile = CodeGPTKeys.TOOLWINDOW_EDITOR_VIRTUAL_FILE.get(toolwindowEditor) + ?: return + val request = AutoApplyRequest().apply { suggestedChanges = toolwindowEditor.document.text - fileContent = mainEditor.document.text + fileContent = editorVirtualFile.readText() } - headerPanel.getComponent(1).isVisible = false + val acceptLink = createDisabledActionLink(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.accept")) + val rejectLink = createDisabledActionLink(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.reject")) - val acceptLink = - createDisabledActionLink(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.accept")) - val rejectLink = - createDisabledActionLink(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.reject")) - - val actionsPanel = JPanel(FlowLayout(FlowLayout.TRAILING, 8, 0)).apply { + val newLinksPanel = JPanel(FlowLayout(FlowLayout.TRAILING, 8, 0)).apply { + isOpaque = false border = JBUI.Borders.empty(4, 0) add(acceptLink) add(JBLabel("|")) add(rejectLink) } - headerPanel.add(actionsPanel) + + linksPanel = newLinksPanel + headerPanel.setRightComponent(newLinksPanel) ProgressManager.getInstance().run( ApplyChangesBackgroundTask( project, request, { modifiedFileContent -> - acceptLink.setupLink(mainEditor, actionsPanel) { - EditorUtil.updateEditorDocument(mainEditor, modifiedFileContent) + acceptLink.isEnabled = true + acceptLink.addActionListener { + WriteCommandAction.runWriteCommandAction(project) { + editorVirtualFile.setBinaryContent(modifiedFileContent.toByteArray(editorVirtualFile.charset)) + } + resetState(editorVirtualFile) } - rejectLink.setupLink(mainEditor, actionsPanel) - showDiff(mainEditor, modifiedFileContent) + + rejectLink.isEnabled = true + rejectLink.addActionListener { + resetState(editorVirtualFile) + } + + showDiff(editorVirtualFile, modifiedFileContent) }, { val errorMessage = if (it is CodeGPTException) { @@ -109,7 +131,7 @@ class AutoApplyAction( } OverlayUtil.showNotification(errorMessage, NotificationType.ERROR) runInEdt { - resetState(mainEditor, actionsPanel) + resetState(editorVirtualFile) } }) ) @@ -119,51 +141,39 @@ class AutoApplyAction( return ActionUpdateThread.EDT } - private fun validateAndUpdatePresentation(e: AnActionEvent) { - val activeEditor = e.project?.let { getSelectedEditor(project) } - if (activeEditor == null) { - e.presentation.disableAction(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.noActiveFile")) - return - } - - val fileTokenCount = service().countTokens(activeEditor.document.text) - if (fileTokenCount > 4096) { - e.presentation.disableAction(CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.fileTooLarge")) - } else { - e.presentation.enableAction() - } - } - - private fun Presentation.disableAction(disabledText: String) { + private fun Presentation.disableAction(disabledText: String? = null) { isEnabled = false icon = Icons.LightningDisabled text = disabledText } - private fun Presentation.enableAction() { - isEnabled = true - icon = Icons.Lightning - text = CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.title") - } - - private fun JButton.setupLink( - mainEditor: Editor, - actionsPanel: JPanel, - onAction: (() -> Unit)? = null - ) { - isEnabled = true - addActionListener { - resetState(mainEditor, actionsPanel) - onAction?.invoke() - } - } - - private fun showDiff(mainEditor: Editor, modifiedFileContent: String) { + private fun showDiff(virtualFile: VirtualFile, modifiedFileContent: String) { diffRequestId = UUID.randomUUID() - val tempDiffFile = LightVirtualFile(mainEditor.virtualFile.name, modifiedFileContent) - val diffRequest = createDiffRequest(project, tempDiffFile, mainEditor).apply { + val tempDiffFile = LightVirtualFile(virtualFile.name, modifiedFileContent) + val diffRequest = createDiffRequest(project, tempDiffFile, virtualFile).apply { putUserData(DIFF_REQUEST_KEY, diffRequestId.toString()) + + val acceptAction = createContextActionButton( + CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.accept"), + Icons.GreenCheckmark, + JBColor.GREEN + ) { + WriteCommandAction.runWriteCommandAction(project) { + virtualFile.setBinaryContent(modifiedFileContent.toByteArray(virtualFile.charset)) + } + resetState(virtualFile) + } + + val rejectAction = createContextActionButton( + CodeGPTBundle.get("toolwindow.chat.editor.action.autoApply.reject"), + AllIcons.Actions.Close, + JBColor.RED + ) { + resetState(virtualFile) + } + + putUserData(DiffUserDataKeys.CONTEXT_ACTIONS, listOf(acceptAction, rejectAction)) } runInEdt { @@ -171,18 +181,13 @@ class AutoApplyAction( } } - private fun createDisabledActionLink(text: String): ActionLink { - return ActionLink(text).apply { - isEnabled = false - autoHideOnDisable = false - } - } + private fun resetState(virtualFile: VirtualFile) { + // Restore the action toolbar + headerPanel.restoreActionToolbar() + linksPanel = null - private fun resetState(mainEditor: Editor, actionsPanel: JPanel) { - headerPanel.remove(actionsPanel) - headerPanel.getComponent(1).isVisible = true - val fileEditorManager = project.service() - fileEditorManager.openFile(mainEditor.virtualFile, true) + val fileEditorManager = FileEditorManager.getInstance(project) + fileEditorManager.openFile(virtualFile, true) val diffFile = fileEditorManager.openFiles.firstOrNull { it is ChainDiffVirtualFile && it.chain.requests @@ -195,6 +200,44 @@ class AutoApplyAction( fileEditorManager.closeFile(diffFile) } } + + private fun createDisabledActionLink(text: String): ActionLink { + return ActionLink(text).apply { + isEnabled = false + autoHideOnDisable = false + } + } + + private fun createContextActionButton( + text: String, + icon: Icon, + textColor: JBColor, + onAction: (() -> Unit) + ): AnAction { + return object : AnAction(text, null, icon), CustomComponentAction { + override fun actionPerformed(e: AnActionEvent) { + onAction() + } + + override fun createCustomComponent( + presentation: Presentation, + place: String + ): JComponent { + val button = JButton(presentation.text).apply { + font = JBUI.Fonts.smallFont() + isFocusable = false + isOpaque = true + foreground = textColor + preferredSize = JBUI.size(preferredSize.width, 26) + maximumSize = JBUI.size(Int.MAX_VALUE, 26) + addActionListener { + onAction() + } + } + return button + } + } + } } internal class ApplyChangesBackgroundTask( @@ -222,4 +265,4 @@ internal class ApplyChangesBackgroundTask( onFailure(ex) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteOutputParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteOutputParser.kt new file mode 100644 index 00000000..3f79ae54 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/CompleteOutputParser.kt @@ -0,0 +1,68 @@ +package ee.carlrobert.codegpt.toolwindow.chat.parser + +import java.util.regex.Pattern + +class CompleteOutputParser { + + companion object { + private val CODE_BLOCK_PATTERN: Pattern = + Pattern.compile("```([a-zA-Z0-9_+-]*)(?::([^\\n]*))?\\n(.*?)```", Pattern.DOTALL) + private const val THINK_OPEN_TAG = "" + private const val THINK_CLOSE_TAG = "\n\n" + } + + var extractedThought: String? = null + private set + + /** + * Parses a complete text output, extracts an optional initial thought block, + * and identifies code blocks and regular text in the remaining content. + * + * @param completeOutput The full text output to parse + * @return A list of parsed response segments (excluding the thought) + */ + fun parse(completeOutput: String): List { + extractedThought = null + var contentToParse = completeOutput.replace("\r", "") + + if (contentToParse.startsWith(THINK_OPEN_TAG)) { + val closeTagIndex = contentToParse.indexOf(THINK_CLOSE_TAG) + if (closeTagIndex != -1) { + val startContent = THINK_OPEN_TAG.length + extractedThought = contentToParse.substring(startContent, closeTagIndex).trim() + contentToParse = contentToParse.substring(closeTagIndex + THINK_CLOSE_TAG.length) + } + } + + return buildList { + val matcher = CODE_BLOCK_PATTERN.matcher(contentToParse) + var lastEnd = 0 + + while (matcher.find()) { + if (matcher.start() > lastEnd) { + val textBefore = contentToParse.substring(lastEnd, matcher.start()) + if (textBefore.isNotEmpty()) { + add(StreamParseResponse.Text(textBefore)) + } + } + + val language = matcher.group(1) ?: "" + val filePath: String? = matcher.group(2) + val codeContent = matcher.group(3) ?: "" + + add(StreamParseResponse.CodeHeader(language, filePath)) + add(StreamParseResponse.CodeContent(codeContent, language, filePath)) + add(StreamParseResponse.CodeEnd(language, filePath)) + + lastEnd = matcher.end() + } + + if (lastEnd < contentToParse.length) { + val remainingText = contentToParse.substring(lastEnd) + if (remainingText.isNotEmpty()) { + add(StreamParseResponse.Text(remainingText)) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/StreamOutputParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/StreamOutputParser.kt new file mode 100644 index 00000000..6e527c83 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/parser/StreamOutputParser.kt @@ -0,0 +1,195 @@ +package ee.carlrobert.codegpt.toolwindow.chat.parser + +sealed class StreamParseResponse( + val type: StreamResponseType, + val content: String, + val language: String? = null, + val filePath: String? = null +) { + enum class StreamResponseType { + TEXT, THINKING, CODE_HEADER, CODE_CONTENT, CODE_END + } + + data class Text(val textContent: String) : + StreamParseResponse(StreamResponseType.TEXT, textContent) + + data class Thinking(val thoughtProcess: String) : + StreamParseResponse(StreamResponseType.THINKING, thoughtProcess) + + data class CodeHeader(val codeLanguage: String, val codeFilePath: String?) : + StreamParseResponse(StreamResponseType.CODE_HEADER, "", codeLanguage, codeFilePath) + + data class CodeContent( + val codeContent: String, + val codeLanguage: String, + val codeFilePath: String? + ) : + StreamParseResponse( + StreamResponseType.CODE_CONTENT, + codeContent, + codeLanguage, + codeFilePath + ) + + data class CodeEnd(val codeLanguage: String, val codeFilePath: String?) : + StreamParseResponse(StreamResponseType.CODE_END, "", codeLanguage, codeFilePath) +} + +class StreamOutputParser { + companion object { + private val CODE_BLOCK_PATTERN = Regex("```([a-zA-Z0-9_+-]*)(?::([^\\n]*))?\\n") + private const val THINK_START_TAG = "" + private const val THINK_END_TAG = "" + } + + private val messageBuilder = StringBuilder() + private var isProcessingCode = false + private var currentLanguage: String? = null + private var currentFilePath: String? = null + + private fun handleProcessText(matcher: MatchResult): List { + val responses = mutableListOf() + isProcessingCode = true + + val startingIndex = matcher.range.first + val prevMessage = messageBuilder.substring(0, startingIndex) + + currentLanguage = matcher.groupValues[1].takeIf { it.isNotEmpty() } ?: "" + currentFilePath = matcher.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } + + messageBuilder.delete(0, startingIndex + matcher.value.length) + + if (prevMessage.isNotEmpty()) { + responses.add(StreamParseResponse.Text(prevMessage)) + } + + responses.add( + StreamParseResponse.CodeHeader( + currentLanguage ?: "", + currentFilePath + ) + ) + + if (messageBuilder.isNotEmpty()) { + responses.add( + StreamParseResponse.CodeContent( + messageBuilder.toString(), + currentLanguage ?: "", + currentFilePath + ) + ) + } + + return responses + } + + private fun handleThinking(): List { + val startIndex = messageBuilder.indexOf(THINK_START_TAG) + + if (messageBuilder.contains(" contentEndIndex) { + return listOf(StreamParseResponse.Thinking("")) + } + + val thoughtContent = messageBuilder.substring(contentStartIndex, contentEndIndex) + return listOf(StreamParseResponse.Thinking(thoughtContent)) + } + + private fun handleProcessCode(endingIndex: Int): List { + val responses = mutableListOf() + isProcessingCode = false + + val codeContent = messageBuilder.substring(0, endingIndex) + + var deleteEndIndex = endingIndex + 3 + if (deleteEndIndex < messageBuilder.length && messageBuilder[deleteEndIndex] == '\n') { + deleteEndIndex++ + } + messageBuilder.delete(0, deleteEndIndex) + + if (codeContent.isNotEmpty()) { + responses.add( + StreamParseResponse.CodeContent( + codeContent, + currentLanguage ?: "", + currentFilePath + ) + ) + } + + responses.add( + StreamParseResponse.CodeEnd( + currentLanguage ?: "", + currentFilePath + ) + ) + + if (messageBuilder.isNotEmpty()) { + responses.add(StreamParseResponse.Text(messageBuilder.toString())) + } + + return responses + } + + fun parse(message: String): List { + val sanitizedMessage = message.replace("\r", "") + messageBuilder.append(sanitizedMessage) + + if (messageBuilder.length < THINK_START_TAG.length) { + return emptyList(); + } + + val isThinking = + messageBuilder.startsWith(THINK_START_TAG) || THINK_START_TAG.startsWith(messageBuilder) + if (isThinking) { + return handleThinking() + } + + if (isProcessingCode) { + val endingIndex = messageBuilder.indexOf("```") + if (endingIndex >= 0) { + return handleProcessCode(endingIndex) + } + } else { + val matcher = CODE_BLOCK_PATTERN.find(messageBuilder.toString()) + if (matcher != null) { + return handleProcessText(matcher) + } + } + + return if (isProcessingCode) { + listOf( + StreamParseResponse.CodeContent( + messageBuilder.toString(), + currentLanguage ?: "", + currentFilePath + ) + ) + } else { + listOf(StreamParseResponse.Text(messageBuilder.toString())) + } + } + + fun clear() { + messageBuilder.setLength(0) + isProcessingCode = false + currentLanguage = null + currentFilePath = null + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/ThoughtProcessPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/ThoughtProcessPanel.kt index 838ca566..bcab460d 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/ThoughtProcessPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/ThoughtProcessPanel.kt @@ -25,6 +25,8 @@ class ThoughtProcessPanel : JPanel(BorderLayout()) { add(contentPanel, BorderLayout.CENTER) } + fun isFinished(): Boolean = finished + fun setFinished() { if (finished) return 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 9a444e30..41c24df0 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/TagProcessorFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/TagProcessorFactory.kt @@ -5,19 +5,9 @@ import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import ee.carlrobert.codegpt.EncodingManager +import ee.carlrobert.codegpt.completions.CompletionRequestUtil import ee.carlrobert.codegpt.conversations.message.Message -import ee.carlrobert.codegpt.ui.textarea.header.tag.CurrentGitChangesTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.DocumentationTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorSelectionTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.EmptyTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.FolderTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.GitCommitTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.PersonaTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.SelectionTagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails -import ee.carlrobert.codegpt.ui.textarea.header.tag.WebTagDetails +import ee.carlrobert.codegpt.ui.textarea.header.tag.* import ee.carlrobert.codegpt.util.GitUtil import git4idea.GitCommit @@ -72,10 +62,12 @@ class SelectionTagProcessor( return } - promptBuilder - .append("\n```${tagDetails.virtualFile.extension}\n") - .append(tagDetails.selectedText) - .append("\n```\n") + promptBuilder.append( + CompletionRequestUtil.formatCode( + tagDetails.selectedText ?: "", + tagDetails.virtualFile.path + ) + ) tagDetails.selectionModel.let { if (it.hasSelection()) { @@ -93,10 +85,12 @@ class EditorSelectionTagProcessor( return } - promptBuilder - .append("\n```${tagDetails.virtualFile.extension}\n") - .append(tagDetails.selectedText) - .append("\n```\n") + promptBuilder.append( + CompletionRequestUtil.formatCode( + tagDetails.selectedText ?: "", + tagDetails.virtualFile.path + ) + ) } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/EditorDiffUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/EditorDiffUtil.kt index fb4308d6..a54c5106 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/util/EditorDiffUtil.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/EditorDiffUtil.kt @@ -4,14 +4,11 @@ import com.intellij.diff.DiffContentFactory import com.intellij.diff.DiffManager import com.intellij.diff.requests.SimpleDiffRequest import com.intellij.diff.util.DiffUserDataKeys -import com.intellij.diff.util.DiffUtil -import com.intellij.diff.util.Side import com.intellij.openapi.components.service import com.intellij.openapi.editor.Editor import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Pair import com.intellij.openapi.vfs.VirtualFile import com.intellij.testFramework.LightVirtualFile import ee.carlrobert.codegpt.CodeGPTBundle @@ -32,7 +29,7 @@ object EditorDiffUtil { createTempDiffContent(mainEditor, toolwindowEditor, highlightedText) ) DiffManager.getInstance() - .showDiff(project, createDiffRequest(project, tempFile, mainEditor)) + .showDiff(project, createDiffRequest(project, tempFile, mainEditor.virtualFile)) } private fun createTempDiffContent( @@ -52,7 +49,7 @@ object EditorDiffUtil { fun createDiffRequest( project: Project, tempFile: VirtualFile, - mainEditor: Editor, + virtualFile: VirtualFile ): SimpleDiffRequest { val diffContentFactory = DiffContentFactory.getInstance() val tempFileDiffContent = diffContentFactory.create(project, tempFile).apply { @@ -61,15 +58,10 @@ object EditorDiffUtil { return SimpleDiffRequest( CodeGPTBundle.get("editor.diff.title"), - diffContentFactory.create(project, mainEditor.virtualFile), + diffContentFactory.create(project, virtualFile), tempFileDiffContent, - mainEditor.virtualFile.name, + virtualFile.name, CodeGPTBundle.get("editor.diff.local.content.title") - ).apply { - putUserData( - DiffUserDataKeys.SCROLL_TO_LINE, - Pair.create(Side.RIGHT, DiffUtil.getCaretPosition(mainEditor).line) - ) - } + ) } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ThinkingOutputParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/ThinkingOutputParser.kt similarity index 96% rename from src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ThinkingOutputParser.kt rename to src/main/kotlin/ee/carlrobert/codegpt/util/ThinkingOutputParser.kt index d2fd0fd5..1f6fccd9 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ThinkingOutputParser.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/ThinkingOutputParser.kt @@ -1,4 +1,4 @@ -package ee.carlrobert.codegpt.toolwindow.chat +package ee.carlrobert.codegpt.util class ThinkingOutputParser { @@ -50,4 +50,4 @@ class ThinkingOutputParser { return "" } } -} +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/file/FileUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/file/FileUtil.kt index 9f29e451..b48bae76 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/util/file/FileUtil.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/file/FileUtil.kt @@ -123,7 +123,7 @@ object FileUtil { } @JvmStatic - fun findLanguageExtensionMapping(language: String): Map.Entry { + fun findLanguageExtensionMapping(language: String? = ""): Map.Entry { val defaultValue = mapOf("Text" to ".txt").entries.first() val mapper = ObjectMapper() @@ -217,7 +217,7 @@ object FileUtil { @JvmStatic fun findFirstExtension( languageFileExtensionMappings: List, - language: String + language: String? = "" ): Optional> { return languageFileExtensionMappings.stream() .filter { diff --git a/src/main/resources/icons/collapseAll.svg b/src/main/resources/icons/collapseAll.svg new file mode 100644 index 00000000..87859d7b --- /dev/null +++ b/src/main/resources/icons/collapseAll.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/collapseAll_dark.svg b/src/main/resources/icons/collapseAll_dark.svg new file mode 100644 index 00000000..83e75744 --- /dev/null +++ b/src/main/resources/icons/collapseAll_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/expandAll.svg b/src/main/resources/icons/expandAll.svg new file mode 100644 index 00000000..8d9ba307 --- /dev/null +++ b/src/main/resources/icons/expandAll.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/expandAll_dark.svg b/src/main/resources/icons/expandAll_dark.svg new file mode 100644 index 00000000..6c982e13 --- /dev/null +++ b/src/main/resources/icons/expandAll_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index a9da4a7c..0ac9006e 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -206,8 +206,8 @@ toolwindow.chat.editor.action.autoApply.disabledTitle=Auto apply is only availab toolwindow.chat.editor.action.autoApply.description=Apply suggested changes automatically toolwindow.chat.editor.action.autoApply.noActiveFile=Active file not found toolwindow.chat.editor.action.autoApply.fileTooLarge=Active file too large to process -toolwindow.chat.editor.action.autoApply.accept=Accept -toolwindow.chat.editor.action.autoApply.reject=Reject +toolwindow.chat.editor.action.autoApply.accept=Accept All +toolwindow.chat.editor.action.autoApply.reject=Reject All toolwindow.chat.editor.action.autoApply.error=Something went wrong while applying changes. {0} toolwindow.chat.editor.action.autoApply.taskTitle=Apply changes toolwindow.chat.editor.action.autoApply.loadingMessage=ProxyAI: Applying changes @@ -330,3 +330,4 @@ tagPopupMenuItem.closeOthers=Close Other Tags tagPopupMenuItem.closeAll=Close All Tags tagPopupMenuItem.closeTagsToLeft=Close Tags to the Left tagPopupMenuItem.closeTagsToRight=Close Tags to the Right +toolwindow.chat.editor.action.autoApply.notApplicable=Unable to locate file for applying changes diff --git a/src/main/resources/prompts/persona/default-persona.txt b/src/main/resources/prompts/persona/default-persona.txt index b329dbda..b34bd5c4 100644 --- a/src/main/resources/prompts/persona/default-persona.txt +++ b/src/main/resources/prompts/persona/default-persona.txt @@ -1,15 +1,41 @@ -You are an AI programming assistant. -Follow the user's requirements carefully & to the letter. -Your responses should be informative and logical. -You should always adhere to technical information. -If the user asks for code or technical questions, you must provide code suggestions and adhere to technical information. -If the question is related to a developer, you must respond with content related to a developer. -First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail. -Then output the code in a single code block. -Minimize any other prose. -Keep your answers short and impersonal. -Use Markdown formatting in your answers. -Always format code using Markdown code blocks, with the programming language specified at the start. -Avoid wrapping the whole response in triple backticks. -The user works in an IDE built by JetBrains which has a concept for editors with open files, integrated unit test support, and output pane that shows the output of running the code as well as an integrated terminal. -You can only give one reply for each conversation turn. \ No newline at end of file +You are an AI programming assistant integrated into a JetBrains IDE plugin. Your primary function is to provide code suggestions, technical information, and programming-related assistance within the IDE environment. You will receive a project path and a user query, and must respond accordingly. + +Here is the project path: + +{{project_path}} + + +Instructions for your response: + +1. Analyze the project structure based on the given project path. +2. Determine if the query is code-related or a request for technical information. +3. If code-related: + a. Identify the most appropriate programming language based on the query context and project structure. + b. Determine a suitable file path for the code. IMPORTANT: Always generate a full file path, not just a filename. If there's no explicit context for the file location, make an educated guess based on common project structures or any information provided in the query or project path. +4. If it's a request for technical information, outline the key points you'll cover in your explanation. + +After your analysis, provide your response using the following structure: + +1. Begin with a brief, impersonal response that directly addresses the query. +2. For code-related queries, provide the code suggestion in a Markdown code block with this format: + ```[language]:[full_file_path] + // Code content + ``` +3. Add a brief (1-2 sentence) explanation after each code block. +4. For technical information queries, provide a concise explanation of key points. + +Example output structure: + +[Brief, impersonal response to the query] + +```[language]:[full_file_path] +[Code content] +``` + +[Short description of the code suggestion] + +[Concise explanation of key points] + +Remember: +- Always provide full file paths, even if you need to make an educated guess based on common project structures. +- Include brief descriptions between each code block for better visual presentation in the UI. \ No newline at end of file diff --git a/src/test/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestProviderTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestProviderTest.kt index a106ecde..257cd295 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestProviderTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestProviderTest.kt @@ -2,10 +2,8 @@ package ee.carlrobert.codegpt.completions import com.intellij.openapi.components.service 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.prompts.PersonasState.Companion.DEFAULT_PERSONA_PROMPT import ee.carlrobert.codegpt.settings.prompts.PromptsSettings import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel import org.assertj.core.api.Assertions.assertThat @@ -40,32 +38,6 @@ class CompletionRequestProviderTest : IntegrationTest() { ) } - fun testChatCompletionRequestWithoutSystemPromptOverride() { - useOpenAIService(OpenAIChatCompletionModel.GPT_3_5.code) - service().state.personas.selectedPersona.instructions = DEFAULT_PERSONA_PROMPT - val conversation = ConversationService.getInstance().startConversation() - val firstMessage = createDummyMessage(500) - val secondMessage = createDummyMessage(250) - conversation.addMessage(firstMessage) - conversation.addMessage(secondMessage) - val callParameters = ChatCompletionParameters - .builder(conversation, Message("TEST_CHAT_COMPLETION_PROMPT")) - .build() - - val request = OpenAIRequestFactory().createChatRequest(callParameters) - - assertThat(request.messages) - .extracting("role", "content") - .containsExactly( - Tuple.tuple("system", DEFAULT_PERSONA_PROMPT), - Tuple.tuple("user", "TEST_PROMPT"), - Tuple.tuple("assistant", firstMessage.response), - Tuple.tuple("user", "TEST_PROMPT"), - Tuple.tuple("assistant", secondMessage.response), - Tuple.tuple("user", "TEST_CHAT_COMPLETION_PROMPT") - ) - } - fun testChatCompletionRequestRetry() { useOpenAIService(OpenAIChatCompletionModel.GPT_3_5.code) service().state.personas.selectedPersona.instructions = "TEST_SYSTEM_PROMPT" @@ -90,33 +62,6 @@ class CompletionRequestProviderTest : IntegrationTest() { ) } - fun testReducedChatCompletionRequest() { - useOpenAIService(OpenAIChatCompletionModel.GPT_3_5.code) - service().state.personas.selectedPersona.instructions = DEFAULT_PERSONA_PROMPT - val conversation = Conversation() - conversation.addMessage(createDummyMessage(50)) - conversation.addMessage(createDummyMessage(100)) - conversation.addMessage(createDummyMessage(150)) - conversation.addMessage(createDummyMessage(1000)) - val remainingMessage = createDummyMessage(1000) - conversation.addMessage(remainingMessage) - conversation.discardTokenLimits() - val callParameters = ChatCompletionParameters - .builder(conversation, Message("TEST_CHAT_COMPLETION_PROMPT")) - .build() - - val request = OpenAIRequestFactory().createChatRequest(callParameters) - - assertThat(request.messages) - .extracting("role", "content") - .containsExactly( - Tuple.tuple("system", DEFAULT_PERSONA_PROMPT), - Tuple.tuple("user", "TEST_PROMPT"), - Tuple.tuple("assistant", remainingMessage.response), - Tuple.tuple("user", "TEST_CHAT_COMPLETION_PROMPT") - ) - } - fun testTotalUsageExceededException() { useOpenAIService(OpenAIChatCompletionModel.GPT_3_5.code) val conversation = ConversationService.getInstance().startConversation() diff --git a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanelTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanelTest.kt index 02df23a8..d0d79247 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanelTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanelTest.kt @@ -129,22 +129,28 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { File Path: /TEST_FILE_NAME_1 File Content: - ```TEST_FILE_NAME_1 + + ```/TEST_FILE_NAME_1:/TEST_FILE_NAME_1 TEST_FILE_CONTENT_1 ``` + File Path: /TEST_FILE_NAME_2 File Content: - ```TEST_FILE_NAME_2 + + ```/TEST_FILE_NAME_2:/TEST_FILE_NAME_2 TEST_FILE_CONTENT_2 ``` + File Path: /TEST_FILE_NAME_3 File Content: - ```TEST_FILE_NAME_3 + + ```/TEST_FILE_NAME_3:/TEST_FILE_NAME_3 TEST_FILE_CONTENT_3 ``` + Question: TEST_MESSAGE""".trimIndent() ) ) @@ -330,22 +336,28 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { File Path: /TEST_FILE_NAME_1 File Content: - ```TEST_FILE_NAME_1 + + ```/TEST_FILE_NAME_1:/TEST_FILE_NAME_1 TEST_FILE_CONTENT_1 ``` + File Path: /TEST_FILE_NAME_2 File Content: - ```TEST_FILE_NAME_2 + + ```/TEST_FILE_NAME_2:/TEST_FILE_NAME_2 TEST_FILE_CONTENT_2 ``` + File Path: /TEST_FILE_NAME_3 File Content: - ```TEST_FILE_NAME_3 + + ```/TEST_FILE_NAME_3:/TEST_FILE_NAME_3 TEST_FILE_CONTENT_3 ``` + Question: TEST_MESSAGE""".trimIndent() ) ) diff --git a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/CompleteOutputParserTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/CompleteOutputParserTest.kt new file mode 100644 index 00000000..871b8767 --- /dev/null +++ b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/CompleteOutputParserTest.kt @@ -0,0 +1,331 @@ +package ee.carlrobert.codegpt.toolwindow.chat + +import ee.carlrobert.codegpt.toolwindow.chat.parser.CompleteOutputParser +import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse +import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse.StreamResponseType +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class CompleteOutputParserTest { + + @Test + fun `parse should return empty list for empty input`() { + val input = "" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).isEmpty() + } + + @Test + fun `parse should return single text element for text only input`() { + val input = "This is just plain text without any code blocks." + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = input) + ) + } + + @Test + fun `parse should handle single code block with language`() { + val language = "java" + val code = """ + public class Test { + public static void main(String[] args) { + System.out.println("Hello"); + } + } + """.trimIndent() + val input = "Here's some Java code:\n```java\n$code\n```\nEnd of example." + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = "Here's some Java code:\n"), + expectedResponse(StreamResponseType.CODE_HEADER, language = language), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$code\n", + language = language + ), + expectedResponse( + StreamResponseType.CODE_END, + language = language + ), + expectedResponse(StreamResponseType.TEXT, content = "\nEnd of example.") + ) + } + + @Test + fun `parse should handle code block with file path`() { + val language = "python" + val filePath = "src/main.py" + val code = """ + def hello(): + print('Hello, world!') + """.trimIndent() + val input = "```python:src/main.py\n$code\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse( + StreamResponseType.CODE_HEADER, + language = language, + filePath = filePath + ), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$code\n", + language = language, + filePath = filePath + ), + expectedResponse(StreamResponseType.CODE_END, language = language, filePath = filePath) + ) + } + + @Test + fun `parse should handle multiple code blocks`() { + val javaCode = "System.out.println();" + val pythonCode = "print('hello')" + val input = + "First block:\n```java\n$javaCode\n```\nSecond block:\n```python\n$pythonCode\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = "First block:\n"), + expectedResponse(StreamResponseType.CODE_HEADER, language = "java"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$javaCode\n", + language = "java" + ), + expectedResponse(StreamResponseType.CODE_END, language = "java"), + expectedResponse(StreamResponseType.TEXT, content = "\nSecond block:\n"), + expectedResponse(StreamResponseType.CODE_HEADER, language = "python"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$pythonCode\n", + language = "python" + ), + expectedResponse(StreamResponseType.CODE_END, language = "python") + ) + } + + @Test + fun `parse should handle code block without language`() { + val code = "const x = 10;" + val input = "Code without language specification:\n```\n$code\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse( + StreamResponseType.TEXT, + content = "Code without language specification:\n" + ), + expectedResponse( + StreamResponseType.CODE_HEADER, + language = "" + ), + expectedResponse(StreamResponseType.CODE_CONTENT, content = "$code\n", language = ""), + expectedResponse(StreamResponseType.CODE_END, language = "") + ) + } + + @Test + fun `parse should handle windows line endings`() { + val code = "System.out.println();" + val input = "Windows line endings:\r\n```java\r\n$code\r\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = "Windows line endings:\n"), + expectedResponse(StreamResponseType.CODE_HEADER, language = "java"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$code\n", + language = "java" + ), + expectedResponse(StreamResponseType.CODE_END, language = "java") + ) + } + + @Test + fun `parse should handle nested backticks within code block`() { + val code = "console.log(`Template literal with backticks`);" + val input = "Nested backticks example:\n```javascript\n$code\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = "Nested backticks example:\n"), + expectedResponse(StreamResponseType.CODE_HEADER, language = "javascript"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$code\n", + language = "javascript" + ), + expectedResponse(StreamResponseType.CODE_END, language = "javascript") + ) + } + + @Test + fun `parse should handle special characters in language specifier`() { + val code = "std::cout << \"Hello\";" + val input = "Special language name:\n```c++\n$code\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = "Special language name:\n"), + expectedResponse(StreamResponseType.CODE_HEADER, language = "c++"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$code\n", + language = "c++" + ), + expectedResponse(StreamResponseType.CODE_END, language = "c++") + ) + } + + @Test + fun `parse should treat incomplete code block as text`() { + val input = "Incomplete code block:\n```java\nSystem.out.println();" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = input) + ) + } + + @Test + fun `parse should handle adjacent code blocks`() { + val javaCode = "int x = 1;" + val pythonCode = "print(2)" + val input = "```java\n$javaCode\n``````python\n$pythonCode\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.CODE_HEADER, language = "java"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$javaCode\n", + language = "java" + ), + expectedResponse(StreamResponseType.CODE_END, language = "java"), + expectedResponse(StreamResponseType.CODE_HEADER, language = "python"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$pythonCode\n", + language = "python" + ), + expectedResponse(StreamResponseType.CODE_END, language = "python") + ) + } + + @Test + fun `parse should handle code block with empty content`() { + val input = "Empty code block:\n```java\n\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = "Empty code block:\n"), + expectedResponse(StreamResponseType.CODE_HEADER, language = "java"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "\n", + language = "java" + ), + expectedResponse(StreamResponseType.CODE_END, language = "java") + ) + } + + @Test + fun `parse should handle hyphen in language specifier`() { + val code = "NSLog(@\"Hello\");" + val input = "Language with hyphen:\n```objective-c\n$code\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = "Language with hyphen:\n"), + expectedResponse(StreamResponseType.CODE_HEADER, language = "objective-c"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$code\n", + language = "objective-c" + ), + expectedResponse(StreamResponseType.CODE_END, language = "objective-c") + ) + } + + @Test + fun `parse should handle plus in language specifier`() { + val code = "std::cout << \"Hello\";" + val input = "Language with plus:\n```c++\n$code\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = "Language with plus:\n"), + expectedResponse(StreamResponseType.CODE_HEADER, language = "c++"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$code\n", + language = "c++" + ), + expectedResponse(StreamResponseType.CODE_END, language = "c++") + ) + } + + @Test + fun `parse should handle underscore in language specifier`() { + val code = "print(\"Hello\");" + val input = "Language with underscore:\n```some_lang\n$code\n```" + + val result = CompleteOutputParser().parse(input) + + assertThat(result).containsExactly( + expectedResponse(StreamResponseType.TEXT, content = "Language with underscore:\n"), + expectedResponse(StreamResponseType.CODE_HEADER, language = "some_lang"), + expectedResponse( + StreamResponseType.CODE_CONTENT, + content = "$code\n", + language = "some_lang" + ), + expectedResponse(StreamResponseType.CODE_END, language = "some_lang") + ) + } + + private fun expectedResponse( + type: StreamResponseType, + content: String? = null, + language: String? = null, + filePath: String? = null + ): StreamParseResponse { + return when (type) { + StreamResponseType.TEXT -> StreamParseResponse.Text(content ?: "") + StreamResponseType.THINKING -> StreamParseResponse.Thinking(content ?: "") + StreamResponseType.CODE_HEADER -> StreamParseResponse.CodeHeader( + language ?: "", + filePath + ) + + StreamResponseType.CODE_CONTENT -> StreamParseResponse.CodeContent( + content ?: "", + language ?: "", + filePath + ) + + StreamResponseType.CODE_END -> StreamParseResponse.CodeEnd(language ?: "", filePath) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/StreamOutputParserTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/StreamOutputParserTest.kt new file mode 100644 index 00000000..58daedd0 --- /dev/null +++ b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/StreamOutputParserTest.kt @@ -0,0 +1,175 @@ +package ee.carlrobert.codegpt.toolwindow.chat + +import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamOutputParser +import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse +import ee.carlrobert.codegpt.toolwindow.chat.parser.StreamParseResponse.StreamResponseType +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.groups.Tuple +import org.junit.Before +import org.junit.Test +import kotlin.random.Random + +class StreamOutputParserTest { + + private lateinit var streamOutputParser: StreamOutputParser + + @Before + fun setUp() { + streamOutputParser = StreamOutputParser() + } + + @Test + fun testTextOnlyInput() { + val input = "This is just plain text without any code blocks." + + val result = streamOutputParser.parse(input) + + assertThat(result).hasSize(1) + assertThat(result[0].type).isEqualTo(StreamResponseType.TEXT) + assertThat(result[0].content).isEqualTo(input) + assertThat(result[0].language).isNull() + assertThat(result[0].filePath).isNull() + } + + @Test + fun testMultipleCodeBlocksWithThinking() { + val input = """ + + Here's some long thinking process + + + Here's some Java code: + + ```java + public class Test { + public static void main(String[] args) { + System.out.println("Hello"); + } + } + ``` + + Here's some Python code: + ```python:/path/to/my/file.py + def hello(): + print("Hello") + ``` + + Here's a basic markdown: + ``` + Some basic text + ``` + + End of `example`. + """.trimIndent() + + val response = simulateStreamedInput(input) + + assertThat(response.flatten()) + .extracting({ it.type }, { it.content.trim() }, { it.language }, { it.filePath }) + .contains( + Tuple.tuple(StreamResponseType.THINKING, "Here's some long thinking process", null, null), + Tuple.tuple(StreamResponseType.TEXT, "Here's some Java code:", null, null), + Tuple.tuple(StreamResponseType.CODE_HEADER, "", "java", null), + Tuple.tuple( + StreamResponseType.CODE_CONTENT, "public class Test {\n" + + " public static void main(String[] args) {\n" + + " System.out.println(\"Hello\");\n" + + " }\n" + + "}", "java", null + ), + Tuple.tuple(StreamResponseType.TEXT, "Here's some Python code:", null, null), + Tuple.tuple(StreamResponseType.CODE_HEADER, "", "python", "/path/to/my/file.py"), + Tuple.tuple( + StreamResponseType.CODE_CONTENT, + "def hello():\n print(\"Hello\")", + "python", + "/path/to/my/file.py" + ), + Tuple.tuple(StreamResponseType.TEXT, "Here's a basic markdown:", null, null), + Tuple.tuple(StreamResponseType.CODE_HEADER, "", "", null), + Tuple.tuple(StreamResponseType.CODE_CONTENT, "Some basic text", "", null), + Tuple.tuple(StreamResponseType.TEXT, "End of `example`.", null, null) + ) + } + + @Test + fun testMultipleCodeBlocksWithoutThinking() { + val input = """ + Here's some Java code: + + ```java + public class Test { + public static void main(String[] args) { + System.out.println("Hello"); + } + } + ``` + + Here's some Python code: + ```python:/path/to/my/file.py + def hello(): + print("Hello") + ``` + + Here's a basic markdown: + ``` + Some basic text + ``` + + End of `example`. + """.trimIndent() + + val response = simulateStreamedInput(input) + + assertThat(response.flatten()) + .extracting({ it.type }, { it.content.trim() }, { it.language }, { it.filePath }) + .contains( + Tuple.tuple(StreamResponseType.TEXT, "Here's some Java code:", null, null), + Tuple.tuple(StreamResponseType.CODE_HEADER, "", "java", null), + Tuple.tuple( + StreamResponseType.CODE_CONTENT, "public class Test {\n" + + " public static void main(String[] args) {\n" + + " System.out.println(\"Hello\");\n" + + " }\n" + + "}", "java", null + ), + Tuple.tuple(StreamResponseType.TEXT, "Here's some Python code:", null, null), + Tuple.tuple(StreamResponseType.CODE_HEADER, "", "python", "/path/to/my/file.py"), + Tuple.tuple( + StreamResponseType.CODE_CONTENT, + "def hello():\n print(\"Hello\")", + "python", + "/path/to/my/file.py" + ), + Tuple.tuple(StreamResponseType.TEXT, "Here's a basic markdown:", null, null), + Tuple.tuple(StreamResponseType.CODE_HEADER, "", "", null), + Tuple.tuple(StreamResponseType.CODE_CONTENT, "Some basic text", "", null), + Tuple.tuple(StreamResponseType.TEXT, "End of `example`.", null, null) + ) + } + + /** + * Simulates streaming input by breaking the input string into random chunks + * and feeding them to the StreamParser. + * + * @param input The complete input string + * @return List of responses from each parse call + */ + private fun simulateStreamedInput(input: String): List> { + streamOutputParser.clear() + val responses = mutableListOf>() + var remainingInput = input + + while (remainingInput.isNotEmpty()) { + // Take a random chunk size between 1 and the remaining length + val chunkSize = Random.nextInt(1, minOf(remainingInput.length + 1, 10)) + val chunk = remainingInput.substring(0, chunkSize) + remainingInput = remainingInput.substring(chunkSize) + + val response = streamOutputParser.parse(chunk) + responses.add(response) + } + + return responses + } +} diff --git a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ThinkingOutputParserTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ThinkingOutputParserTest.kt index 82d97aff..c62891b0 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ThinkingOutputParserTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ThinkingOutputParserTest.kt @@ -1,5 +1,6 @@ package ee.carlrobert.codegpt.toolwindow.chat +import ee.carlrobert.codegpt.util.ThinkingOutputParser import org.assertj.core.api.Assertions.assertThat import org.junit.Test