diff --git a/src/main/java/ee/carlrobert/codegpt/conversations/Conversation.java b/src/main/java/ee/carlrobert/codegpt/conversations/Conversation.java index ff56d1470..99038fee4 100644 --- a/src/main/java/ee/carlrobert/codegpt/conversations/Conversation.java +++ b/src/main/java/ee/carlrobert/codegpt/conversations/Conversation.java @@ -17,9 +17,11 @@ public class Conversation { private LocalDateTime updatedOn; private boolean discardTokenLimit; private String projectPath; + private List attachedFiles; public Conversation() { this.messages = new ArrayList<>(); + this.attachedFiles = new ArrayList<>(); this.id = UUID.randomUUID(); this.createdOn = LocalDateTime.now(); this.updatedOn = LocalDateTime.now(); @@ -91,4 +93,12 @@ public String getProjectPath() { public void setProjectPath(String projectPath) { this.projectPath = projectPath; } + + public List getAttachedFiles() { + return attachedFiles; + } + + public void setAttachedFiles(List attachedFiles) { + this.attachedFiles = attachedFiles == null ? new ArrayList<>() : new ArrayList<>(attachedFiles); + } } diff --git a/src/main/java/ee/carlrobert/codegpt/conversations/ConversationAttachedFile.java b/src/main/java/ee/carlrobert/codegpt/conversations/ConversationAttachedFile.java new file mode 100644 index 000000000..231723d9d --- /dev/null +++ b/src/main/java/ee/carlrobert/codegpt/conversations/ConversationAttachedFile.java @@ -0,0 +1,51 @@ +package ee.carlrobert.codegpt.conversations; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Objects; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class ConversationAttachedFile { + + private String path; + private boolean selected; + + public ConversationAttachedFile() { + } + + public ConversationAttachedFile(String path, boolean selected) { + this.path = path; + this.selected = selected; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public boolean isSelected() { + return selected; + } + + public void setSelected(boolean selected) { + this.selected = selected; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ConversationAttachedFile other)) { + return false; + } + return selected == other.selected && Objects.equals(path, other.path); + } + + @Override + public int hashCode() { + return Objects.hash(path, selected); + } +} diff --git a/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationComponent.java b/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationComponent.java index f11cfe16b..c78e3dddb 100644 --- a/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationComponent.java +++ b/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationComponent.java @@ -30,6 +30,7 @@ public class ConfigurationComponent { private final CodeCompletionConfigurationForm codeCompletionForm; private final ChatCompletionConfigurationForm chatCompletionForm; private final ScreenshotConfigurationForm screenshotForm; + private final DefaultChatModeConfigurationForm defaultChatModeForm; public ConfigurationComponent( Disposable parentDisposable, @@ -76,6 +77,7 @@ public void changedUpdate(DocumentEvent e) { codeCompletionForm = new CodeCompletionConfigurationForm(); chatCompletionForm = new ChatCompletionConfigurationForm(); screenshotForm = new ScreenshotConfigurationForm(); + defaultChatModeForm = new DefaultChatModeConfigurationForm(); screenshotForm.loadState(configuration.getScreenshotWatchPaths()); mainPanel = FormBuilder.createFormBuilder() @@ -96,6 +98,9 @@ public void changedUpdate(DocumentEvent e) { .addComponent(new TitledSeparator( CodeGPTBundle.get("configurationConfigurable.section.chatCompletion.title"))) .addComponent(chatCompletionForm.createPanel()) + .addComponent(new TitledSeparator( + CodeGPTBundle.get("configurationConfigurable.section.defaultChatMode.title"))) + .addComponent(defaultChatModeForm.createPanel()) .addComponentFillVertically(new JPanel(), 0) .getPanel(); } @@ -112,6 +117,9 @@ public ConfigurationSettingsState getCurrentFormState() { state.setCheckForNewScreenshots(checkForNewScreenshotsCheckBox.isSelected()); state.setMethodNameGenerationEnabled(methodNameGenerationCheckBox.isSelected()); state.setAutoFormattingEnabled(autoFormattingCheckBox.isSelected()); + state.setChatEditModeByDefault(defaultChatModeForm.isEditModeByDefaultEnabled()); + state.setRememberAttachedFilesToChat( + defaultChatModeForm.isRememberAttachedFilesToChatEnabled()); state.setCodeCompletionSettings(codeCompletionForm.getFormState()); state.setChatCompletionSettings(chatCompletionForm.getFormState()); @@ -130,6 +138,7 @@ public void resetForm() { checkForNewScreenshotsCheckBox.setSelected(configuration.getCheckForNewScreenshots()); methodNameGenerationCheckBox.setSelected(configuration.getMethodNameGenerationEnabled()); autoFormattingCheckBox.setSelected(configuration.getAutoFormattingEnabled()); + defaultChatModeForm.resetForm(configuration); codeCompletionForm.resetForm(configuration.getCodeCompletionSettings()); chatCompletionForm.resetForm(configuration.getChatCompletionSettings()); screenshotForm.loadState(configuration.getScreenshotWatchPaths()); 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 4c9c5f7ae..aa2f8e192 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowTabPanel.java @@ -9,6 +9,7 @@ import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.SelectionModel; import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.ui.AnimatedIcon; import com.intellij.ui.JBColor; @@ -29,6 +30,7 @@ import ee.carlrobert.codegpt.completions.ToolApprovalMode; import ee.carlrobert.codegpt.completions.ToolwindowChatCompletionRequestHandler; import ee.carlrobert.codegpt.conversations.Conversation; +import ee.carlrobert.codegpt.conversations.ConversationAttachedFile; import ee.carlrobert.codegpt.conversations.ConversationService; import ee.carlrobert.codegpt.conversations.message.Message; import ee.carlrobert.codegpt.mcp.ConnectionStatus; @@ -38,6 +40,8 @@ import ee.carlrobert.codegpt.psistructure.models.ClassStructure; import ee.carlrobert.codegpt.settings.ProxyAISettingsService; import ee.carlrobert.codegpt.settings.service.FeatureType; +import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings; +import ee.carlrobert.codegpt.settings.configuration.ConfigurationStateListener; import ee.carlrobert.codegpt.telemetry.TelemetryAction; import ee.carlrobert.codegpt.toolwindow.chat.editor.actions.CopyAction; import ee.carlrobert.codegpt.toolwindow.chat.structure.data.PsiStructureRepository; @@ -60,6 +64,7 @@ import ee.carlrobert.codegpt.ui.textarea.header.tag.PersonaTagDetails; import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails; import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager; +import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManagerListener; import ee.carlrobert.codegpt.util.EditorUtil; import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers; import java.awt.BorderLayout; @@ -67,6 +72,7 @@ import java.awt.GridBagLayout; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; @@ -142,6 +148,7 @@ public ChatToolWindowTabPanel( this::handleSubmit, this::handleCancel, true); + initializeRememberedAttachedFiles(); userInputPanel.requestFocus(); mcpApprovalContainer = new JPanel(); @@ -387,6 +394,100 @@ private ToolApprovalMode getToolApprovalMode() { return ToolApprovalMode.REQUIRE_APPROVAL; } + private void initializeRememberedAttachedFiles() { + restoreRememberedAttachedFiles(); + + tagManager.addListener(new TagManagerListener() { + @Override + public void onTagAdded(TagDetails tag) { + syncRememberedAttachedFiles(); + } + + @Override + public void onTagRemoved(TagDetails tag) { + syncRememberedAttachedFiles(); + } + + @Override + public void onTagSelectionChanged(TagDetails tag, SelectionModel selectionModel) { + } + + @Override + public void onTagUpdated(TagDetails tag) { + syncRememberedAttachedFiles(); + } + }); + + ApplicationManager.getApplication().getMessageBus().connect(this).subscribe( + ConfigurationStateListener.Companion.getTOPIC(), + newState -> syncRememberedAttachedFiles()); + } + + private void restoreRememberedAttachedFiles() { + if (!isRememberAttachedFilesEnabled()) { + return; + } + + var attachedFiles = conversation.getAttachedFiles(); + if (attachedFiles == null || attachedFiles.isEmpty()) { + return; + } + + attachedFiles.forEach(attachedFile -> { + var virtualFile = LocalFileSystem.getInstance() + .refreshAndFindFileByPath(attachedFile.getPath()); + if (virtualFile == null || !virtualFile.isValid()) { + return; + } + + TagDetails tagDetails = virtualFile.isDirectory() + ? new FolderTagDetails(virtualFile) + : new FileTagDetails(virtualFile); + tagDetails.setSelected(attachedFile.isSelected()); + userInputPanel.addTag(tagDetails); + }); + } + + private void syncRememberedAttachedFiles() { + if (draftSubmitHandler != null) { + return; + } + + var attachedFiles = isRememberAttachedFilesEnabled() + ? collectRememberedAttachedFiles() + : List.of(); + + if (Objects.equals(conversation.getAttachedFiles(), attachedFiles)) { + return; + } + + conversation.setAttachedFiles(attachedFiles); + conversationService.saveConversation(conversation); + } + + private List collectRememberedAttachedFiles() { + return tagManager.getTags().stream() + .filter(tag -> tag instanceof FileTagDetails || tag instanceof FolderTagDetails) + .sorted(Comparator.comparingLong(TagDetails::getCreatedOn)) + .map(this::toConversationAttachedFile) + .filter(Objects::nonNull) + .toList(); + } + + private ConversationAttachedFile toConversationAttachedFile(TagDetails tag) { + if (tag instanceof FileTagDetails fileTagDetails) { + return new ConversationAttachedFile(fileTagDetails.getVirtualFile().getPath(), tag.getSelected()); + } + if (tag instanceof FolderTagDetails folderTagDetails) { + return new ConversationAttachedFile(folderTagDetails.getFolder().getPath(), tag.getSelected()); + } + return null; + } + + private boolean isRememberAttachedFilesEnabled() { + return ConfigurationSettings.getState().getRememberAttachedFilesToChat(); + } + public void sendMessage(Message message, ConversationType conversationType) { sendMessage(message, conversationType, new HashSet<>()); } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/actions/BaseCommitWorkflowAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/actions/BaseCommitWorkflowAction.kt index beb6eb76a..28dd4d619 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/actions/BaseCommitWorkflowAction.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/actions/BaseCommitWorkflowAction.kt @@ -11,6 +11,8 @@ import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.components.service import com.intellij.openapi.diff.impl.patch.IdeaTextPatchBuilder import com.intellij.openapi.diff.impl.patch.UnifiedDiffWriter +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages @@ -57,39 +59,59 @@ abstract class BaseCommitWorkflowAction : DumbAwareAction() { override fun actionPerformed(event: AnActionEvent) { val project: Project = event.project ?: return - - val gitDiff: String = getDiff(event, project) - val tokenCount: Int = service().countTokens(gitDiff) - if (tokenCount > MAX_TOKEN_COUNT_WARNING - && OverlayUtil.showTokenSoftLimitWarningDialog(tokenCount) != Messages.OK - ) { - return - } val commitWorkflowUi = event.getData(VcsDataKeys.COMMIT_WORKFLOW_UI) ?: return - performAction(project, commitWorkflowUi, gitDiff) + val includedChanges = commitWorkflowUi.getIncludedChanges() + + object : Task.Backgroundable(project, "Preparing Commit Diff", true) { + private var gitDiff: String? = null + private var tokenCount: Int = 0 + + override fun run(indicator: ProgressIndicator) { + indicator.text = "Generating diff for selected changes" + gitDiff = getDiff(project, includedChanges) + tokenCount = service().countTokens(gitDiff.orEmpty()) + } + + override fun onSuccess() { + val diff = gitDiff ?: return + if (tokenCount > MAX_TOKEN_COUNT_WARNING + && OverlayUtil.showTokenSoftLimitWarningDialog(tokenCount) != Messages.OK + ) { + return + } + + performAction(project, commitWorkflowUi, diff) + } + + override fun onThrowable(error: Throwable) { + Notifications.Bus.notify( + Notification( + "proxyai.notification.group", + "ProxyAI", + error.message ?: "Unable to create git diff", + NotificationType.ERROR + ), + project + ) + } + }.queue() } override fun getActionUpdateThread(): ActionUpdateThread { return ActionUpdateThread.BGT } - private fun getDiff(event: AnActionEvent, project: Project): String { - val commitWorkflowUi = event.getData(VcsDataKeys.COMMIT_WORKFLOW_UI) - ?: throw IllegalStateException("Could not retrieve commit workflow ui.") - + private fun getDiff(project: Project, includedChanges: List): String { return generateDiff( project, - commitWorkflowUi.getIncludedChanges(), + includedChanges, getRepository(project).root.toNioPath() ) } private fun getRepository(project: Project): GitRepository { - return runCatching { - ApplicationManager.getApplication() - .executeOnPooledThread { getProjectRepository(project) } - .get() - }.getOrNull() ?: throw IllegalStateException("No repository found for the project.") + return getProjectRepository(project) + ?: throw IllegalStateException("No repository found for the project.") } private fun generateDiff( @@ -129,17 +151,30 @@ class CommitMessageEventListener( private val messageBuilder = StringBuilder() private val thinkingOutputParser = ThinkingOutputParser() + override fun onOpen() { + withCommitMessageUi { + it.startLoading() + } + } + override fun onMessage(message: String) { val processedChunk = thinkingOutputParser.processChunk(message) - if (processedChunk.isNotEmpty() && thinkingOutputParser.isFinished) { - messageBuilder.append(message) + if (processedChunk.isNotEmpty()) { + messageBuilder.append(processedChunk) updateCommitMessage(messageBuilder.toString()) } } override fun onComplete(messageBuilder: StringBuilder) { - if (messageBuilder.isEmpty()) { - updateCommitMessage(messageBuilder.toString()) + if (this.messageBuilder.isEmpty() && messageBuilder.isNotEmpty()) { + val processedMessage = ThinkingOutputParser().processChunk(messageBuilder.toString()) + if (processedMessage.isNotEmpty()) { + this.messageBuilder.append(processedMessage) + } + } + + if (this.messageBuilder.isNotEmpty()) { + updateCommitMessage(this.messageBuilder.toString()) } stopLoading() } @@ -161,14 +196,23 @@ class CommitMessageEventListener( } private fun stopLoading() { - CompletionProgressNotifier.update(project, false) + withCommitMessageUi { + it.stopLoading() + CompletionProgressNotifier.update(project, false) + } } private fun updateCommitMessage(message: String?) { - ApplicationManager.getApplication().invokeLater { + withCommitMessageUi { WriteCommandAction.runWriteCommandAction(project) { - commitWorkflowUi.commitMessageUi.setText(message) + it.setText(message) } } } + + private fun withCommitMessageUi(action: (com.intellij.vcs.commit.CommitMessageUi) -> Unit) { + ApplicationManager.getApplication().invokeLater { + action(commitWorkflowUi.commitMessageUi) + } + } } 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 083f6b614..96bc2a676 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.kt @@ -33,6 +33,8 @@ class ConfigurationSettingsState : BaseState() { var methodNameGenerationEnabled by property(true) var captureCompileErrors by property(true) var autoFormattingEnabled by property(true) + var chatEditModeByDefault by property(false) + var rememberAttachedFilesToChat by property(false) var tableData by map() var chatCompletionSettings by property(ChatCompletionSettingsState()) var codeCompletionSettings by property(CodeCompletionSettingsState()) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/DefaultChatModeConfigurationForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/DefaultChatModeConfigurationForm.kt new file mode 100644 index 000000000..6757b05dd --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/configuration/DefaultChatModeConfigurationForm.kt @@ -0,0 +1,44 @@ +package ee.carlrobert.codegpt.settings.configuration + +import com.intellij.openapi.components.service +import com.intellij.openapi.ui.DialogPanel +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.CodeGPTBundle + +class DefaultChatModeConfigurationForm { + + private val editModeByDefaultCheckBox = JBCheckBox( + CodeGPTBundle.get("configurationConfigurable.section.defaultChatMode.editModeByDefault.title"), + service().state.chatEditModeByDefault + ) + private val rememberAttachedFilesToChatCheckBox = JBCheckBox( + CodeGPTBundle.get("configurationConfigurable.section.defaultChatMode.rememberAttachedFilesToChat.title"), + service().state.rememberAttachedFilesToChat + ) + + fun createPanel(): DialogPanel { + return panel { + row { + cell(editModeByDefaultCheckBox) + } + row { + cell(rememberAttachedFilesToChatCheckBox) + } + }.withBorder(JBUI.Borders.emptyLeft(16)) + } + + fun resetForm(prevState: ConfigurationSettingsState) { + editModeByDefaultCheckBox.isSelected = prevState.chatEditModeByDefault + rememberAttachedFilesToChatCheckBox.isSelected = prevState.rememberAttachedFilesToChat + } + + fun isEditModeByDefaultEnabled(): Boolean { + return editModeByDefaultCheckBox.isSelected + } + + fun isRememberAttachedFilesToChatEnabled(): Boolean { + return rememberAttachedFilesToChatCheckBox.isSelected + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffSyncManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffSyncManager.kt index 35c7c6de0..1ab469429 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffSyncManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/diff/DiffSyncManager.kt @@ -53,7 +53,7 @@ object DiffSyncManager { val replacedText = newText.replace(search.trim(), replace.trim()) runInEdt { - if (replacedText.length != newText.length) { + if (replacedText != newText && rightSideDoc.isWritable) { runUndoTransparentWriteAction { rightSideDoc.setText( StringUtil.convertLineSeparators( diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/DiffEditorState.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/DiffEditorState.kt index 8e8804935..57af8861c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/DiffEditorState.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/chat/editor/state/DiffEditorState.kt @@ -15,6 +15,7 @@ import com.intellij.openapi.actionSystem.Presentation import com.intellij.openapi.actionSystem.ex.CustomComponentAction import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.components.service +import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project @@ -127,7 +128,9 @@ abstract class DiffEditorState( val diffContentFactory = DiffContentFactory.getInstance() val leftSide = diffContentFactory.create(project, virtualFile) - val rightSideDoc = viewer.getDocument(Side.RIGHT).apply { setReadOnly(true) } + val rightSideDoc = EditorFactory.getInstance() + .createDocument(viewer.getDocument(Side.RIGHT).text) + .apply { setReadOnly(true) } val rightSide = diffContentFactory.create(project, rightSideDoc, virtualFile) var diffRequest = SimpleDiffRequest( "Code Diff", diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt index 2b4ca3d54..6dbd74c36 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextField.kt @@ -291,6 +291,7 @@ class PromptTextField( editorEx.settings.isUseSoftWraps = true editorEx.backgroundColor = service().globalScheme.defaultBackground setupDocumentListener(editorEx) + adjustHeight(editorEx) return editorEx } @@ -660,6 +661,7 @@ class PromptTextField( private fun adjustHeight(editor: EditorEx) { val contentHeight = editor.contentComponent.preferredSize.height + PromptTextFieldConstants.HEIGHT_PADDING + val minimumHeight = calculateMinimumHeight(editor) val toolWindow = project.service().getToolWindow("ProxyAI") val maxHeight = if (toolWindow == null || !toolWindow.component.isAncestorOf(this)) { @@ -667,7 +669,7 @@ class PromptTextField( } else { JBUI.scale(getToolWindowHeight(toolWindow) / 2) } - val newHeight = minOf(contentHeight, maxHeight) + val newHeight = minOf(maxOf(contentHeight, minimumHeight), maxHeight) runInEdt { preferredSize = Dimension(width, newHeight) @@ -676,6 +678,13 @@ class PromptTextField( } } + private fun calculateMinimumHeight(editor: EditorEx): Int { + val verticalPadding = JBUI.scale( + PromptTextFieldConstants.HEIGHT_PADDING + PromptTextFieldConstants.BORDER_PADDING * 2 + ) + return editor.lineHeight * PromptTextFieldConstants.MIN_VISIBLE_LINES + verticalPadding + } + private fun getToolWindowHeight(toolWindow: ToolWindow): Int { val h = toolWindow.component.visibleRect.height return if (h > 0) h else PromptTextFieldConstants.DEFAULT_TOOL_WINDOW_HEIGHT diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt index c79eb40b5..6419c2588 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/PromptTextFieldConstants.kt @@ -4,6 +4,7 @@ object PromptTextFieldConstants { const val SEARCH_DELAY_MS = 200L const val MIN_DYNAMIC_SEARCH_LENGTH = 2 const val MAX_SEARCH_RESULTS = 100 + const val MIN_VISIBLE_LINES = 2 const val DEFAULT_TOOL_WINDOW_HEIGHT = 400 const val BORDER_PADDING = 4 const val BORDER_SIDE_PADDING = 8 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 f3fa10622..7a53267d2 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt @@ -30,6 +30,7 @@ import ee.carlrobert.codegpt.Icons import ee.carlrobert.codegpt.agent.PromptEnhancer import ee.carlrobert.codegpt.settings.ProxyAISettingsService import ee.carlrobert.codegpt.settings.configuration.ChatMode +import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.models.ModelSettings import ee.carlrobert.codegpt.settings.service.ServiceType @@ -118,7 +119,8 @@ class UserInputPanel @JvmOverloads constructor( withRemovableSelectedEditorTag, onApply, getMarkdownContent, - featureType + featureType, + ::schedulePreferredSizeUpdate ) private var footerPanelRef: JPanel? = null @@ -246,6 +248,15 @@ class UserInputPanel @JvmOverloads constructor( init { setupDisposables(parentDisposable) setupLayout(featureType) + if (featureType == FeatureType.CHAT) { + setChatMode( + if (ConfigurationSettings.getState().chatEditModeByDefault) { + ChatMode.EDIT + } else { + ChatMode.ASK + } + ) + } bindTokenCounterToInputTokens() addSelectedEditorContent() if (featureType == FeatureType.INLINE_EDIT) { @@ -272,9 +283,11 @@ class UserInputPanel @JvmOverloads constructor( addToBottom(createFooterPanel(featureType).also { footerPanelRef = it }) promptTextField.addPropertyChangeListener("preferredSize") { _ -> - runInEdt { updatePreferredSizeFromChildren() } + schedulePreferredSizeUpdate() } + schedulePreferredSizeUpdate() + if (featureType == FeatureType.INLINE_EDIT) { invokeLater { updatePreferredSizeFromChildren() } minimumSize = Dimension(JBUI.scale(600), JBUI.scale(80)) @@ -289,10 +302,18 @@ class UserInputPanel @JvmOverloads constructor( private fun setupTextChangeListener() { userInputHeaderPanel.addPropertyChangeListener("preferredSize") { _ -> - runInEdt { updatePreferredSizeFromChildren() } + schedulePreferredSizeUpdate() } footerPanelRef?.addPropertyChangeListener("preferredSize") { _ -> - runInEdt { updatePreferredSizeFromChildren() } + schedulePreferredSizeUpdate() + } + } + + private fun schedulePreferredSizeUpdate() { + invokeLater { + if (!Disposer.isDisposed(parentDisposable)) { + updatePreferredSizeFromChildren() + } } } @@ -651,6 +672,7 @@ class UserInputPanel @JvmOverloads constructor( inlineEditControls.forEach { it.isVisible = visible } revalidate() repaint() + schedulePreferredSizeUpdate() } fun setThinkingVisible(visible: Boolean, text: String = CodeGPTBundle.get("shared.thinking")) { @@ -660,6 +682,7 @@ class UserInputPanel @JvmOverloads constructor( thinkingPanel.isVisible = visible revalidate() repaint() + schedulePreferredSizeUpdate() } private fun isImageActionSupported(): Boolean { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt index 77f9050a2..28d261c7d 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/UserInputHeaderPanel.kt @@ -55,7 +55,8 @@ class UserInputHeaderPanel( private val withRemovableSelectedEditorTag: Boolean, private val onApply: (() -> Unit)? = null, private val getMarkdownContent: (() -> String)? = null, - private val featureType: FeatureType? = null + private val featureType: FeatureType? = null, + private val onLayoutChanged: () -> Unit = {} ) : JPanel(WrapLayout(FlowLayout.LEFT, 4, 4)), TagManagerListener, McpTagUpdateListener { companion object { @@ -162,6 +163,7 @@ class UserInputHeaderPanel( invalidate() revalidate() repaint() + onLayoutChanged() parent?.let { it.invalidate() @@ -276,6 +278,7 @@ class UserInputHeaderPanel( revalidate() repaint() + onLayoutChanged() } private fun createTagPanel(tagDetails: TagDetails) = @@ -307,6 +310,7 @@ class UserInputHeaderPanel( else -> Unit } + tagManager.notifyTagUpdated(tagDetails) } } @@ -332,12 +336,14 @@ class UserInputHeaderPanel( applyChip?.isVisible = visible revalidate() repaint() + onLayoutChanged() } fun setApplyEnabled(enabled: Boolean) { applyChip?.isEnabled = enabled revalidate() repaint() + onLayoutChanged() } private fun addInitialTags() { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt index b2c640ea8..f034bf313 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManager.kt @@ -41,8 +41,14 @@ class TagManager { remove(tagDetails) } - if (tags.count { !it.selected } == 2) { - remove(tags.sortedBy { it.createdOn }.first { !it.selected }) + if (tagDetails is EditorTagDetails) { + val unselectedEditorTags = tags + .filterIsInstance() + .filterNot { it.selected } + .sortedBy { it.createdOn } + if (unselectedEditorTags.size == 2) { + remove(unselectedEditorTags.first()) + } } tags.add(tagDetails) @@ -77,6 +83,13 @@ class TagManager { } } + fun notifyTagUpdated(tagDetails: TagDetails) { + val containsTag = synchronized(this) { tags.contains(tagDetails) } + if (containsTag) { + listeners.forEach { it.onTagUpdated(tagDetails) } + } + } + fun updateTag(oldTag: TagDetails, newTag: TagDetails) { runInEdt { performTagUpdate(oldTag, newTag) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManagerListener.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManagerListener.kt index c95756298..a1d3d956c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManagerListener.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/header/tag/TagManagerListener.kt @@ -6,4 +6,5 @@ interface TagManagerListener { fun onTagAdded(tag: TagDetails) fun onTagRemoved(tag: TagDetails) fun onTagSelectionChanged(tag: TagDetails, selectionModel: SelectionModel) -} \ No newline at end of file + fun onTagUpdated(tag: TagDetails) {} +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/FileActionItem.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/FileActionItem.kt index 69016245d..a267e0be8 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/FileActionItem.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/lookup/action/files/FileActionItem.kt @@ -8,7 +8,7 @@ import com.intellij.openapi.project.guessProjectDir import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VirtualFile import ee.carlrobert.codegpt.ui.textarea.UserInputPanel -import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorTagDetails +import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails import ee.carlrobert.codegpt.ui.textarea.lookup.action.AbstractLookupActionItem import ee.carlrobert.codegpt.ui.textarea.lookup.action.InsertsDisplayNameLookupItem @@ -31,6 +31,6 @@ class FileActionItem(private val project: Project, val file: VirtualFile) : } override fun execute(project: Project, userInputPanel: UserInputPanel) { - userInputPanel.addTag(EditorTagDetails(file)) + userInputPanel.addTag(FileTagDetails(file)) } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/ThinkingOutputParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/ThinkingOutputParser.kt index 1f6fccd93..ea3b32a81 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/util/ThinkingOutputParser.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/ThinkingOutputParser.kt @@ -4,7 +4,7 @@ class ThinkingOutputParser { companion object { private const val OPEN_TAG = "" - private const val CLOSE_TAG = "\n\n" + private const val CLOSE_TAG = "" } var thoughtProcess: String = "" @@ -43,11 +43,16 @@ class ThinkingOutputParser { isFinished = true isThinking = false - val responseStart = indexClose + CLOSE_TAG.length + var responseStart = indexClose + CLOSE_TAG.length + while (responseStart < current.length && + (current[responseStart] == '\n' || current[responseStart] == '\r') + ) { + responseStart++ + } return current.substring(responseStart) } else { thoughtProcess = current.substring(startContent) return "" } } -} \ No newline at end of file +} diff --git a/src/main/resources/icons/qwen.png b/src/main/resources/icons/qwen.png new file mode 100644 index 000000000..f4a9fb31c Binary files /dev/null and b/src/main/resources/icons/qwen.png differ diff --git a/src/main/resources/messages/codegpt.properties b/src/main/resources/messages/codegpt.properties index 4730605a7..c8b93e812 100644 --- a/src/main/resources/messages/codegpt.properties +++ b/src/main/resources/messages/codegpt.properties @@ -171,6 +171,9 @@ configurationConfigurable.section.chatCompletion.clickableLinks.title=Show click configurationConfigurable.section.chatCompletion.clickableLinks.description=If enabled, code references in answers become clickable so you can jump to them in your IDE. configurationConfigurable.section.chatCompletion.sendMessageShortcut.title=Send Message Shortcut configurationConfigurable.section.chatCompletion.sendMessageShortcut.description=If none are selected, 'Enter' by itself sends the message. If any are selected, the chosen shortcut plus 'Enter' sends the message and 'Enter' by itself inserts a newline. +configurationConfigurable.section.defaultChatMode.title=Default Chat Mode +configurationConfigurable.section.defaultChatMode.editModeByDefault.title=Enable edit mode by default +configurationConfigurable.section.defaultChatMode.rememberAttachedFilesToChat.title=Remember attached files to chat settingsConfigurable.service.llama.predefinedModel.comment=Download and use vetted models from HuggingFace. settingsConfigurable.service.llama.customModel.comment=Use your own GGUF model file from a local path on your computer. settingsConfigurable.service.custom.openai.testConnection.label=Test Connection diff --git a/src/test/kotlin/ee/carlrobert/codegpt/conversations/ConversationsStateTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/conversations/ConversationsStateTest.kt index e6f68408f..85dda9bd3 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/conversations/ConversationsStateTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/conversations/ConversationsStateTest.kt @@ -2,9 +2,6 @@ package ee.carlrobert.codegpt.conversations import com.intellij.testFramework.fixtures.BasePlatformTestCase import ee.carlrobert.codegpt.conversations.message.Message -import ee.carlrobert.codegpt.settings.GeneralSettings -import ee.carlrobert.codegpt.settings.service.ServiceType -import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings import org.assertj.core.api.Assertions.assertThat class ConversationsStateTest : BasePlatformTestCase() { @@ -32,6 +29,24 @@ class ConversationsStateTest : BasePlatformTestCase() { .containsExactly("TEST_PROMPT", "TEST_RESPONSE") } + fun testSaveConversationPreservesAttachedFiles() { + val service = ConversationService.getInstance() + val conversation = service.createConversation() + conversation.attachedFiles = listOf( + ConversationAttachedFile("/tmp/First.kt", true), + ConversationAttachedFile("/tmp/Folder", false), + ) + + service.addConversation(conversation) + service.saveConversation(conversation) + + assertThat(ConversationsState.getCurrentConversation()!!.attachedFiles) + .containsExactly( + ConversationAttachedFile("/tmp/First.kt", true), + ConversationAttachedFile("/tmp/Folder", false), + ) + } + fun testGetPreviousConversation() { val service = ConversationService.getInstance() val firstConversation = service.startConversation(project) 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 c62891b00..dbe7637a7 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ThinkingOutputParserTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/toolwindow/chat/ThinkingOutputParserTest.kt @@ -52,4 +52,16 @@ class ThinkingOutputParserTest { assertThat(finalOutput).isEqualTo("The final answer.") assertThat(parser.thoughtProcess).isEqualTo("some internal processing with even more details...") } -} \ No newline at end of file + + @Test + fun `support close think tag without trailing blank line`() { + val parser = ThinkingOutputParser() + + assertThat(parser.processChunk("internal reasoning")).isEmpty() + + val finalOutput = parser.processChunk("feat: add sync fix") + + assertThat(finalOutput).isEqualTo("feat: add sync fix") + assertThat(parser.thoughtProcess).isEqualTo("internal reasoning") + } +} diff --git a/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/TagManagerIntegrationTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/TagManagerIntegrationTest.kt new file mode 100644 index 000000000..9d66b7ffd --- /dev/null +++ b/src/test/kotlin/ee/carlrobert/codegpt/ui/textarea/TagManagerIntegrationTest.kt @@ -0,0 +1,37 @@ +package ee.carlrobert.codegpt.ui.textarea + +import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorTagDetails +import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails +import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager +import org.assertj.core.api.Assertions.assertThat +import testsupport.IntegrationTest + +class TagManagerIntegrationTest : IntegrationTest() { + + fun `test adding third unselected editor tag removes oldest unselected editor tag only`() { + val attachedFileOne = myFixture.configureByText("AttachedOne.kt", "class AttachedOne").virtualFile + val attachedFileTwo = myFixture.configureByText("AttachedTwo.kt", "class AttachedTwo").virtualFile + val editorFileOne = myFixture.configureByText("EditorOne.kt", "class EditorOne").virtualFile + val editorFileTwo = myFixture.configureByText("EditorTwo.kt", "class EditorTwo").virtualFile + val editorFileThree = myFixture.configureByText("EditorThree.kt", "class EditorThree").virtualFile + + val attachedTagOne = FileTagDetails(attachedFileOne).apply { selected = false } + val attachedTagTwo = FileTagDetails(attachedFileTwo).apply { selected = false } + val editorTagOne = EditorTagDetails(editorFileOne).apply { selected = false } + val editorTagTwo = EditorTagDetails(editorFileTwo).apply { selected = false } + val editorTagThree = EditorTagDetails(editorFileThree).apply { selected = false } + + val tagManager = TagManager().apply { + addTag(attachedTagOne) + addTag(attachedTagTwo) + addTag(editorTagOne) + addTag(editorTagTwo) + addTag(editorTagThree) + } + + val tags = tagManager.getTags() + + assertThat(tags).contains(attachedTagOne, attachedTagTwo, editorTagTwo, editorTagThree) + assertThat(tags).doesNotContain(editorTagOne) + } +}