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 1b496adf..9e264479 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java @@ -4,11 +4,14 @@ import static ee.carlrobert.codegpt.ui.UIUtil.createScrollPaneWithSmartScroller; import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ModalityState; +import com.intellij.openapi.application.ReadAction; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.SelectionModel; import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.ui.JBColor; +import com.intellij.util.concurrency.AppExecutorUtil; import com.intellij.util.ui.JBUI; import ee.carlrobert.codegpt.CodeGPTKeys; import ee.carlrobert.codegpt.ReferencedFile; @@ -178,6 +181,7 @@ public class ChatToolWindowTabPanel implements Disposable { .referencedFiles(getReferencedFiles(selectedTags)) .history(getHistory(getSelectedTags())) .psiStructure(psiStructure) + .project(project) .chatMode(userInputPanel.getChatMode()); findTagOfType(selectedTags, PersonaTagDetails.class) @@ -286,8 +290,15 @@ public class ChatToolWindowTabPanel implements Disposable { public void includeFiles(List referencedFiles) { userInputPanel.includeFiles(referencedFiles); - totalTokensPanel.updateReferencedFilesTokens( - referencedFiles.stream().map(it -> ReferencedFile.from(it).fileContent()).toList()); + ReadAction.nonBlocking(() -> + referencedFiles.stream() + .map(it -> ReferencedFile.from(it).fileContent()) + .toList() + ) + .inSmartMode(project) + .expireWith(project) + .finishOnUiThread(ModalityState.any(), totalTokensPanel::updateReferencedFilesTokens) + .submit(AppExecutorUtil.getAppExecutorService()); } private boolean hasReferencedFilePaths(Message message) { @@ -498,6 +509,7 @@ public class ChatToolWindowTabPanel implements Disposable { userMessagePanel.addReloadAction(() -> reloadMessage( ChatCompletionParameters.builder(conversation, message) .conversationType(ConversationType.DEFAULT) + .project(project) .chatMode(userInputPanel.getChatMode()) .build(), userMessagePanel)); 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 69d1ef18..235c5196 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 @@ -13,17 +13,21 @@ import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.actionSystem.AnActionEvent; import com.intellij.openapi.actionSystem.DefaultActionGroup; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ModalityState; +import com.intellij.openapi.application.ReadAction; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.fileEditor.FileEditorManager; import com.intellij.openapi.options.ShowSettingsUtil; import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.VerticalFlowLayout; +import com.intellij.openapi.util.Disposer; import com.intellij.openapi.util.io.FileUtil; import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.ui.AnimatedIcon; import com.intellij.ui.PopupHandler; import com.intellij.ui.components.JBLabel; +import com.intellij.util.concurrency.AppExecutorUtil; import com.intellij.util.ui.JBUI; import com.intellij.util.ui.components.BorderLayoutPanel; import ee.carlrobert.codegpt.CodeGPTBundle; @@ -58,6 +62,7 @@ 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; diff --git a/src/main/java/ee/carlrobert/codegpt/ui/UIUtil.java b/src/main/java/ee/carlrobert/codegpt/ui/UIUtil.java index 7f240a4d..de4ed280 100644 --- a/src/main/java/ee/carlrobert/codegpt/ui/UIUtil.java +++ b/src/main/java/ee/carlrobert/codegpt/ui/UIUtil.java @@ -3,16 +3,20 @@ package ee.carlrobert.codegpt.ui; import static javax.swing.event.HyperlinkEvent.EventType.ACTIVATED; import com.intellij.ide.BrowserUtil; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.roots.ui.componentsList.components.ScrollablePanel; import com.intellij.openapi.ui.panel.ComponentPanelBuilder; import com.intellij.ui.JBColor; import com.intellij.ui.ScrollPaneFactory; import com.intellij.ui.components.JBRadioButton; import com.intellij.ui.components.JBTextArea; +import com.intellij.util.ui.HTMLEditorKitBuilder; import com.intellij.util.ui.JBUI; import com.intellij.util.ui.UI; import ee.carlrobert.codegpt.CodeGPTBundle; import ee.carlrobert.codegpt.toolwindow.chat.ui.SmartScroller; +import ee.carlrobert.codegpt.util.PsiLinkNavigator; import java.awt.BorderLayout; import java.awt.CardLayout; import java.awt.Component; @@ -43,6 +47,8 @@ import javax.swing.text.DefaultCaret; public class UIUtil { + private static final Logger LOG = Logger.getInstance(UIUtil.class); + public static JTextPane createTextPane(String text) { return createTextPane(text, true); } @@ -54,6 +60,7 @@ public class UIUtil { public static JTextPane createTextPane(String text, boolean opaque, HyperlinkListener listener) { var textPane = new JTextPane(); textPane.putClientProperty(JTextPane.HONOR_DISPLAY_PROPERTIES, true); + textPane.setEditorKit(HTMLEditorKitBuilder.simple()); textPane.addHyperlinkListener(listener); textPane.setContentType("text/html"); textPane.setEditable(false); @@ -103,12 +110,23 @@ public class UIUtil { } public static void handleHyperlinkClicked(HyperlinkEvent event) { + if (!ACTIVATED.equals(event.getEventType())) { + return; + } + + String desc = event.getDescription(); + if (desc != null && PsiLinkNavigator.isValidNavigationLink(desc)) { + ApplicationManager.getApplication() + .executeOnPooledThread(() -> PsiLinkNavigator.handle(desc)); + return; + } + var url = event.getURL(); - if (ACTIVATED.equals(event.getEventType()) && url != null) { + if (url != null) { try { BrowserUtil.browse(url.toURI()); } catch (URISyntaxException e) { - throw new RuntimeException(e); + LOG.warn("Failed to browse URL: " + url, e); } } } @@ -119,7 +137,6 @@ public class UIUtil { textArea.getActionMap().put("text-submit", onSubmit); } - public static JPanel createRadioButtonsPanel(List radioButtons) { var buttonGroup = new ButtonGroup(); var radioPanel = new JPanel(); diff --git a/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionEventListener.kt b/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionEventListener.kt index ca55f595..734801ff 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionEventListener.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/codecompletions/CodeCompletionEventListener.kt @@ -116,19 +116,20 @@ class CodeCompletionEventListener( } if (firstLineSent.get() && firstLine != null) { - val remainingContent = finalResult.removePrefix(firstLine!!).toString() + val first = firstLine ?: return + val remainingContent = finalResult.removePrefix(first).toString() if (remainingContent.trim().isEmpty()) { return } - val parsedContent = parseOutput(firstLine + remainingContent) + val parsedContent = parseOutput(first + remainingContent) if (parsedContent.isNotEmpty()) { cache?.setCache(prefix, suffix, parsedContent) CodeGPTKeys.REMAINING_CODE_COMPLETION.set( editor, PartialCodeCompletionResponse.newBuilder() - .setPartialCompletion(parsedContent.removePrefix(firstLine ?: "")) + .setPartialCompletion(parsedContent.removePrefix(first)) .build() ) } @@ -200,4 +201,4 @@ class CodeCompletionEventListener( .parse(prefix, suffix, input) .trimEnd() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt index e0ce4a38..ada4a8b3 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionParameters.kt @@ -1,5 +1,6 @@ package ee.carlrobert.codegpt.completions +import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import ee.carlrobert.codegpt.ReferencedFile import ee.carlrobert.codegpt.conversations.Conversation @@ -26,6 +27,7 @@ class ChatCompletionParameters private constructor( var referencedFiles: List?, var personaDetails: PersonaDetails?, var psiStructure: Set?, + var project: Project?, var chatMode: ChatMode = ChatMode.ASK, var featureType: FeatureType = FeatureType.CHAT ) : CompletionParameters { @@ -39,6 +41,7 @@ class ChatCompletionParameters private constructor( referencedFiles(this@ChatCompletionParameters.referencedFiles) personaDetails(this@ChatCompletionParameters.personaDetails) psiStructure(this@ChatCompletionParameters.psiStructure) + project(this@ChatCompletionParameters.project) chatMode(this@ChatCompletionParameters.chatMode) featureType(this@ChatCompletionParameters.featureType) } @@ -54,6 +57,7 @@ class ChatCompletionParameters private constructor( private var personaDetails: PersonaDetails? = null private var psiStructure: Set? = null private var gitDiff: String = "" + private var project: Project? = null private var chatMode: ChatMode = ChatMode.ASK private var featureType: FeatureType = FeatureType.CHAT @@ -83,6 +87,8 @@ class ChatCompletionParameters private constructor( fun psiStructure(psiStructure: Set?) = apply { this.psiStructure = psiStructure } + fun project(project: Project?) = apply { this.project = project } + fun chatMode(chatMode: ChatMode) = apply { this.chatMode = chatMode } fun featureType(featureType: FeatureType) = apply { this.featureType = featureType } @@ -99,6 +105,7 @@ class ChatCompletionParameters private constructor( referencedFiles, personaDetails, psiStructure, + project, chatMode, featureType ) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt index 27e5dceb..040389e0 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestUtil.kt @@ -43,7 +43,7 @@ object CompletionRequestUtil { fun getPromptWithContext( referencedFiles: List, userPrompt: String?, - psiStructure: Set? + psiStructure: Set?, ): String { val includedFilesSettings = service().state val repeatableContext = includedFilesSettings.repeatableContext @@ -77,7 +77,10 @@ object CompletionRequestUtil { } return includedFilesSettings.promptTemplate - .replace("{REPEATABLE_CONTEXT}", fileContext + structureContext.orEmpty()) + .replace("{REPEATABLE_CONTEXT}", buildString { + append(fileContext) + append(structureContext.orEmpty()) + }) .replace("{QUESTION}", userPrompt!!) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/ClaudeRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/ClaudeRequestFactory.kt index 52d257eb..a8213088 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/ClaudeRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/ClaudeRequestFactory.kt @@ -4,7 +4,6 @@ import com.intellij.openapi.components.service import ee.carlrobert.codegpt.completions.BaseRequestFactory import ee.carlrobert.codegpt.completions.ChatCompletionParameters import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings -import ee.carlrobert.codegpt.settings.models.ModelSettings import ee.carlrobert.codegpt.settings.prompts.FilteredPromptsService import ee.carlrobert.codegpt.settings.prompts.PromptsSettings import ee.carlrobert.codegpt.settings.service.FeatureType @@ -22,7 +21,8 @@ class ClaudeRequestFactory : BaseRequestFactory() { val selectedPersona = service().state.personas.selectedPersona if (!selectedPersona.disabled) { - system = service().getFilteredPersonaPrompt(params.chatMode) + val base = service().getFilteredPersonaPrompt(params.chatMode) + system = service().applyClickableLinks(base) } messages = params.conversation.messages diff --git a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/GoogleRequestFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/GoogleRequestFactory.kt index acd5cab5..cae415dd 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/GoogleRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/GoogleRequestFactory.kt @@ -10,10 +10,8 @@ import ee.carlrobert.codegpt.conversations.ConversationsState import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.settings.prompts.FilteredPromptsService import ee.carlrobert.codegpt.settings.prompts.PromptsSettings -import ee.carlrobert.codegpt.settings.service.google.GoogleSettings import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ModelSelectionService -import ee.carlrobert.codegpt.settings.models.ModelSettings import ee.carlrobert.codegpt.util.file.FileUtil import ee.carlrobert.llm.client.google.completion.GoogleCompletionContent import ee.carlrobert.llm.client.google.completion.GoogleCompletionRequest @@ -130,15 +128,16 @@ class GoogleRequestFactory : BaseRequestFactory() { messages.add(GoogleCompletionContent("model", listOf(prevMessage.response))) } - if (params.imageDetails != null) { + val imageDetails = params.imageDetails + if (imageDetails != null) { messages.add( GoogleCompletionContent( listOf( GoogleContentPart( null, GoogleContentPart.Blob( - params.imageDetails!!.mediaType, - params.imageDetails!!.data + imageDetails.mediaType, + imageDetails.data ) ), GoogleContentPart(message.prompt) @@ -192,16 +191,17 @@ class GoogleRequestFactory : BaseRequestFactory() { return when (params.conversationType) { ConversationType.DEFAULT -> { val selectedPersona = service().state.personas.selectedPersona - return if (!selectedPersona.disabled) { - service().getFilteredPersonaPrompt(params.chatMode) - } else { - null - } + if (!selectedPersona.disabled) { + val base = service().getFilteredPersonaPrompt(params.chatMode) + service().applyClickableLinks(base) + } else null } - ConversationType.FIX_COMPILE_ERRORS -> service().state.coreActions.fixCompileErrors.instructions + ConversationType.FIX_COMPILE_ERRORS -> service() + .applyClickableLinks(service().state.coreActions.fixCompileErrors.instructions.orEmpty()) - ConversationType.REVIEW_CHANGES -> service().state.coreActions.reviewChanges.instructions + ConversationType.REVIEW_CHANGES -> service() + .applyClickableLinks(service().state.coreActions.reviewChanges.instructions.orEmpty()) else -> null } 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 4b941575..a21ae44b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/LlamaRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/LlamaRequestFactory.kt @@ -13,14 +13,13 @@ import ee.carlrobert.codegpt.settings.prompts.PromptsSettings import ee.carlrobert.codegpt.settings.prompts.addProjectPath import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings -import ee.carlrobert.codegpt.conversations.message.Message import ee.carlrobert.llm.client.llama.completion.LlamaCompletionRequest class LlamaRequestFactory : BaseRequestFactory() { override fun createChatRequest(params: ChatCompletionParameters): LlamaCompletionRequest { val promptTemplate = getPromptTemplate() - val systemPrompt = + var systemPrompt = if (params.conversationType == ConversationType.FIX_COMPILE_ERRORS) { service().state.coreActions.fixCompileErrors.instructions } else { @@ -30,6 +29,7 @@ class LlamaRequestFactory : BaseRequestFactory() { ).addProjectPath() } } + systemPrompt = systemPrompt?.let { service().applyClickableLinks(it) } val prompt = promptTemplate.buildPrompt( systemPrompt, 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 cfb9a817..5d07362a 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/completions/factory/OpenAIRequestFactory.kt @@ -253,9 +253,12 @@ class OpenAIRequestFactory : BaseRequestFactory() { val selectedPersona = service().state.personas.selectedPersona if (callParameters.conversationType == ConversationType.DEFAULT && !selectedPersona.disabled) { val sessionPersonaDetails = callParameters.personaDetails - val instructions = sessionPersonaDetails?.instructions?.addProjectPath() - ?: service().getFilteredPersonaPrompt(callParameters.chatMode) + val baseInstructions = sessionPersonaDetails?.instructions?.addProjectPath() + ?: service() + .getFilteredPersonaPrompt(callParameters.chatMode) .addProjectPath() + val instructions = service() + .applyClickableLinks(baseInstructions) val history = if (conversationsHistory.isNullOrEmpty()) { "" } else { @@ -265,29 +268,22 @@ class OpenAIRequestFactory : BaseRequestFactory() { } if (instructions.isNotEmpty()) { + val content = if (history.isBlank()) instructions else instructions.trimEnd() + "\n" + history messages.add( OpenAIChatCompletionStandardMessage( role, - instructions + "\n" + history + content ) ) } } if (callParameters.conversationType == ConversationType.REVIEW_CHANGES) { - messages.add( - OpenAIChatCompletionStandardMessage( - role, - service().state.coreActions.reviewChanges.instructions - ) - ) + val base = service().state.coreActions.reviewChanges.instructions + messages.add(OpenAIChatCompletionStandardMessage(role, base)) } if (callParameters.conversationType == ConversationType.FIX_COMPILE_ERRORS) { - messages.add( - OpenAIChatCompletionStandardMessage( - role, - service().state.coreActions.fixCompileErrors.instructions - ) - ) + val base = service().state.coreActions.fixCompileErrors.instructions + messages.add(OpenAIChatCompletionStandardMessage(role, base)) } for (prevMessage in callParameters.conversation.messages) { @@ -333,15 +329,16 @@ class OpenAIRequestFactory : BaseRequestFactory() { ) } - if (callParameters.imageDetails != null) { + val imageDetails = callParameters.imageDetails + if (imageDetails != null) { messages.add( OpenAIChatCompletionDetailedMessage( "user", listOf( OpenAIMessageImageURLContent( OpenAIImageUrl( - callParameters.imageDetails!!.mediaType, - callParameters.imageDetails!!.data + imageDetails.mediaType, + imageDetails.data ) ), OpenAIMessageTextContent(message.prompt) @@ -355,7 +352,7 @@ class OpenAIRequestFactory : BaseRequestFactory() { CompletionRequestUtil.getPromptWithContext( referencedFiles, message.prompt, - psiStructure + psiStructure, ) } messages.add(OpenAIChatCompletionStandardMessage("user", prompt)) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlay.kt b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlay.kt index f3c73878..82e9bdf6 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlay.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/inlineedit/InlineEditInlay.kt @@ -66,12 +66,12 @@ class InlineEditInlay(private var editor: Editor) : Disposable { private var isUpdatingInlaySize = false private var resizeTimer: Timer? = null - private val project = editor.project!! + private val project = requireNotNull(editor.project) { "Editor project is null" } private var inlayDisposable: Disposable? = null private val psiStructureRepository = PsiStructureRepository( this, - editor.project!!, + project, tagManager, PsiStructureProvider(), CoroutineDispatchers() @@ -85,7 +85,7 @@ class InlineEditInlay(private var editor: Editor) : Disposable { ) private val userInputPanel = UserInputPanel( - project = editor.project!!, + project = project, totalTokensPanel = dummyTokensPanel, parentDisposable = this, featureType = FeatureType.INLINE_EDIT, @@ -497,7 +497,7 @@ class InlineEditInlay(private var editor: Editor) : Disposable { try { val refs = collectSelectedReferencedFiles() val diff = try { - GitUtil.getCurrentChanges(editor.project!!) + GitUtil.getCurrentChanges(project) } catch (_: Exception) { null } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/psistructure/KotlinFileAnalyzer.kt b/src/main/kotlin/ee/carlrobert/codegpt/psistructure/KotlinFileAnalyzer.kt index 6854960f..0c4a97eb 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/psistructure/KotlinFileAnalyzer.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/psistructure/KotlinFileAnalyzer.kt @@ -241,7 +241,7 @@ class KotlinFileAnalyzer( val parameters = constructor.valueParameters.map { parameter -> val type = parameter.typeReference?.text ?: "TypeUnknown" val resolvedType = resolveType(type) - ParameterInfo(parameter.name!!, resolvedType, getModifiers(parameter)) + ParameterInfo(parameter.name.orEmpty(), resolvedType, getModifiers(parameter)) } val modifierList = getModifiers(constructor) return ConstructorStructure(parameters, modifierList) @@ -260,10 +260,10 @@ class KotlinFileAnalyzer( val parameters = function.valueParameters.map { parameter -> val type = parameter.typeReference?.text ?: "TypeUnknown" val resolvedType = resolveType(type) - ParameterInfo(parameter.name!!, resolvedType, getModifiers(parameter)) + ParameterInfo(parameter.name.orEmpty(), resolvedType, getModifiers(parameter)) } val modifierList = getModifiers(function) - return MethodStructure(function.name!!, resolvedReturnType, parameters, modifierList) + return MethodStructure(function.name.orEmpty(), resolvedReturnType, parameters, modifierList) } private fun resolveType(shortType: String): ClassName { @@ -327,4 +327,4 @@ class KotlinFileAnalyzer( psiFileQueue.put(psiFile, ktFile.name) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ChatCompletionConfigurationForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ChatCompletionConfigurationForm.kt index bdea6828..48fc01ff 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ChatCompletionConfigurationForm.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ChatCompletionConfigurationForm.kt @@ -4,8 +4,6 @@ import com.intellij.openapi.components.service import com.intellij.openapi.ui.DialogPanel import com.intellij.ui.PortField import com.intellij.ui.components.JBCheckBox -import com.intellij.ui.components.JBTextField -import com.intellij.ui.components.fields.IntegerField import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBUI import ee.carlrobert.codegpt.CodeGPTBundle @@ -26,8 +24,17 @@ class ChatCompletionConfigurationForm { number = service().state.chatCompletionSettings.psiStructureAnalyzeDepth } + private val clickableLinksCheckBox = JBCheckBox( + CodeGPTBundle.get("configurationConfigurable.section.chatCompletion.clickableLinks.title"), + service().state.chatCompletionSettings.clickableLinksEnabled + ) + fun createPanel(): DialogPanel { return panel { + row { + cell(clickableLinksCheckBox) + .comment(CodeGPTBundle.get("configurationConfigurable.section.chatCompletion.clickableLinks.description")) + } row { cell(editorContextTagCheckBox) .comment(CodeGPTBundle.get("configurationConfigurable.section.chatCompletion.editorContextTag.description")) @@ -50,6 +57,7 @@ class ChatCompletionConfigurationForm { editorContextTagCheckBox.isSelected = prevState.editorContextTagEnabled psiStructureCheckBox.isSelected = prevState.psiStructureEnabled psiStructureAnalyzeDepthField.number = prevState.psiStructureAnalyzeDepth + clickableLinksCheckBox.isSelected = prevState.clickableLinksEnabled } fun getFormState(): ChatCompletionSettingsState { @@ -57,6 +65,7 @@ class ChatCompletionConfigurationForm { this.editorContextTagEnabled = editorContextTagCheckBox.isSelected this.psiStructureEnabled = psiStructureCheckBox.isSelected this.psiStructureAnalyzeDepth = psiStructureAnalyzeDepthField.number + this.clickableLinksEnabled = clickableLinksCheckBox.isSelected } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt index 1d841b3a..5b724a8a 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt @@ -49,6 +49,7 @@ class ChatCompletionSettingsState : BaseState() { var editorContextTagEnabled by property(true) var psiStructureEnabled by property(true) var psiStructureAnalyzeDepth by property(3) + var clickableLinksEnabled by property(true) } class CodeCompletionSettingsState : BaseState() { @@ -57,4 +58,4 @@ class CodeCompletionSettingsState : BaseState() { var collectDependencyStructure by property(true) var contextAwareEnabled by property(false) var psiStructureAnalyzeDepth by property(2) -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/FilteredPromptsService.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/FilteredPromptsService.kt index 8c426ff1..c4f94d78 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/FilteredPromptsService.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/prompts/FilteredPromptsService.kt @@ -4,6 +4,7 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.vfs.VirtualFile import ee.carlrobert.codegpt.settings.configuration.ChatMode +import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.util.file.FileUtil.getResourceContent /** @@ -25,17 +26,15 @@ class FilteredPromptsService { fun getFilteredPersonaPrompt(chatMode: ChatMode): String { val selectedPersona = service().state.personas.selectedPersona - + return when (chatMode) { ChatMode.EDIT -> DEFAULT_PERSONA_EDIT_MODE_PROMPT ChatMode.ASK -> { - if (isDefaultPersona(selectedPersona)) { - PersonasState.DEFAULT_PERSONA_PROMPT - } else { - val originalPrompt = getOriginalPersonaPrompt() - filterOutSearchReplaceInstructions(originalPrompt) - } + val originalPrompt = getOriginalPersonaPrompt() + if (isDefaultPersona(selectedPersona)) originalPrompt + else filterOutSearchReplaceInstructions(originalPrompt) } + ChatMode.AGENT -> PersonasState.DEFAULT_PERSONA_PROMPT } } @@ -47,6 +46,14 @@ class FilteredPromptsService { ChatMode.AGENT -> getSimpleEditCodePrompt() } + fun applyClickableLinks(prompt: String): String { + val enabled = + service().state.chatCompletionSettings.clickableLinksEnabled + if (!enabled) return prompt + + return prompt + "\n" + PSI_LINKS_GUIDELINES + } + private fun isDefaultPersona(persona: PersonaPromptDetailsState) = persona.id == DEFAULT_PERSONA_ID @@ -60,7 +67,7 @@ class FilteredPromptsService { private fun getOriginalPersonaPrompt(): String { val selectedPersona = service().state.personas.selectedPersona - + return selectedPersona.instructions ?: when { isDefaultPersona(selectedPersona) -> PersonasState.DEFAULT_PERSONA_PROMPT else -> "" @@ -101,6 +108,9 @@ class FilteredPromptsService { val DEFAULT_PERSONA_EDIT_MODE_PROMPT = getResourceContent(EDIT_MODE_PROMPT_RESOURCE) + private val PSI_LINKS_GUIDELINES = + getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + private val SEARCH_REPLACE_BLOCKS_REGEX = Regex( "When generating SEARCH/REPLACE blocks:.*?Keep SEARCH blocks concise while including necessary surrounding lines\\.", RegexOption.DOT_MATCHES_ALL @@ -144,4 +154,4 @@ class FilteredPromptsService { CALCULATOR_EXAMPLE_REGEX to CALCULATOR_EXAMPLE_REPLACEMENT ) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ollama/OllamaSettingsForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ollama/OllamaSettingsForm.kt index 36d78b5c..005fdab4 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ollama/OllamaSettingsForm.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/ollama/OllamaSettingsForm.kt @@ -113,13 +113,8 @@ class OllamaSettingsForm { .addComponentFillVertically(JPanel(), 0) .panel - fun getModel(featureType: FeatureType): String? { - return if (modelComboBoxes[featureType]!!.isEnabled) { - modelComboBoxes[featureType]!!.item - } else { - null - } - } + fun getModel(featureType: FeatureType): String? = + modelComboBoxes[featureType]?.let { combo -> if (combo.isEnabled) combo.item else null } fun getApiKey(): String? = String(apiKeyField.password).ifEmpty { null } @@ -137,11 +132,11 @@ class OllamaSettingsForm { fun applyChanges() { service().state.run { host = hostField.text - if (modelComboBoxes[FeatureType.CHAT]!!.isEnabled) - model = modelComboBoxes[FeatureType.CHAT]!!.item + if (modelComboBoxes[FeatureType.CHAT]?.isEnabled == true) + model = modelComboBoxes[FeatureType.CHAT]?.item codeCompletionsEnabled = codeCompletionConfigurationForm.isCodeCompletionsEnabled - fimTemplate = codeCompletionConfigurationForm.fimTemplate!! - fimOverride = codeCompletionConfigurationForm.fimOverride ?: false + fimTemplate = codeCompletionConfigurationForm.fimTemplate ?: fimTemplate + fimOverride = codeCompletionConfigurationForm.fimOverride == true } setCredential(OllamaApikey, getApiKey()) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/ErrorPopoverHandler.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/ErrorPopoverHandler.kt index 0d23a73e..7292cdda 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/ErrorPopoverHandler.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/header/ErrorPopoverHandler.kt @@ -52,7 +52,7 @@ class ErrorPopoverHandler( private fun showErrorPopoverWithHover() { if (errorContent == null) return - if (errorPopup != null && errorPopup!!.isVisible) return + if (errorPopup?.isVisible == true) return val documentationHint = DocumentationHintEditorPane( project, @@ -96,4 +96,4 @@ class ErrorPopoverHandler( errorPopup = popup popup.showUnderneathOf(errorLabel) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryToolWindow.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryToolWindow.kt index 82c0593f..8ce62f2b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryToolWindow.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/history/ChatHistoryToolWindow.kt @@ -198,9 +198,9 @@ class ChatHistoryToolWindow(private val project: Project) : BorderLayoutPanel() lastFilteredConversations = null projectFiltered } else if (searchText == lastSearchText && lastFilteredConversations != null) { - lastFilteredConversations!!.filter { conversation -> + lastFilteredConversations?.filter { conversation -> projectFiltered.contains(conversation) - } + }.orEmpty() } else { val startList = getOptimizedSearchStartList(searchText).filter { conversation -> projectFiltered.contains(conversation) @@ -215,7 +215,7 @@ class ChatHistoryToolWindow(private val project: Project) : BorderLayoutPanel() private fun getOptimizedSearchStartList(searchText: String): List { return if (lastSearchText.isNotEmpty() && searchText.startsWith(lastSearchText) && lastFilteredConversations != null) { - lastFilteredConversations!! + lastFilteredConversations.orEmpty() } else { allConversations } @@ -601,4 +601,4 @@ class ChatHistoryToolWindow(private val project: Project) : BorderLayoutPanel() refresh() } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt index bc23e2cf..15b298c0 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt @@ -5,6 +5,8 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.ReadAction import com.intellij.openapi.application.invokeLater import com.intellij.openapi.application.runInEdt import com.intellij.openapi.components.service @@ -20,6 +22,7 @@ import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.RightGap import com.intellij.ui.dsl.builder.panel import com.intellij.util.IconUtil +import com.intellij.util.concurrency.AppExecutorUtil import com.intellij.util.ui.AsyncProcessIcon import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel @@ -105,9 +108,16 @@ class UserInputPanel @JvmOverloads constructor( onSubmit = ::handleSubmit, onFilesDropped = { files -> includeFiles(files.toMutableList()) - totalTokensPanel.updateReferencedFilesTokens(files.map { - ReferencedFile.from(it).fileContent() - }) + ReadAction + .nonBlocking> { + files.map { ReferencedFile.from(it).fileContent() } + } + .inSmartMode(project) + .expireWith(project) + .finishOnUiThread(ModalityState.any()) { contents -> + totalTokensPanel.updateReferencedFilesTokens(contents) + } + .submit(AppExecutorUtil.getAppExecutorService()) }, featureType = featureType ) @@ -192,9 +202,16 @@ class UserInputPanel @JvmOverloads constructor( } FileDragAndDrop.install(this) { files -> includeFiles(files.toMutableList()) - totalTokensPanel.updateReferencedFilesTokens( - files.map { ReferencedFile.from(it).fileContent() } - ) + ReadAction + .nonBlocking> { + files.map { ReferencedFile.from(it).fileContent() } + } + .inSmartMode(project) + .expireWith(project) + .finishOnUiThread(ModalityState.any()) { contents -> + totalTokensPanel.updateReferencedFilesTokens(contents) + } + .submit(AppExecutorUtil.getAppExecutorService()) } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/MarkdownUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/MarkdownUtil.kt index 8d6c81c9..1708f4c6 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/util/MarkdownUtil.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/MarkdownUtil.kt @@ -7,40 +7,42 @@ import ee.carlrobert.codegpt.toolwindow.chat.ResponseNodeRenderer import java.util.regex.Pattern object MarkdownUtil { - /** - * Splits a given string into a list of strings where each element is either a code block - * surrounded by triple backticks or a non-code block text. - * - * @param inputMarkdown The input markdown formatted string to be split. - * @return A list of strings where each element is a code block or a non-code block text from the - * input string. - */ - @JvmStatic - fun splitCodeBlocks(inputMarkdown: String): List { - val result: MutableList = ArrayList() - val pattern = Pattern.compile( - """(?m)^```[a-zA-Z0-9]*\r?\n.*?\r?\n```""", - Pattern.DOTALL - ) - val matcher = pattern.matcher(inputMarkdown) - var start = 0 - while (matcher.find()) { - result.add(inputMarkdown.substring(start, matcher.start())) - result.add(matcher.group()) - start = matcher.end() - } - result.add(inputMarkdown.substring(start)) - return result.stream().filter(String::isNotBlank).toList() - } - @JvmStatic - fun convertMdToHtml(message: String): String { - val options = MutableDataSet() - options.set(HtmlRenderer.SOFT_BREAK, "
") - val document = Parser.builder(options).build().parse(message) - return HtmlRenderer.builder(options) - .nodeRendererFactory(ResponseNodeRenderer.Factory()) - .build() - .render(document) - } + /** + * Splits a given string into a list of strings where each element is either a code block + * surrounded by triple backticks or a non-code block text. + * + * @param inputMarkdown The input markdown formatted string to be split. + * @return A list of strings where each element is a code block or a non-code block text from the + * input string. + */ + @JvmStatic + fun splitCodeBlocks(inputMarkdown: String): List { + val result: MutableList = ArrayList() + val pattern = Pattern.compile( + """(?m)^```[a-zA-Z0-9]*\r?\n.*?\r?\n```""", + Pattern.DOTALL + ) + val matcher = pattern.matcher(inputMarkdown) + var start = 0 + while (matcher.find()) { + result.add(inputMarkdown.substring(start, matcher.start())) + result.add(matcher.group()) + start = matcher.end() + } + result.add(inputMarkdown.substring(start)) + return result.stream().filter(String::isNotBlank).toList() + } + + @JvmStatic + fun convertMdToHtml(message: String): String { + val options = MutableDataSet() + options.set(HtmlRenderer.SOFT_BREAK, "
") + + val document = Parser.builder(options).build().parse(message) + return HtmlRenderer.builder(options) + .nodeRendererFactory(ResponseNodeRenderer.Factory()) + .build() + .render(document) + } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/PsiLinkNavigator.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/PsiLinkNavigator.kt new file mode 100644 index 00000000..f61424ff --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/PsiLinkNavigator.kt @@ -0,0 +1,250 @@ +package ee.carlrobert.codegpt.util + +import com.intellij.ide.util.gotoByName.GotoClassModel2 +import com.intellij.ide.util.gotoByName.GotoFileModel +import com.intellij.ide.util.gotoByName.GotoSymbolModel2 +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.pom.Navigatable +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiNamedElement +import com.intellij.psi.search.FilenameIndex +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.util.concurrency.AppExecutorUtil +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +object PsiLinkNavigator { + private val logger = thisLogger() + + private const val PSI_ELEMENT_PREFIX = "psi_element://" + private const val FILE_PREFIX = "file://" + + private val SUPPORTED_PROTOCOLS = listOf(PSI_ELEMENT_PREFIX, FILE_PREFIX) + + @JvmStatic + fun handle(description: String): Boolean { + if (!isValidNavigationLink(description)) { + return false + } + + val protocol = extractProtocol(description) + val target = extractTarget(description) + + return try { + ReadAction.nonBlocking { + val resolver = NavigationResolverFactory.create(protocol) + resolver.resolve(target) + } + .finishOnUiThread(ModalityState.nonModal()) { navigatable -> + navigatable?.let { navigate(it) } + } + .submit(AppExecutorUtil.getAppExecutorService()) + true + } catch (t: Throwable) { + logger.warn("Failed to schedule navigation for: $target", t) + false + } + } + + @JvmStatic + fun isValidNavigationLink(description: String?): Boolean { + if (description.isNullOrBlank()) return false + return SUPPORTED_PROTOCOLS.any { description.startsWith(it) } + } + + private fun extractProtocol(description: String): String { + return SUPPORTED_PROTOCOLS.first { description.startsWith(it) } + } + + private fun extractTarget(description: String): String { + val protocol = extractProtocol(description) + val raw = description.removePrefix(protocol) + return decode(raw) + } + + private fun decode(value: String): String = try { + URLDecoder.decode(value, StandardCharsets.UTF_8) + } catch (e: Exception) { + logger.error("Failed to decode URL: $value", e) + value + } + + private fun navigate(target: Navigatable) { + if (target.canNavigate()) { + target.navigate(true) + logger.info("Successfully navigated to: $target") + } else { + logger.info("Target cannot navigate: $target") + } + } +} + +abstract class NavigationResolver { + abstract fun resolve(target: String): Navigatable? + + protected val logger = thisLogger() + protected fun getProject(): Project? = ApplicationUtil.findCurrentProject() +} + +class PsiElementResolver : NavigationResolver() { + override fun resolve(target: String): Navigatable? { + val project = getProject() ?: return null + logger.info("Resolving PSI element: $target") + + findByJavaFQN(project, target)?.let { return it } + + findByMemberSeparation(project, target)?.let { return it } + + return searchInModels(project, target) + } + + private fun findByJavaFQN(project: Project, target: String): Navigatable? { + try { + val memberSeparatorIndex = target.indexOf('#') + val className = if (memberSeparatorIndex > 0) { + target.substring(0, memberSeparatorIndex) + } else { + target + } + + val javaPsiFacade = JavaPsiFacade.getInstance(project) + val projectScope = GlobalSearchScope.projectScope(project) + val allScope = GlobalSearchScope.allScope(project) + + val psiClass = javaPsiFacade.findClass(className, projectScope) + ?: javaPsiFacade.findClass(className, allScope) + + if (psiClass != null) { + val memberName = if (memberSeparatorIndex > 0) { + target.substring(memberSeparatorIndex + 1) + } else { + null + } + + if (memberName != null) { + findMemberInClass(psiClass, memberName)?.let { return it } + } + return psiClass + } + + if (className.contains('$')) { + val innerClassFQN = className.replace('$', '.') + val innerClass = javaPsiFacade.findClass(innerClassFQN, projectScope) + ?: javaPsiFacade.findClass(innerClassFQN, allScope) + if (innerClass != null) { + return innerClass + } + } + + return null + } catch (t: Throwable) { + logger.warn("Failed to resolve Java FQN: $target", t) + return null + } + } + + private fun findByMemberSeparation(project: Project, target: String): Navigatable? { + val memberSeparatorIndex = target.indexOf('#') + if (memberSeparatorIndex <= 0) return null + + val owner = target.substring(0, memberSeparatorIndex) + val member = target.substring(memberSeparatorIndex + 1) + + searchInModels(project, owner)?.let { ownerElement -> + if (ownerElement is PsiClass) { + findMemberInClass(ownerElement, member)?.let { return it } + } + } + + return null + } + + private fun findMemberInClass(psiClass: PsiClass, memberName: String): Navigatable? { + psiClass.findMethodsByName(memberName, false).firstOrNull()?.let { return it } + psiClass.findFieldByName(memberName, false)?.let { return it } + psiClass.findInnerClassByName(memberName, false)?.let { return it } + return null + } + + private fun searchInModels(project: Project, searchTerm: String): Navigatable? { + try { + val classModel = GotoClassModel2(project) + classModel.getElementsByName(searchTerm, true, searchTerm) + .filterIsInstance() + .firstOrNull { it.canNavigate() } + ?.let { return it } + + val symbolModel = GotoSymbolModel2(project, project) + symbolModel.getElementsByName(searchTerm, true, searchTerm) + .filterIsInstance() + .firstOrNull { it.canNavigate() } + ?.let { return it } + } catch (e: Exception) { + logger.warn("Search failed for term: $searchTerm", e) + } + return null + } +} + +class FileResolver : NavigationResolver() { + + override fun resolve(target: String): Navigatable? { + val project = getProject() ?: return null + logger.info("Resolving file: $target") + + val memberSeparatorIndex = target.indexOf('#') + val filePath = if (memberSeparatorIndex > 0) { + target.substring(0, memberSeparatorIndex) + } else { + target + } + + val fileName = filePath.substringAfterLast('/') + val matchingVirtualFile = + FilenameIndex.getVirtualFilesByName(fileName, GlobalSearchScope.projectScope(project)) + .firstOrNull { vf -> + vf.path.endsWith(filePath) || vf.name == fileName + } + + if (matchingVirtualFile != null) { + val psiFile = PsiManager.getInstance(project).findFile(matchingVirtualFile) + if (psiFile != null) { + if (memberSeparatorIndex > 0) { + val memberName = target.substring(memberSeparatorIndex + 1) + val memberElement = + PsiTreeUtil.findChildrenOfType(psiFile, PsiNamedElement::class.java) + .firstOrNull { it.name == memberName } + + return (memberElement as? Navigatable) ?: psiFile + } + return psiFile + } + } + + return try { + val fileModel = GotoFileModel(project) + fileModel.getElementsByName(fileName, true, fileName) + .filterIsInstance() + .firstOrNull { it.canNavigate() } + } catch (t: Throwable) { + logger.warn("File search failed for: $target", t) + null + } + } +} + +object NavigationResolverFactory { + fun create(protocol: String): NavigationResolver { + return when (protocol) { + "psi_element://" -> PsiElementResolver() + "file://" -> FileResolver() + else -> throw IllegalArgumentException("Unsupported protocol: $protocol") + } + } +} \ No newline at end of file diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 95cde31a..d8201b09 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -7,8 +7,6 @@ org.jetbrains.kotlin com.intellij.modules.java com.intellij.modules.python - - @@ -199,15 +197,15 @@ + - @@ -336,7 +334,7 @@ text="ProxyAI" popup="true" icon="ee.carlrobert.codegpt.Icons.DefaultSmall"> - + {0}, \u6211\u662F ProxyAI\uFF01\u4F60\u53EF\u4EE5\u95EE\u6211\u4EFB\u4F55\u95EE\u9898\uFF0C\u4F46\u5927\u591A\u6570\u4EBA\u4F1A\u8BF7\u6C42\u6211\u63D0\u4F9B\u4EE3\u7801\u65B9\u9762\u7684\u5E2E\u52A9\u3002\u4EE5\u4E0B\u662F\u4E00\u4E9B\u4F60\u53EF\u4EE5\u5411\u6211\u54A8\u8BE2\u7684\u95EE\u9898\uFF1A \ No newline at end of file +chat.message.welcome=\u55E8 {0}, \u6211\u662F ProxyAI\uFF01\u4F60\u53EF\u4EE5\u95EE\u6211\u4EFB\u4F55\u95EE\u9898\uFF0C\u4F46\u5927\u591A\u6570\u4EBA\u4F1A\u8BF7\u6C42\u6211\u63D0\u4F9B\u4EE3\u7801\u65B9\u9762\u7684\u5E2E\u52A9\u3002\u4EE5\u4E0B\u662F\u4E00\u4E9B\u4F60\u53EF\u4EE5\u5411\u6211\u54A8\u8BE2\u7684\u95EE\u9898\uFF1A diff --git a/src/main/resources/prompts/persona/default-persona-edit-mode.txt b/src/main/resources/prompts/persona/default-persona-edit-mode.txt index 09a7fef9..8d38f63c 100644 --- a/src/main/resources/prompts/persona/default-persona-edit-mode.txt +++ b/src/main/resources/prompts/persona/default-persona-edit-mode.txt @@ -78,4 +78,4 @@ Formatting Guidelines: 6. Do not provide an implementation plan for pure explanations or general questions. -7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required. \ No newline at end of file +7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required. diff --git a/src/main/resources/prompts/persona/default-persona.txt b/src/main/resources/prompts/persona/default-persona.txt index f0b1f74b..0bb48998 100644 --- a/src/main/resources/prompts/persona/default-persona.txt +++ b/src/main/resources/prompts/persona/default-persona.txt @@ -73,5 +73,3 @@ Formatting Guidelines: 5. Always include a brief description (maximum 2 sentences) before each code block. 6. Do not provide an implementation plan for pure explanations or general questions. - -7. When refactoring an entire file, provide the complete updated file content in a single code block. \ No newline at end of file diff --git a/src/main/resources/prompts/persona/psi-navigation-guidelines.txt b/src/main/resources/prompts/persona/psi-navigation-guidelines.txt new file mode 100644 index 00000000..32abd311 --- /dev/null +++ b/src/main/resources/prompts/persona/psi-navigation-guidelines.txt @@ -0,0 +1,69 @@ +## JetBrains Navigation Links (MANDATORY) + +**Link every concrete symbol** (class, method, field, constant, function) using these two protocols only: + +### Navigation Protocols + +**Java/Kotlin ONLY (.java, .kt files):** +- Classes: `psi_element://fully.qualified.ClassName` (MUST be fully qualified) +- Methods: `psi_element://fully.qualified.ClassName#methodName` +- Fields: `psi_element://fully.qualified.ClassName#fieldName` +- Constants: `psi_element://fully.qualified.ClassName#CONSTANT_NAME` + +**All Other Languages (C/C++, JS, Python, etc.):** +- Functions: `file://src/path/file.ext#functionName` +- Constants/Variables: `file://src/path/file.ext#VARIABLE_NAME` +- Files: `file://src/path/file.ext` + +**No Link Available:** +- Use backticks: `someSymbol` (when no file context or reference is possible) + +### Critical Rules + +1. **psi_element:// ONLY for Java/Kotlin**: Never use for other languages +2. **file:// for everything else**: C/C++, JavaScript, Python, Go, etc. +3. **Visible text = exact symbol name**: `[Repository]`, `[handleSubmit]`, `[API_KEY]` +4. **Use backticks when no context**: If you can't determine file location, use `backticks` +5. **Methods must include owner**: `fully.qualified.ClassName#methodName` or `file.ext#functionName` +6. **MANDATORY**: Always use fully qualified class names - never use short class names alone + +### Examples + +**Java/Kotlin:** +``` +The [System](psi_element://java.lang.System) class has [out](psi_element://java.lang.System#out) field. +Use [UserRepository](psi_element://com.example.repository.UserRepository) with [findById](psi_element://com.example.repository.UserRepository#findById). +The [name](psi_element://com.example.model.User#name) field in [User](psi_element://com.example.model.User) class. +``` + +**C/C++:** +``` +Call [logError](file://src/utils/logger.cpp#logError) function. +The [MAX_SIZE](file://include/constants.h#MAX_SIZE) constant is defined. +Implement [processData](file://src/processor.h#processData) in header. +``` + +**JavaScript:** +``` +The [handleClick](file://src/components/Button.js#handleClick) event handler. +Export [API_BASE_URL](file://src/config.js#API_BASE_URL) from config. +``` + +**Python:** +``` +Use [parse_json](file://utils/parser.py#parse_json) function. +The [DATABASE_URL](file://settings.py#DATABASE_URL) setting. +``` + +**No Context Available:** +``` +Use the `printf` function for formatting. +The `malloc` function allocates memory. +Consider using `async/await` pattern. +``` + +**Files:** +``` +Edit the [main.cpp](file://src/main.cpp) file. +Check [UserService.java](file://src/service/UserService.java). +``` diff --git a/src/test/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestProviderTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestProviderTest.kt index 9a2812f0..c2fabc48 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestProviderTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/completions/CompletionRequestProviderTest.kt @@ -7,6 +7,7 @@ import ee.carlrobert.codegpt.conversations.message.Message import ee.carlrobert.codegpt.settings.prompts.PersonaPromptDetailsState import ee.carlrobert.codegpt.settings.prompts.PromptsSettings import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel +import ee.carlrobert.codegpt.util.file.FileUtil.getResourceContent import org.assertj.core.api.Assertions.assertThat import org.assertj.core.groups.Tuple import org.junit.jupiter.api.Assertions.assertThrows @@ -33,10 +34,12 @@ class CompletionRequestProviderTest : IntegrationTest() { val request = OpenAIRequestFactory().createChatRequest(callParameters) + val guidelines = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + val expectedSystem = "TEST_SYSTEM_PROMPT\n$guidelines" assertThat(request.messages) .extracting("role", "content") .containsExactly( - Tuple.tuple("system", "TEST_SYSTEM_PROMPT\n"), + Tuple.tuple("system", expectedSystem), Tuple.tuple("user", "TEST_PROMPT"), Tuple.tuple("assistant", firstMessage.response), Tuple.tuple("user", "TEST_PROMPT"), @@ -64,10 +67,12 @@ class CompletionRequestProviderTest : IntegrationTest() { val request = OpenAIRequestFactory().createChatRequest(callParameters) + val guidelines = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + val expectedSystem = "TEST_SYSTEM_PROMPT\n$guidelines" assertThat(request.messages) .extracting("role", "content") .containsExactly( - Tuple.tuple("system", "TEST_SYSTEM_PROMPT\n"), + Tuple.tuple("system", expectedSystem), Tuple.tuple("user", "FIRST_TEST_PROMPT"), Tuple.tuple("assistant", firstMessage.response), Tuple.tuple("user", "SECOND_TEST_PROMPT") diff --git a/src/test/kotlin/ee/carlrobert/codegpt/completions/DefaultToolwindowChatCompletionRequestHandlerTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/completions/DefaultToolwindowChatCompletionRequestHandlerTest.kt index 0b1f89be..ebde2d48 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/completions/DefaultToolwindowChatCompletionRequestHandlerTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/completions/DefaultToolwindowChatCompletionRequestHandlerTest.kt @@ -14,6 +14,7 @@ import ee.carlrobert.llm.client.util.JSONUtil.* import org.apache.http.HttpHeaders import org.assertj.core.api.Assertions.assertThat import testsupport.IntegrationTest +import ee.carlrobert.codegpt.util.file.FileUtil.getResourceContent class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { @@ -31,6 +32,8 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { assertThat(request.uri.path).isEqualTo("/v1/chat/completions") assertThat(request.method).isEqualTo("POST") assertThat(request.headers[HttpHeaders.AUTHORIZATION]!![0]).isEqualTo("Bearer TEST_API_KEY") + val guidelines = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + val expectedSystem = "TEST_SYSTEM_PROMPT\n$guidelines" assertThat(request.body) .extracting( "model", @@ -39,7 +42,7 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { .containsExactly( "gpt-4o", listOf( - mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"), + mapOf("role" to "system", "content" to expectedSystem), mapOf("role" to "user", "content" to "TEST_PROMPT") ) ) @@ -75,6 +78,8 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { conversation.addMessage(Message("Ping", "Pong")) expectLlama(StreamHttpExchange { request: RequestEntity -> assertThat(request.uri.path).isEqualTo("/completion") + val guidelines = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + val expectedSystem = "TEST_SYSTEM_PROMPT\n$guidelines" assertThat(request.body) .extracting( "prompt", @@ -83,7 +88,7 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { ) .containsExactly( LLAMA.buildPrompt( - "TEST_SYSTEM_PROMPT", + expectedSystem, "TEST_PROMPT", conversation.messages ), @@ -122,6 +127,8 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { assertThat(request.uri.path).isEqualTo("/v1/chat/completions") assertThat(request.method).isEqualTo("POST") assertThat(request.headers[HttpHeaders.AUTHORIZATION]!![0]).isEqualTo("Bearer TEST_API_KEY") + val guidelines = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + val expectedSystem = "TEST_SYSTEM_PROMPT\n$guidelines" assertThat(request.body) .extracting( "model", @@ -130,7 +137,7 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { .containsExactly( HuggingFaceModel.LLAMA_3_8B_Q6_K.code, listOf( - mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"), + mapOf("role" to "system", "content" to expectedSystem), mapOf("role" to "user", "content" to "TEST_PROMPT") ) ) @@ -154,6 +161,8 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { fun testGoogleChatCompletionCall() { useGoogleService() + service().state + .chatCompletionSettings.clickableLinksEnabled = true val customPersona = PersonaPromptDetailsState().apply { id = 999L name = "Test Persona" @@ -166,13 +175,15 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { assertThat(request.uri.path).isEqualTo("/v1/models/gemini-2.0-flash:streamGenerateContent") assertThat(request.method).isEqualTo("POST") assertThat(request.uri.query).isEqualTo("key=TEST_API_KEY&alt=sse") + val guidelines = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + val expectedSystem = "TEST_SYSTEM_PROMPT\n$guidelines" assertThat(request.body) .extracting("contents", "systemInstruction") .containsExactly( listOf( mapOf("parts" to listOf(mapOf("text" to "TEST_PROMPT")), "role" to "user"), ), - mapOf("parts" to listOf(mapOf("text" to "TEST_SYSTEM_PROMPT"))) + mapOf("parts" to listOf(mapOf("text" to expectedSystem))) ) listOf( jsonMapResponse( @@ -212,6 +223,8 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { assertThat(request.uri.path).isEqualTo("/v1/chat/completions") assertThat(request.method).isEqualTo("POST") assertThat(request.headers[HttpHeaders.AUTHORIZATION]!![0]).isEqualTo("Bearer TEST_API_KEY") + val guidelines = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + val expectedSystem = "TEST_SYSTEM_PROMPT\n$guidelines" assertThat(request.body) .extracting( "model", @@ -220,9 +233,9 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() { .containsExactly( "gpt-5-mini", listOf( - mapOf("role" to "user", "content" to "TEST_SYSTEM_PROMPT\n"), + mapOf("role" to "user", "content" to expectedSystem), mapOf("role" to "user", "content" to "TEST_PROMPT") - ) + ) ) listOf( jsonMapResponse( diff --git a/src/test/kotlin/ee/carlrobert/codegpt/completions/OpenAIRequestFactoryIntegrationTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/completions/OpenAIRequestFactoryIntegrationTest.kt index d9fcad73..41a4e36b 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/completions/OpenAIRequestFactoryIntegrationTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/completions/OpenAIRequestFactoryIntegrationTest.kt @@ -34,12 +34,13 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() { val request = OpenAIRequestFactory().createChatRequest(callParameters) val systemMessages = request.messages - .filterIsInstance() + .filterIsInstance() .filter { it.role == "system" } .map { it.content } assertThat(systemMessages).isNotEmpty() val systemContent = systemMessages.first() - assertThat(systemContent).isEqualTo( + val guidelinesEdit = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + assertThat(systemContent).startsWith( "You are an AI programming assistant integrated into a JetBrains IDE plugin. Your role is to answer coding questions, suggest new code, and perform refactoring or editing tasks. You have access to the following project information:\n" + "\n" + "Before we proceed with the main instructions, here is the content of relevant files in the project:\n" + @@ -122,6 +123,7 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() { "\n" + "7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required.\n" ) + assertThat(systemContent).endsWith(guidelinesEdit) } fun testDefaultPersonaIsFilteredInAskMode() { @@ -137,12 +139,13 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() { val request = OpenAIRequestFactory().createChatRequest(callParameters) val systemMessages = request.messages - .filterIsInstance() + .filterIsInstance() .filter { it.role == "system" } .map { it.content } assertThat(systemMessages).isNotEmpty() val systemContent = systemMessages.first() - assertThat(systemContent).isEqualTo( + val guidelinesAskDefault = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + assertThat(systemContent).startsWith( "You are an AI programming assistant integrated into a JetBrains IDE plugin. Your role is to answer coding questions, suggest new code, and perform refactoring or editing tasks. You have access to the following project information:\n" + "\n" + "Before we proceed with the main instructions, here is the content of relevant files in the project:\n" + @@ -217,10 +220,9 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() { "\n" + "5. Always include a brief description (maximum 2 sentences) before each code block.\n" + "\n" + - "6. Do not provide an implementation plan for pure explanations or general questions.\n" + - "\n" + - "7. When refactoring an entire file, provide the complete updated file content in a single code block.\n" + "6. Do not provide an implementation plan for pure explanations or general questions.\n\n" ) + assertThat(systemContent).endsWith(guidelinesAskDefault) } fun testChatRequestUsesFilteredPersonaPromptInAskMode() { @@ -256,12 +258,13 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() { val request = OpenAIRequestFactory().createChatRequest(callParameters) val systemMessages = request.messages - .filterIsInstance() + .filterIsInstance() .filter { it.role == "system" } .map { it.content } assertThat(systemMessages).isNotEmpty() val systemContent = systemMessages.first() - assertThat(systemContent).isEqualTo( + val guidelinesAskCustom = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + assertThat(systemContent).startsWith( "You are a helpful assistant.\n" + "For refactoring or editing an existing file, provide the complete modified code.\n" + "When providing code modifications:\n" + @@ -279,6 +282,7 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() { " }\n" + " ```\n" ) + assertThat(systemContent).endsWith(guidelinesAskCustom) } fun testChatRequestKeepsOriginalPersonaPromptInEditMode() { @@ -304,7 +308,8 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() { .map { it.content } assertThat(systemMessages).isNotEmpty() val systemContent = systemMessages.first() - assertThat(systemContent).isEqualTo( + val guidelinesEditCustom = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + assertThat(systemContent).startsWith( "You are an AI programming assistant integrated into a JetBrains IDE plugin. Your role is to answer coding questions, suggest new code, and perform refactoring or editing tasks. You have access to the following project information:\n" + "\n" + "Before we proceed with the main instructions, here is the content of relevant files in the project:\n" + @@ -387,6 +392,7 @@ class OpenAIRequestFactoryIntegrationTest : IntegrationTest() { "\n" + "7. When refactoring an entire file, output multiple code blocks as needed, keeping changes concise unless a more extensive update is required.\n" ) + assertThat(systemContent).endsWith(guidelinesEditCustom) } fun testInlineEditSingleRequestNoHistory() { 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 654ccb9e..a24ac3fa 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanelTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanelTest.kt @@ -16,6 +16,7 @@ import ee.carlrobert.llm.client.http.RequestEntity import ee.carlrobert.llm.client.http.exchange.StreamHttpExchange import ee.carlrobert.llm.client.util.JSONUtil.* import org.apache.http.HttpHeaders +import ee.carlrobert.codegpt.util.file.FileUtil.getResourceContent import org.assertj.core.api.Assertions.assertThat import testsupport.IntegrationTest import java.io.IOException @@ -36,6 +37,8 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { assertThat(request.uri.path).isEqualTo("/v1/chat/completions") assertThat(request.method).isEqualTo("POST") assertThat(request.headers[HttpHeaders.AUTHORIZATION]!![0]).isEqualTo("Bearer TEST_API_KEY") + val guidelines = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + val expectedSystem = "TEST_SYSTEM_PROMPT\n$guidelines" assertThat(request.body) .extracting( "model", @@ -44,7 +47,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { .containsExactly( "gpt-4o", listOf( - mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"), + mapOf("role" to "system", "content" to expectedSystem), mapOf("role" to "user", "content" to "Hello!") ) ) @@ -108,6 +111,8 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { assertThat(request.uri.path).isEqualTo("/v1/chat/completions") assertThat(request.method).isEqualTo("POST") assertThat(request.headers[HttpHeaders.AUTHORIZATION]!![0]).isEqualTo("Bearer TEST_API_KEY") + val guidelines = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + val expectedSystem = "TEST_SYSTEM_PROMPT\n$guidelines" assertThat(request.body) .extracting( "model", @@ -116,7 +121,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { .containsExactly( "gpt-4o", listOf( - mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"), + mapOf("role" to "system", "content" to expectedSystem), mapOf( "role" to "user", "content" to """ @@ -203,6 +208,8 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { assertThat(request.method).isEqualTo("POST") assertThat(request.headers[HttpHeaders.AUTHORIZATION]!![0]).isEqualTo("Bearer TEST_API_KEY") try { + val guidelines = getResourceContent("/prompts/persona/psi-navigation-guidelines.txt") + val expectedSystem = "TEST_SYSTEM_PROMPT\n$guidelines" val testImageUrl = ("data:image/png;base64," + Base64.getEncoder() .encodeToString(Files.readAllBytes(Path.of(testImagePath)))) @@ -211,7 +218,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { .containsExactly( "gpt-4-vision-preview", listOf( - mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"), + mapOf("role" to "system", "content" to expectedSystem), mapOf( "role" to "user", "content" to listOf( mapOf( @@ -408,7 +415,9 @@ class ChatToolWindowTabPanelTest : IntegrationTest() { ) .containsExactly( LLAMA.buildPrompt( - "TEST_SYSTEM_PROMPT", + (getResourceContent("/prompts/persona/psi-navigation-guidelines.txt").let { g -> + "TEST_SYSTEM_PROMPT\n$g" + }), "TEST_PROMPT", conversation.messages ),