diff --git a/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestProvider.java b/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestProvider.java index a1be84ed..8e97e693 100644 --- a/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestProvider.java +++ b/src/main/java/ee/carlrobert/codegpt/completions/CompletionRequestProvider.java @@ -21,6 +21,7 @@ import ee.carlrobert.codegpt.conversations.message.Message; import ee.carlrobert.codegpt.credentials.CredentialsStore; import ee.carlrobert.codegpt.settings.IncludedFilesSettings; import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings; +import ee.carlrobert.codegpt.settings.persona.PersonaSettings; import ee.carlrobert.codegpt.settings.service.anthropic.AnthropicSettings; import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState; import ee.carlrobert.codegpt.settings.service.custom.CustomServiceSettings; @@ -71,9 +72,6 @@ import org.jetbrains.annotations.Nullable; public class CompletionRequestProvider { - public static final String COMPLETION_SYSTEM_PROMPT = - getResourceContent("/prompts/default-completion.txt"); - public static final String GENERATE_COMMIT_MESSAGE_SYSTEM_PROMPT = getResourceContent("/prompts/generate-commit-message.txt"); @@ -197,7 +195,7 @@ public class CompletionRequestProvider { } var systemPrompt = conversationType == FIX_COMPILE_ERRORS - ? FIX_COMPILE_ERRORS_SYSTEM_PROMPT : ConfigurationSettings.getSystemPrompt(); + ? FIX_COMPILE_ERRORS_SYSTEM_PROMPT : PersonaSettings.getSystemPrompt(); var prompt = promptTemplate.buildPrompt( systemPrompt, @@ -302,7 +300,7 @@ public class CompletionRequestProvider { request.setModel(settings.getModel()); request.setMaxTokens(configuration.getMaxTokens()); request.setStream(true); - request.setSystem(ConfigurationSettings.getSystemPrompt()); + request.setSystem(PersonaSettings.getSystemPrompt()); List messages = conversation.getMessages().stream() .filter(prevMessage -> prevMessage.getResponse() != null && !prevMessage.getResponse().isEmpty()) @@ -345,8 +343,8 @@ public class CompletionRequestProvider { var message = callParameters.getMessage(); var messages = new ArrayList(); if (callParameters.getConversationType() == ConversationType.DEFAULT) { - String systemPrompt = ConfigurationSettings.getCurrentState().getSystemPrompt(); - messages.add(new OllamaChatCompletionMessage("system", systemPrompt, null)); + messages.add( + new OllamaChatCompletionMessage("system", PersonaSettings.getSystemPrompt(), null)); } if (callParameters.getConversationType() == ConversationType.FIX_COMPILE_ERRORS) { messages.add( @@ -397,8 +395,8 @@ public class CompletionRequestProvider { var message = callParameters.getMessage(); var messages = new ArrayList(); if (callParameters.getConversationType() == ConversationType.DEFAULT) { - String systemPrompt = ConfigurationSettings.getCurrentState().getSystemPrompt(); - messages.add(new OpenAIChatCompletionStandardMessage("system", systemPrompt)); + messages.add( + new OpenAIChatCompletionStandardMessage("system", PersonaSettings.getSystemPrompt())); } if (callParameters.getConversationType() == ConversationType.FIX_COMPILE_ERRORS) { messages.add( @@ -474,8 +472,7 @@ public class CompletionRequestProvider { // Gemini API does not support direct 'system' prompts: // see https://www.reddit.com/r/Bard/comments/1b90i8o/does_gemini_have_a_system_prompt_option_while/ if (callParameters.getConversationType() == ConversationType.DEFAULT) { - String systemPrompt = ConfigurationSettings.getCurrentState().getSystemPrompt(); - messages.add(new GoogleCompletionContent("user", List.of(systemPrompt))); + messages.add(new GoogleCompletionContent("user", List.of(PersonaSettings.getSystemPrompt()))); messages.add(new GoogleCompletionContent("model", List.of("Understood."))); } if (callParameters.getConversationType() == ConversationType.FIX_COMPILE_ERRORS) { 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 7de25840..eaf67309 100644 --- a/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationComponent.java +++ b/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationComponent.java @@ -1,7 +1,6 @@ package ee.carlrobert.codegpt.settings.configuration; import static ee.carlrobert.codegpt.actions.editor.EditorActionsUtil.DEFAULT_ACTIONS_ARRAY; -import static ee.carlrobert.codegpt.completions.CompletionRequestProvider.COMPLETION_SYSTEM_PROMPT; import com.intellij.icons.AllIcons; import com.intellij.icons.AllIcons.Nodes; @@ -49,7 +48,6 @@ public class ConfigurationComponent { private final JBCheckBox autoFormattingCheckBox; private final JBCheckBox autocompletionPostProcessingCheckBox; private final JBCheckBox autocompletionContextAwareCheckBox; - private final JTextArea systemPromptTextArea; private final JTextArea commitMessagePromptTextArea; private final IntegerField maxTokensField; private final JBTextField temperatureField; @@ -94,17 +92,6 @@ public class ConfigurationComponent { maxTokensField.setColumns(12); maxTokensField.setValue(configuration.getMaxTokens()); - systemPromptTextArea = new JTextArea(3, 60); - if (configuration.getSystemPrompt().isBlank()) { - // for backward compatibility - systemPromptTextArea.setText(COMPLETION_SYSTEM_PROMPT); - } else { - systemPromptTextArea.setText(configuration.getSystemPrompt()); - } - systemPromptTextArea.setLineWrap(true); - systemPromptTextArea.setWrapStyleWord(true); - systemPromptTextArea.setBorder(JBUI.Borders.empty(8, 4)); - commitMessagePromptTextArea = new JTextArea(configuration.getCommitMessagePrompt(), 3, 60); commitMessagePromptTextArea.setLineWrap(true); commitMessagePromptTextArea.setWrapStyleWord(true); @@ -164,7 +151,6 @@ public class ConfigurationComponent { state.setTableData(getTableData()); state.setMaxTokens(maxTokensField.getValue()); state.setTemperature(Double.parseDouble(temperatureField.getText())); - state.setSystemPrompt(systemPromptTextArea.getText()); state.setCommitMessagePrompt(commitMessagePromptTextArea.getText()); state.setCheckForPluginUpdates(checkForPluginUpdatesCheckBox.isSelected()); state.setCheckForNewScreenshots(checkForNewScreenshotsCheckBox.isSelected()); @@ -181,7 +167,6 @@ public class ConfigurationComponent { setTableData(configuration.getTableData()); maxTokensField.setValue(configuration.getMaxTokens()); temperatureField.setText(String.valueOf(configuration.getTemperature())); - systemPromptTextArea.setText(configuration.getSystemPrompt()); commitMessagePromptTextArea.setText(configuration.getCommitMessagePrompt()); checkForPluginUpdatesCheckBox.setSelected(configuration.isCheckForPluginUpdates()); checkForNewScreenshotsCheckBox.setSelected(configuration.isCheckForNewScreenshots()); @@ -242,15 +227,6 @@ public class ConfigurationComponent { private JPanel createAssistantConfigurationForm() { var formBuilder = FormBuilder.createFormBuilder(); - addAssistantFormLabeledComponent( - formBuilder, - "configurationConfigurable.section.assistant.systemPromptField.label", - "configurationConfigurable.section.assistant.systemPromptField.comment", - JBUI.Panels - .simplePanel(systemPromptTextArea) - .withBorder(JBUI.Borders.customLine( - JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground()))); - formBuilder.addVerticalGap(8); addAssistantFormLabeledComponent( formBuilder, "configurationConfigurable.section.assistant.temperatureField.label", diff --git a/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.java b/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.java index 3d2d7f1c..abcce780 100644 --- a/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.java +++ b/src/main/java/ee/carlrobert/codegpt/settings/configuration/ConfigurationSettings.java @@ -31,8 +31,4 @@ public class ConfigurationSettings implements PersistentStateComponent tableData = EditorActionsUtil.DEFAULT_ACTIONS; - public String getSystemPrompt() { - return systemPrompt; - } - public String getCommitMessagePrompt() { return commitMessagePrompt; } - public void setSystemPrompt(String systemPrompt) { - this.systemPrompt = systemPrompt; - } - public void setCommitMessagePrompt(String commitMessagePrompt) { this.commitMessagePrompt = commitMessagePrompt; } @@ -155,14 +145,13 @@ public class ConfigurationState { && autoFormattingEnabled == that.autoFormattingEnabled && autocompletionPostProcessingEnabled == that.autocompletionPostProcessingEnabled && autocompletionContextAwareEnabled == that.autocompletionContextAwareEnabled - && Objects.equals(systemPrompt, that.systemPrompt) && Objects.equals(commitMessagePrompt, that.commitMessagePrompt) && Objects.equals(tableData, that.tableData); } @Override public int hashCode() { - return Objects.hash(systemPrompt, commitMessagePrompt, maxTokens, temperature, + return Objects.hash(commitMessagePrompt, maxTokens, temperature, checkForPluginUpdates, checkForNewScreenshots, createNewChatOnEachAction, ignoreGitCommitTokenLimit, methodNameGenerationEnabled, captureCompileErrors, autoFormattingEnabled, autocompletionPostProcessingEnabled, diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowPanel.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowPanel.java index 2ca19ffe..d07a2e47 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowPanel.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ChatToolWindowPanel.java @@ -7,8 +7,13 @@ import com.intellij.ide.BrowserUtil; import com.intellij.openapi.Disposable; import com.intellij.openapi.actionSystem.ActionManager; import com.intellij.openapi.actionSystem.ActionToolbar; +import com.intellij.openapi.actionSystem.AnActionEvent; import com.intellij.openapi.actionSystem.DefaultCompactActionGroup; +import com.intellij.openapi.actionSystem.Presentation; +import com.intellij.openapi.actionSystem.ex.CustomComponentAction; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.options.ShowSettingsUtil; +import com.intellij.openapi.project.DumbAwareAction; import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.SimpleToolWindowPanel; import com.intellij.openapi.util.Disposer; @@ -23,6 +28,8 @@ import ee.carlrobert.codegpt.actions.toolwindow.OpenInEditorAction; import ee.carlrobert.codegpt.conversations.ConversationService; import ee.carlrobert.codegpt.conversations.ConversationsState; import ee.carlrobert.codegpt.settings.GeneralSettings; +import ee.carlrobert.codegpt.settings.persona.PersonaSettings; +import ee.carlrobert.codegpt.settings.persona.PersonasConfigurable; import ee.carlrobert.codegpt.settings.service.ProviderChangeNotifier; import ee.carlrobert.codegpt.settings.service.ServiceType; import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTUserDetailsNotifier; @@ -35,6 +42,7 @@ import java.nio.file.Paths; import java.util.List; import java.util.stream.Collectors; import javax.swing.BoxLayout; +import javax.swing.JComponent; import javax.swing.JPanel; import org.jetbrains.annotations.NotNull; @@ -165,6 +173,8 @@ public class ChatToolWindowPanel extends SimpleToolWindowPanel { new ClearChatWindowAction(() -> tabbedPane.resetCurrentlyActiveTabPanel(project))); actionGroup.addSeparator(); actionGroup.add(new OpenInEditorAction()); + actionGroup.addSeparator(); + actionGroup.add(new SelectedPersonaActionLink(project)); var toolbar = ActionManager.getInstance() .createActionToolbar("NAVIGATION_BAR_TOOLBAR", actionGroup, true); @@ -186,4 +196,47 @@ public class ChatToolWindowPanel extends SimpleToolWindowPanel { .syncPublisher(IncludeFilesInContextNotifier.FILES_INCLUDED_IN_CONTEXT_TOPIC) .filesIncluded(emptyList()); } + + private static class SelectedPersonaActionLink extends DumbAwareAction implements + CustomComponentAction { + + private final Project project; + + SelectedPersonaActionLink(Project project) { + this.project = project; + } + + @Override + @NotNull + public JComponent createCustomComponent( + @NotNull Presentation presentation, + @NotNull String place) { + var link = new ActionLink(getSelectedPersonaName(), (e) -> { + ShowSettingsUtil.getInstance() + .showSettingsDialog(project, PersonasConfigurable.class); + }); + link.setExternalLinkIcon(); + link.setFont(JBUI.Fonts.smallFont()); + link.setBorder(JBUI.Borders.empty(0, 4)); + return link; + } + + @Override + public void updateCustomComponent( + @NotNull JComponent component, + @NotNull Presentation presentation) { + ((ActionLink) component).setText(getSelectedPersonaName()); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + } + + private String getSelectedPersonaName() { + return ApplicationManager.getApplication().getService(PersonaSettings.class) + .getState() + .getSelectedPersona() + .getName(); + } + } } diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/TotalTokensDetails.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/TotalTokensDetails.java index f38fbbdd..837ac792 100644 --- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/TotalTokensDetails.java +++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/textarea/TotalTokensDetails.java @@ -1,7 +1,7 @@ package ee.carlrobert.codegpt.toolwindow.chat.ui.textarea; import ee.carlrobert.codegpt.EncodingManager; -import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings; +import ee.carlrobert.codegpt.settings.persona.PersonaSettings; public class TotalTokensDetails { @@ -12,7 +12,7 @@ public class TotalTokensDetails { private int referencedFilesTokens; public TotalTokensDetails(EncodingManager encodingManager) { - systemPromptTokens = encodingManager.countTokens(ConfigurationSettings.getSystemPrompt()); + systemPromptTokens = encodingManager.countTokens(PersonaSettings.getSystemPrompt()); } public int getSystemPromptTokens() { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonaSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonaSettings.kt new file mode 100644 index 00000000..6f917573 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonaSettings.kt @@ -0,0 +1,44 @@ +package ee.carlrobert.codegpt.settings.persona + +import com.intellij.openapi.components.* + +const val DEFAULT_PROMPT = + "You are an AI programming assistant.\nFollow the user's requirements carefully & to the letter.\nYour responses should be informative and logical.\nYou should always adhere to technical information.\nIf the user asks for code or technical questions, you must provide code suggestions and adhere to technical information.\nIf the question is related to a developer, you must respond with content related to a developer.\nFirst think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.\nThen output the code in a single code block.\nMinimize any other prose.\nKeep your answers short and impersonal.\nUse Markdown formatting in your answers.\nMake sure to include the programming language name at the start of the Markdown code blocks.\nAvoid wrapping the whole response in triple backticks.\nThe user works in an IDE built by JetBrains which has a concept for editors with open files, integrated unit test support, and output pane that shows the output of running the code as well as an integrated terminal.\nYou can only give one reply for each conversation turn." + +@Service +@State( + name = "CodeGPT_PersonaSettings", + storages = [Storage("CodeGPT_PersonaSettings.xml")] +) +class PersonaSettings : + SimplePersistentStateComponent(PersonaSettingsState()) { + + companion object { + @JvmStatic + fun getSystemPrompt(): String { + return service().state.selectedPersona.instructions ?: "" + } + } +} + +class PersonaSettingsState : BaseState() { + var selectedPersona by property(PersonaDetailsState()) + var userCreatedPersonas by list() +} + +class PersonaDetailsState : BaseState() { + var id by property(1L) + var name by string("CodeGPT Default") + var instructions by string(DEFAULT_PROMPT) +} + +@JvmRecord +data class PersonaDetails(val id: Long, val name: String, val instructions: String) + +fun PersonaDetails.toPersonaDetailsState(): PersonaDetailsState { + val newState = PersonaDetailsState() + newState.id = id + newState.name = name + newState.instructions = instructions + return newState +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonasConfigurable.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonasConfigurable.kt new file mode 100644 index 00000000..c4746175 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonasConfigurable.kt @@ -0,0 +1,28 @@ +package ee.carlrobert.codegpt.settings.persona + +import com.intellij.openapi.options.Configurable +import javax.swing.JComponent + +class PersonasConfigurable : Configurable { + + private lateinit var component: PersonasSettingsForm + + override fun getDisplayName(): String { + return "CodeGPT: Personas" + } + + override fun createComponent(): JComponent { + component = PersonasSettingsForm() + return component.createPanel() + } + + override fun isModified(): Boolean = component.isModified() + + override fun apply() { + component.applyChanges() + } + + override fun reset() { + component.resetChanges() + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonasSettingsForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonasSettingsForm.kt new file mode 100644 index 00000000..409793ca --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/persona/PersonasSettingsForm.kt @@ -0,0 +1,288 @@ +package ee.carlrobert.codegpt.settings.persona + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.components.service +import com.intellij.openapi.ui.DialogPanel +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.ToolbarDecorator +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.LabelPosition +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBUI +import ee.carlrobert.codegpt.util.ResourceUtil +import java.awt.Dimension +import javax.swing.UIManager +import javax.swing.event.DocumentEvent +import javax.swing.table.DefaultTableModel +import javax.swing.text.JTextComponent + +class NonEditableTableModel(columnNames: Array, rowCount: Int) : + DefaultTableModel(columnNames, rowCount) { + override fun isCellEditable(row: Int, column: Int) = false +} + +class PersonasSettingsForm { + + private val tableModel = + NonEditableTableModel(arrayOf("Id", "Name", "Instructions", "FromResource"), 0) + private val table = JBTable(tableModel).apply { + setupTableColumns() + selectionModel.addListSelectionListener { populateEditArea() } + } + private val nameField = JBTextField().apply { + addTextChangeListener { newText -> + updateTableModelIfRowSelected(1, newText) + } + } + private val instructionsTextArea = JBTextArea().apply { + lineWrap = true + wrapStyleWord = true + font = UIManager.getFont("TextField.font") + border = JBUI.Borders.empty(3, 6) + addTextChangeListener { newText -> + updateTableModelIfRowSelected(2, newText) + } + } + private val addedItems = mutableListOf() + private val removedItemIds = mutableListOf() + + init { + setupForm() + } + + fun createPanel(): DialogPanel { + return panel { + row { + val toolbarDecorator = ToolbarDecorator.createDecorator(table) + .setAddAction { handleAddItem() } + .setRemoveAction { handleRemoveItem() } + .addExtraAction(object : + AnAction("Duplicate", "Duplicate persona", AllIcons.Actions.Copy) { + override fun actionPerformed(e: AnActionEvent) { + handleDuplicateItem() + } + }) + .setRemoveActionUpdater { + val selectedRow = table.selectedRow + selectedRow != -1 && !(tableModel.getValueAt(selectedRow, 3) as Boolean) + } + .disableUpDownActions() + + cell(toolbarDecorator.createPanel()) + .align(Align.FILL) + .resizableColumn() + .applyToComponent { + preferredSize = Dimension(650, 250) + } + } + row { + cell(nameField) + .label("Name:", LabelPosition.TOP) + .align(Align.FILL) + } + row { + cell(JBScrollPane(instructionsTextArea).apply { + preferredSize = Dimension(650, 225) + }) + .label("Instructions:", LabelPosition.TOP) + .align(Align.FILL) + .resizableColumn() + } + } + } + + fun applyChanges() { + val persona = getSelectedPersona() + service().state.run { + if (persona != null) { + selectedPersona = persona.toPersonaDetailsState() + } + + userCreatedPersonas.removeIf { removedItemIds.contains(it.id) } + userCreatedPersonas.addAll(addedItems.map { it.toPersonaDetailsState() }) + } + clear() + } + + fun isModified(): Boolean { + service().state.let { + val (id, name, description) = getSelectedPersona() ?: return false + return it.selectedPersona.id != id + || it.selectedPersona.name != name + || it.selectedPersona.instructions != description + || removedItemIds.size > 0 + || addedItems.size > 0 + } + } + + fun resetChanges() { + clear() + tableModel.rowCount = 0 + setupForm() + } + + private fun populateEditArea() { + val selectedRow = table.selectedRow + if (selectedRow != -1) { + val userCreatedResource = !(tableModel.getValueAt(selectedRow, 3) as Boolean) + + nameField.text = tableModel.getValueAt(selectedRow, 1) as String + nameField.isEnabled = userCreatedResource + instructionsTextArea.text = tableModel.getValueAt(selectedRow, 2) as String + instructionsTextArea.isEnabled = userCreatedResource + } else { + nameField.text = "" + instructionsTextArea.text = "" + } + } + + private fun handleAddItem() { + addPersonaToTable(createNewPersona("New Persona", "New Prompt")) + } + + private fun handleDuplicateItem() { + val selectedRow = table.selectedRow + val originalName = tableModel.getValueAt(selectedRow, 1) as String + val originalPrompt = tableModel.getValueAt(selectedRow, 2) as String + addPersonaToTable(createNewPersona("$originalName Copy", originalPrompt)) + } + + private fun createNewPersona(name: String, prompt: String): PersonaDetails { + val newId = findMaxId() + 1 + return PersonaDetails(newId, name, prompt) + } + + private fun addPersonaToTable(persona: PersonaDetails) { + addedItems.add(persona) + tableModel.addRow(arrayOf(persona.id, persona.name, persona.instructions, false)) + selectLastRowAndUpdateUI() + } + + private fun findMaxId(): Long { + return (0 until table.rowCount).maxOf { + tableModel.getValueAt(it, 0) as Long + } + } + + private fun selectLastRowAndUpdateUI() { + val lastRow = table.rowCount - 1 + table.setRowSelectionInterval(lastRow, lastRow) + populateEditArea() + scrollToLastRow() + nameField.requestFocus() + } + + private fun handleRemoveItem() { + val selectedRow = table.selectedRow + if (selectedRow != -1 && !(tableModel.getValueAt(selectedRow, 3) as Boolean)) { + val id = tableModel.getValueAt(selectedRow, 0) as Long + if (addedItems.none { it.id == id }) { + removedItemIds.add(id) + } + addedItems.filter { it.id != id } + tableModel.removeRow(selectedRow) + populateEditArea() + + val newSelectedRow = if (selectedRow > 0) selectedRow - 1 else 0 + if (table.rowCount > 0) { + table.setRowSelectionInterval(newSelectedRow, newSelectedRow) + } + } + } + + private fun setupForm() { + service().state.run { + userCreatedPersonas.forEachIndexed { index, persona -> + tableModel.addPersonaRow( + PersonaDetails( + persona.id, + persona.name!!, + persona.instructions!! + ), + selectedPersona.id, + index + ) + } + ResourceUtil.getFilteredPersonaSuggestions().forEachIndexed { index, persona -> + tableModel.addPersonaRow(persona, selectedPersona.id, index, true) + } + } + } + + private fun DefaultTableModel.addPersonaRow( + persona: PersonaDetails, + selectedPersonaId: Long, + rowIndex: Int, + fromResource: Boolean = false + ) { + val (id, name, instructions) = persona + addRow(arrayOf(id, name, instructions, fromResource)) + if (selectedPersonaId == id) { + table.setRowSelectionInterval(rowIndex, rowIndex) + } + } + + private fun scrollToLastRow() { + table.scrollRectToVisible(table.getCellRect(table.rowCount - 1, 0, true)) + } + + private fun JBTable.setupTableColumns() { + columnModel.apply { + getColumn(0).apply { + minWidth = 0 + maxWidth = 0 + preferredWidth = 0 + resizable = false + } + getColumn(1).preferredWidth = 60 + getColumn(2).preferredWidth = 240 + getColumn(3).apply { + minWidth = 0 + maxWidth = 0 + preferredWidth = 0 + resizable = false + } + } + } + + private fun JBTable.getSelectedPersona(): PersonaDetails? { + if (selectedRow == -1) { + return null + } + + return PersonaDetails( + tableModel.getValueAt(selectedRow, 0) as Long, + tableModel.getValueAt(selectedRow, 1) as String, + tableModel.getValueAt(selectedRow, 2) as String + ) + } + + private fun JTextComponent.addTextChangeListener(listener: (String) -> Unit) { + document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + listener(e.document.getText(0, e.document.length)) + } + }) + } + + private fun updateTableModelIfRowSelected(column: Int, newValue: Any) { + if (table.selectedRow != -1) { + tableModel.setValueAt(newValue, table.selectedRow, column) + } + } + + private fun getSelectedPersona(): PersonaDetails? { + return table.getSelectedPersona() + } + + private fun clear() { + addedItems.clear() + removedItemIds.clear() + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/CustomTextPane.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/CustomTextPane.kt index cb81cabd..89c04929 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/CustomTextPane.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/CustomTextPane.kt @@ -4,6 +4,7 @@ import com.intellij.openapi.components.service import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorFontType import com.intellij.openapi.editor.ex.util.EditorUtil +import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.registry.Registry import com.intellij.ui.JBColor import com.intellij.util.ui.JBFont @@ -43,8 +44,12 @@ class CustomTextPane(private val onSubmit: (String) -> Unit) : JTextPane() { }) } - fun highlightText(text: String) { - val lastIndex = this.text.lastIndexOf('@') + fun appendHighlightedText( + text: String, + searchChar: Char = '@', + withWhitespace: Boolean = true + ): TextRange? { + val lastIndex = this.text.lastIndexOf(searchChar) if (lastIndex != -1) { val styleContext = StyleContext.getDefaultStyleContext() val fileNameStyle = styleContext.addStyle("smart-highlighter", null) @@ -71,12 +76,16 @@ class CustomTextPane(private val onSubmit: (String) -> Unit) : JTextPane() { fileNameStyle, true ) - document.insertString( - document.length, - " ", - styleContext.getStyle(StyleContext.DEFAULT_STYLE) - ) + if (withWhitespace) { + document.insertString( + document.length, + " ", + styleContext.getStyle(StyleContext.DEFAULT_STYLE) + ) + } + return TextRange(lastIndex, lastIndex + text.length) } + return null } override fun paintComponent(g: Graphics) { @@ -90,7 +99,6 @@ class CustomTextPane(private val onSubmit: (String) -> Unit) : JTextPane() { } else { UIManager.getFont("TextField.font") } - // Draw placeholder g2d.drawString( CodeGPTBundle.get("toolwindow.chat.textArea.emptyText"), insets.left, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/CustomTextPaneKeyAdapter.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/CustomTextPaneKeyAdapter.kt new file mode 100644 index 00000000..e4fdcbc6 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/CustomTextPaneKeyAdapter.kt @@ -0,0 +1,109 @@ +package ee.carlrobert.codegpt.ui.textarea + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.jetbrains.rd.util.AtomicReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.swing.text.StyleContext +import javax.swing.text.StyledDocument + +class CustomTextPaneKeyAdapter( + private val project: Project, + private val textPane: CustomTextPane +) : KeyAdapter() { + + private val suggestionsPopupManager = SuggestionsPopupManager(project, textPane) + private val popupOpenedAtRange: AtomicReference = AtomicReference(null) + + override fun keyReleased(e: KeyEvent) { + if (textPane.text.isEmpty()) { + // TODO: Remove only the files that were added via shortcuts + project.service().removeFilesFromSession() + suggestionsPopupManager.hidePopup() + return + } + if (e.keyCode == KeyEvent.VK_BACK_SPACE) { + if (popupOpenedAtRange.get() == TextRange( + textPane.caretPosition, + textPane.caretPosition + 1 + ) + ) { + suggestionsPopupManager.hidePopup() + return + } + + if (textPane.text.isNotEmpty() && textPane.text.last() == '@') { + suggestionsPopupManager.reset() + } + } + + when (e.keyCode) { + KeyEvent.VK_UP, KeyEvent.VK_DOWN -> { + suggestionsPopupManager.requestFocus() + suggestionsPopupManager.selectNext() + e.consume() + } + + else -> { + if (suggestionsPopupManager.isPopupVisible()) { + updateSuggestions() + } + } + } + } + + override fun keyTyped(e: KeyEvent) { + val popupVisible = suggestionsPopupManager.isPopupVisible() + if (e.keyChar == '@' && !popupVisible) { + suggestionsPopupManager.showPopup(textPane) + popupOpenedAtRange.getAndSet( + TextRange( + textPane.caretPosition, + textPane.caretPosition + 1 + ) + ) + return + } else if (e.keyChar == '\t') { + suggestionsPopupManager.requestFocus() + suggestionsPopupManager.selectNext() + return + } else if (popupVisible) { + updateSuggestions() + } + + val doc = textPane.document as StyledDocument + if (textPane.caretPosition >= 0) { + doc.setCharacterAttributes( + textPane.caretPosition, + 1, + StyleContext.getDefaultStyleContext().getStyle(StyleContext.DEFAULT_STYLE), + true + ) + } + } + + private fun updateSuggestions() { + CoroutineScope(Dispatchers.Default).launch { + runInEdt { + val lastAtIndex = textPane.text.lastIndexOf('@') + if (lastAtIndex != -1) { + val lastAtSearchIndex = textPane.text.lastIndexOf(':') + if (lastAtSearchIndex != -1) { + val searchText = textPane.text.substring(lastAtSearchIndex + 1) + if (searchText.isNotEmpty()) { + suggestionsPopupManager.updateSuggestions(searchText) + } + } + } else { + suggestionsPopupManager.hidePopup() + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionList.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionList.kt index 4d541080..de614e7b 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionList.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionList.kt @@ -1,33 +1,38 @@ package ee.carlrobert.codegpt.ui.textarea -import com.intellij.icons.AllIcons -import com.intellij.openapi.fileTypes.FileTypeManager -import com.intellij.ui.JBColor import com.intellij.ui.components.JBList -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBUI -import java.awt.Component -import java.awt.Dimension import java.awt.KeyboardFocusManager import java.awt.event.KeyAdapter import java.awt.event.KeyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import javax.swing.* +import javax.swing.DefaultListModel +import javax.swing.ListSelectionModel class SuggestionList( listModel: DefaultListModel, + private val textPane: CustomTextPane, private val onSelected: (SuggestionItem) -> Unit ) : JBList(listModel) { init { + setupUI() + setupKeyboardFocusManager() + setupKeyListener() + setupMouseListener() + setupMouseMotionListener() + } + + private fun setupUI() { border = JBUI.Borders.empty() - preferredSize = Dimension(480, (30 * 6)) selectionMode = ListSelectionModel.SINGLE_SELECTION - cellRenderer = SuggestionsListCellRenderer() + cellRenderer = SuggestionListCellRenderer(textPane) + } + + private fun setupKeyboardFocusManager() { KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher { e -> - if (e.keyCode == KeyEvent.VK_TAB && e.id == KeyEvent.KEY_PRESSED && isFocusOwner) { + if (isTabKeyPressed(e) && isFocusOwner) { selectNext() e.consume() true @@ -35,22 +40,33 @@ class SuggestionList( false } } + } + + private fun isTabKeyPressed(e: KeyEvent) = + e.keyCode == KeyEvent.VK_TAB && e.id == KeyEvent.KEY_PRESSED + + private fun setupKeyListener() { addKeyListener(object : KeyAdapter() { override fun keyReleased(e: KeyEvent) { - when (e.keyCode) { - KeyEvent.VK_ENTER -> { - onSelected(listModel.get(selectedIndex)) - e.consume() - } + if (e.keyCode == KeyEvent.VK_ENTER) { + handleEnterKey() + e.consume() } } }) + } + + private fun handleEnterKey() { + val item = model.getElementAt(selectedIndex) + if (item is SuggestionItem.ActionItem && item.action.enabled || item !is SuggestionItem.ActionItem) { + onSelected(item) + } + } + + private fun setupMouseListener() { addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { - val index = locationToIndex(e.point) - if (index >= 0) { - onSelected(listModel.getElementAt(index)) - } + handleMouseClick(e) } override fun mouseExited(e: MouseEvent) { @@ -58,6 +74,20 @@ class SuggestionList( repaint() } }) + } + + private fun handleMouseClick(e: MouseEvent) { + val index = locationToIndex(e.point) + if (index >= 0) { + val item = model.getElementAt(index) + if (item is SuggestionItem.ActionItem && item.action.enabled || item !is SuggestionItem.ActionItem) { + onSelected(item) + } + e.consume() + } + } + + private fun setupMouseMotionListener() { addMouseMotionListener(object : MouseAdapter() { override fun mouseMoved(e: MouseEvent) { val index = locationToIndex(e.point) @@ -74,105 +104,4 @@ class SuggestionList( selectedIndex = newIndex ensureIndexIsVisible(newIndex) } -} - -private class SuggestionsListCellRenderer : DefaultListCellRenderer() { - - override fun getListCellRendererComponent( - list: JList<*>?, - value: Any?, - index: Int, - isSelected: Boolean, - cellHasFocus: Boolean - ): Component = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus).apply { - setOpaque(false) - }.let { component -> - if (component is JLabel && value is SuggestionItem) { - renderSuggestionItem(component, value, list, index, isSelected, cellHasFocus) - } else { - component - } - } - - private fun renderSuggestionItem( - component: JLabel, - value: SuggestionItem, - list: JList<*>?, - index: Int, - isSelected: Boolean, - cellHasFocus: Boolean - ): JPanel = when (value) { - is SuggestionItem.FileItem -> renderFileItem(component, value) - is SuggestionItem.ActionItem -> renderActionItem(component, value) - }.apply { - setupPanelProperties(list, index, isSelected, cellHasFocus) - } - - private fun renderFileItem(component: JLabel, value: SuggestionItem.FileItem): JPanel { - val file = value.file - component.apply { - text = file.name - icon = when { - file.isDirectory -> AllIcons.Nodes.Folder - else -> FileTypeManager.getInstance().getFileTypeByFileName(file.name).icon - } - iconTextGap = 4 - } - - return panel { - row { - cell(component) - text(truncatePath(480 - component.width - 28, file.path)) - .align(AlignX.RIGHT) - .applyToComponent { - font = JBUI.Fonts.smallFont() - foreground = JBColor.gray - } - } - } - } - - private fun renderActionItem(component: JLabel, value: SuggestionItem.ActionItem): JPanel { - component.apply { - text = value.action.displayName - icon = value.action.icon - iconTextGap = 4 - } - return panel { - row { - cell(component) - } - } - } - - private fun JPanel.setupPanelProperties( - list: JList<*>?, - index: Int, - isSelected: Boolean, - cellHasFocus: Boolean - ) { - preferredSize = Dimension(preferredSize.width, 30) - border = JBUI.Borders.empty(0, 4, 0, 4) - - val isHovered = list?.getClientProperty("hoveredIndex") == index - if (isHovered || isSelected || cellHasFocus) { - background = UIManager.getColor("List.selectionBackground") - foreground = UIManager.getColor("List.selectionForeground") - } - } - - private fun truncatePath(maxWidth: Int, fullPath: String): String { - val fontMetrics = getFontMetrics(JBUI.Fonts.smallFont()) - - if (fontMetrics.stringWidth(fullPath) <= maxWidth) { - return fullPath - } - - val ellipsis = "..." - var truncatedPath = fullPath - while (truncatedPath.isNotEmpty() && fontMetrics.stringWidth(ellipsis + truncatedPath) > maxWidth) { - truncatedPath = truncatedPath.substring(1) - } - return ellipsis + truncatedPath - } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionListCellRenderer.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionListCellRenderer.kt new file mode 100644 index 00000000..8f01f9b5 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionListCellRenderer.kt @@ -0,0 +1,188 @@ +package ee.carlrobert.codegpt.ui.textarea + +import com.intellij.icons.AllIcons +import com.intellij.openapi.components.service +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.ui.ColorUtil +import com.intellij.ui.JBColor +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.gridLayout.UnscaledGaps +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.JBUI.CurrentTheme.GotItTooltip +import ee.carlrobert.codegpt.settings.persona.PersonaSettings +import java.awt.Component +import java.awt.Dimension +import javax.swing.* + +class SuggestionListCellRenderer( + private val textPane: CustomTextPane +) : DefaultListCellRenderer() { + + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component = + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus).apply { + setOpaque(false) + }.let { component -> + if (component is JLabel && value is SuggestionItem) { + renderSuggestionItem(component, value, list, index, isSelected, cellHasFocus) + } else { + component + } + } + + private fun renderSuggestionItem( + component: JLabel, + value: SuggestionItem, + list: JList<*>?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): JPanel = when (value) { + is SuggestionItem.FileItem -> renderFileItem(component, value) + is SuggestionItem.FolderItem -> renderFolderItem(component, value) + is SuggestionItem.ActionItem -> renderActionItem(component, value) + is SuggestionItem.PersonaItem -> renderPersonaItem(component, value) + }.apply { + setupPanelProperties(list, index, isSelected, cellHasFocus) + } + + private fun renderFileItem(component: JLabel, item: SuggestionItem.FileItem): JPanel { + val icon = when { + item.file.isDirectory -> AllIcons.Nodes.Folder + else -> service().getFileTypeByFileName(item.file.name).icon + } + return createDefaultPanel(component, icon, item.file.name, item.file.path) + } + + private fun renderFolderItem(component: JLabel, item: SuggestionItem.FolderItem): JPanel { + return createDefaultPanel( + component, + AllIcons.Nodes.Folder, + item.folder.name, + item.folder.path + ) + } + + private fun renderActionItem(component: JLabel, item: SuggestionItem.ActionItem): JPanel { + val description = if (item.action == DefaultAction.PERSONAS) + service().state.selectedPersona.name + else null + + return createDefaultPanel( + component.apply { + disabledIcon = item.action.icon + isEnabled = item.action.enabled + }, + item.action.icon, + item.action.displayName, + description + ) + } + + private fun renderPersonaItem(component: JLabel, item: SuggestionItem.PersonaItem): JPanel { + return createDefaultPanel( + component, + AllIcons.General.User, + item.personaDetails.name, + item.personaDetails.instructions, + ) + } + + private fun getSearchText(text: String): String? { + val lastAtIndex = text.lastIndexOf('@') + if (lastAtIndex == -1) return null + + val lastColonIndex = text.lastIndexOf(':') + if (lastColonIndex == -1) return null + + return text.substring(lastColonIndex + 1).takeIf { it.isNotEmpty() } + } + + private fun generateHighlightedHtml(title: String, searchText: String): String { + val searchIndex = title.indexOf(searchText, ignoreCase = true) + if (searchIndex == -1) return title + + val prefix = title.substring(0, searchIndex) + val highlight = title.substring( + searchIndex, + (searchIndex + searchText.length).coerceAtMost(title.length) + ) + val suffix = title.substring((searchIndex + searchText.length).coerceAtMost(title.length)) + + val foregroundHex = ColorUtil.toHex(GotItTooltip.codeForeground(true)) + val backgroundHex = ColorUtil.toHex(GotItTooltip.codeBackground(true)) + + return "$prefix$highlight$suffix" + } + + private fun createDefaultPanel( + label: JLabel, + labelIcon: Icon, + title: String, + description: String? = null + ): JPanel { + val searchText = getSearchText(textPane.text) + label.apply { + icon = labelIcon + iconTextGap = 4 + text = if (searchText != null) { + generateHighlightedHtml(title, searchText) + } else { + title + } + } + + return panel { + row { + cell(label) + if (description != null) { + text(description.truncate(480 - label.width - 28, false)) + .customize(UnscaledGaps(left = 8)) + .align(AlignX.RIGHT) + .applyToComponent { + font = JBUI.Fonts.smallFont() + foreground = JBColor.gray + } + } + } + } + } + + private fun JPanel.setupPanelProperties( + list: JList<*>?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ) { + preferredSize = Dimension(480, 30) + border = JBUI.Borders.empty(0, 4, 0, 4) + + val isHovered = list?.getClientProperty("hoveredIndex") == index + if (isHovered || isSelected || cellHasFocus) { + background = UIManager.getColor("List.selectionBackground") + foreground = UIManager.getColor("List.selectionForeground") + } + } + + private fun String.truncate(maxWidth: Int, fromEnd: Boolean = true): String { + val fontMetrics = getFontMetrics(JBUI.Fonts.smallFont()) + if (fontMetrics.stringWidth(this) <= maxWidth) return this + + val ellipsis = "..." + var truncated = this + while (fontMetrics.stringWidth(ellipsis + truncated) > maxWidth && truncated.isNotEmpty()) { + truncated = if (fromEnd) { + truncated.drop(1) + } else { + truncated.dropLast(1) + } + } + return ellipsis + truncated + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionUpdateStrategy.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionUpdateStrategy.kt new file mode 100644 index 00000000..578389f4 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionUpdateStrategy.kt @@ -0,0 +1,164 @@ +package ee.carlrobert.codegpt.ui.textarea + +import com.intellij.openapi.application.readAction +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex +import ee.carlrobert.codegpt.util.ResourceUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import javax.swing.DefaultListModel +import kotlin.io.path.absolutePathString +import kotlin.io.path.isDirectory +import kotlin.io.path.name + +interface SuggestionUpdateStrategy { + fun populateSuggestions( + project: Project, + listModel: DefaultListModel, + ) + + fun updateSuggestions( + project: Project, + listModel: DefaultListModel, + searchText: String, + ) +} + +class FileSuggestionActionStrategy : SuggestionUpdateStrategy { + override fun populateSuggestions( + project: Project, + listModel: DefaultListModel, + ) { + val projectFileIndex = project.service() + CoroutineScope(Dispatchers.Default).launch { + val openFilePaths = project.service().openFiles + .filter { readAction { projectFileIndex.isInContent(it) } } + .take(10) + .map { file -> file.path } + listModel.clear() + listModel.addAll(openFilePaths.map { SuggestionItem.FileItem(File(it)) }) + } + } + + override fun updateSuggestions( + project: Project, + listModel: DefaultListModel, + searchText: String, + ) { + val filePaths = project.service().searchFiles(searchText).take(10) + listModel.clear() + listModel.addAll(filePaths.map { SuggestionItem.FileItem(File(it)) }) + } +} + +class FolderSuggestionActionStrategy : SuggestionUpdateStrategy { + private val projectFoldersCache = mutableMapOf>() + + override fun populateSuggestions( + project: Project, + listModel: DefaultListModel + ) { + CoroutineScope(Dispatchers.Default).launch { + val folderPaths = getProjectFolders(project) + .take(10) + .map { SuggestionItem.FolderItem(Path.of(it).toFile()) } + listModel.clear() + listModel.addAll(folderPaths) + } + } + + override fun updateSuggestions( + project: Project, + listModel: DefaultListModel, + searchText: String + ) { + CoroutineScope(Dispatchers.Default).launch { + val filteredFolders = getProjectFolders(project) + .filter { it.contains(searchText, ignoreCase = true) } + .take(10) + .map { SuggestionItem.FolderItem(Path.of(it).toFile()) } + listModel.clear() + listModel.addAll(filteredFolders) + } + } + + private suspend fun getProjectFolders(project: Project): List { + return projectFoldersCache.getOrPut(project) { + findProjectFolders(project) + } + } + + private suspend fun findProjectFolders(project: Project): List { + val projectRoot = project.basePath?.let { Path.of(it) } ?: return emptyList() + return withContext(Dispatchers.IO) { + val uniqueFolders = mutableSetOf() + Files.walk(projectRoot) + .filter { it.isDirectory() && !it.name.startsWith(".") } + .forEach { folder -> + val folderPath = folder.absolutePathString() + if (uniqueFolders.none { it.startsWith(folderPath) }) { + uniqueFolders.removeAll { it.startsWith(folderPath) } + uniqueFolders.add(folderPath) + } + } + uniqueFolders.toList() + } + } +} + +class PersonaSuggestionActionStrategy : SuggestionUpdateStrategy { + + override fun populateSuggestions( + project: Project, + listModel: DefaultListModel, + ) { + listModel.clear() + listModel.addAll(ResourceUtil.getFilteredPersonaSuggestions(null)) + } + + override fun updateSuggestions( + project: Project, + listModel: DefaultListModel, + searchText: String, + ) { + listModel.clear() + listModel.addAll(ResourceUtil.getFilteredPersonaSuggestions { it.name.contains(searchText, true) }) + } +} + +class CreatePersonaActionStrategy : SuggestionUpdateStrategy { + override fun populateSuggestions( + project: Project, + listModel: DefaultListModel, + ) { + } + + override fun updateSuggestions( + project: Project, + listModel: DefaultListModel, + searchText: String, + ) { + } +} + +class DefaultSuggestionActionStrategy : SuggestionUpdateStrategy { + override fun populateSuggestions( + project: Project, + listModel: DefaultListModel, + ) { + } + + override fun updateSuggestions( + project: Project, + listModel: DefaultListModel, + searchText: String, + ) { + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionsPopupManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionsPopupManager.kt index fff15b0b..474f713f 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionsPopupManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/SuggestionsPopupManager.kt @@ -1,62 +1,96 @@ package ee.carlrobert.codegpt.ui.textarea import com.intellij.icons.AllIcons -import com.intellij.openapi.application.readAction import com.intellij.openapi.components.service -import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project -import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.ui.popup.JBPopup import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.VirtualFileVisitor +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI import com.intellij.vcsUtil.showAbove -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import ee.carlrobert.codegpt.settings.persona.PersonaDetails +import ee.carlrobert.codegpt.settings.persona.PersonaSettings +import ee.carlrobert.codegpt.settings.persona.PersonasConfigurable +import java.awt.Dimension +import java.awt.Point import java.io.File +import java.nio.file.Paths import javax.swing.DefaultListModel import javax.swing.Icon import javax.swing.JComponent +import javax.swing.ScrollPaneConstants +import javax.swing.event.ListDataEvent +import javax.swing.event.ListDataListener -enum class DefaultAction(val displayName: String, val icon: Icon) { - ATTACH_IMAGE("Attach image", AllIcons.FileTypes.Image), - SEARCH_WEB("Search web", AllIcons.General.Web), +enum class DefaultAction( + val displayName: String, + val code: String, + val icon: Icon, + val enabled: Boolean = true +) { + FILES("Files →", "file:", AllIcons.FileTypes.Any_type), + FOLDERS("Folders →", "folder:", AllIcons.Nodes.Folder), + PERSONAS("Personas →", "persona:", AllIcons.General.User), + DOCS("Docs (coming soon) →", "docs:", AllIcons.Toolwindows.Documentation, false), + SEARCH_WEB("Web (coming soon)", "", AllIcons.General.Web, false), + CREATE_NEW_PERSONA("Create new persona", "", AllIcons.General.Add), } sealed class SuggestionItem { data class FileItem(val file: File) : SuggestionItem() + data class FolderItem(val folder: File) : SuggestionItem() data class ActionItem(val action: DefaultAction) : SuggestionItem() + data class PersonaItem(val personaDetails: PersonaDetails) : SuggestionItem() } +val DEFAULT_ACTIONS = mutableListOf( + SuggestionItem.ActionItem(DefaultAction.FILES), + SuggestionItem.ActionItem(DefaultAction.FOLDERS), + SuggestionItem.ActionItem(DefaultAction.PERSONAS), + SuggestionItem.ActionItem(DefaultAction.DOCS), + SuggestionItem.ActionItem(DefaultAction.SEARCH_WEB), +) + class SuggestionsPopupManager( private val project: Project, - private val onSelected: (filePath: String) -> Unit + private val textPane: CustomTextPane, ) { + private var currentActionStrategy: SuggestionUpdateStrategy = DefaultSuggestionActionStrategy() + private val appliedActions: MutableList = mutableListOf() private var popup: JBPopup? = null - private val listModel = DefaultListModel() - private val list = SuggestionList(listModel) { - if (it is SuggestionItem.FileItem) { - onSelected(it.file.path) - } else if (it is SuggestionItem.ActionItem) { - when (it.action) { - DefaultAction.ATTACH_IMAGE -> {} // todo - DefaultAction.SEARCH_WEB -> {} // todo - } + private var originalLocation: Point? = null + private val listModel = DefaultListModel().apply { + addListDataListener(object : ListDataListener { + override fun intervalAdded(e: ListDataEvent) = adjustPopupSize() + override fun intervalRemoved(e: ListDataEvent) {} + override fun contentsChanged(e: ListDataEvent) {} + }) + } + private val list = SuggestionList(listModel, textPane) { + when (it) { + is SuggestionItem.ActionItem -> handleActionSelection(it) + is SuggestionItem.FileItem -> handleFileSelection(it.file.path) + is SuggestionItem.FolderItem -> handleFolderSelection(it.folder.path) + is SuggestionItem.PersonaItem -> handlePersonaSelection(it.personaDetails) } } + private val scrollPane: JBScrollPane = JBScrollPane(list).apply { + border = JBUI.Borders.empty() + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + } fun showPopup(component: JComponent) { popup = createPopup(component) popup?.showAbove(component) - - val projectFileIndex = project.service() - CoroutineScope(Dispatchers.Default).launch { - val openFilePaths = project.service().openFiles - .filter { readAction { projectFileIndex.isInContent(it) } } - .take(6) - .map { it.path } - updateSuggestions(openFilePaths) - } + originalLocation = component.locationOnScreen + reset(true) } fun hidePopup() { @@ -67,11 +101,6 @@ class SuggestionsPopupManager( return popup?.isVisible ?: false } - fun updateSuggestions(filePaths: List) { - listModel.clear() - listModel.addAll(filePaths.map { SuggestionItem.FileItem(File(it)) }) - } - fun requestFocus() { list.requestFocus() } @@ -80,16 +109,119 @@ class SuggestionsPopupManager( list.selectNext() } - private fun createPopup(preferableFocusComponent: JComponent? = null): JBPopup = + fun updateSuggestions(searchText: String) { + currentActionStrategy.updateSuggestions(project, listModel, searchText) + } + + fun reset(clearPrevious: Boolean = true) { + if (clearPrevious) { + listModel.clear() + } + listModel.addAll(DEFAULT_ACTIONS) + popup?.content?.revalidate() + popup?.content?.repaint() + } + + private fun handleActionSelection(item: SuggestionItem.ActionItem) { + if (item.action == DefaultAction.CREATE_NEW_PERSONA) { + hidePopup() + service().showSettingsDialog( + project, + PersonasConfigurable::class.java + ) + return + } + + appliedActions.add(item) + currentActionStrategy = when (item.action) { + DefaultAction.FILES -> { + FileSuggestionActionStrategy() + } + + DefaultAction.FOLDERS -> { + FolderSuggestionActionStrategy() + } + + DefaultAction.PERSONAS -> { + PersonaSuggestionActionStrategy() + } + + else -> { + DefaultSuggestionActionStrategy() + } + } + currentActionStrategy.populateSuggestions(project, listModel) + textPane.appendHighlightedText(item.action.code, withWhitespace = false) + textPane.requestFocus() + } + + private fun handleFileSelection(filePath: String) { + val selectedFile = service().findFileByNioPath(Paths.get(filePath)) + selectedFile?.let { file -> + textPane.appendHighlightedText(file.name, ':') + project.service().addFileToSession(file) + } + hidePopup() + } + + private fun handleFolderSelection(folderPath: String) { + textPane.appendHighlightedText(folderPath, ':') + + val folder = service().findFileByNioPath(Paths.get(folderPath)) + if (folder != null) { + VfsUtilCore.visitChildrenRecursively(folder, object : VirtualFileVisitor() { + override fun visitFile(file: VirtualFile): Boolean { + if (!file.isDirectory) { + project.service().addFileToSession(file) + } + return true + } + }) + } + + hidePopup() + } + + private fun handlePersonaSelection(personaDetails: PersonaDetails) { + service().state.selectedPersona.apply { + id = personaDetails.id + name = personaDetails.name + instructions = personaDetails.instructions + } + textPane.appendHighlightedText(personaDetails.name, ':') + hidePopup() + } + + private fun adjustPopupSize() { + val maxVisibleRows = 15 + val newRowCount = minOf(listModel.size(), maxVisibleRows) + list.setVisibleRowCount(newRowCount) + list.revalidate() + list.repaint() + + popup?.size = list.preferredSize + + originalLocation?.let { original -> + val newY = original.y - list.preferredSize.height + popup?.setLocation(Point(original.x, maxOf(newY, 0))) + } + } + + private fun createPopup( + preferableFocusComponent: JComponent? = null, + ): JBPopup = service() - .createComponentPopupBuilder(list, preferableFocusComponent) + .createComponentPopupBuilder(scrollPane, preferableFocusComponent) .setMovable(true) - .setCancelOnClickOutside(true) + .setCancelOnClickOutside(false) .setCancelOnWindowDeactivation(false) .setRequestFocus(true) + .setMinSize(Dimension(480, 30)) .setCancelCallback { - listModel.removeAllElements() + originalLocation = null + currentActionStrategy = DefaultSuggestionActionStrategy() true } + .setResizable(true) .createPopup() } \ 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 9939db54..59cb6ba1 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt @@ -7,7 +7,6 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service import com.intellij.openapi.observable.properties.AtomicBooleanProperty import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.ui.components.AnActionLink import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.RightGap @@ -22,16 +21,8 @@ import ee.carlrobert.codegpt.settings.GeneralSettings import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.ModelComboBoxAction import ee.carlrobert.codegpt.ui.IconActionButton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import java.awt.* -import java.awt.event.KeyAdapter -import java.awt.event.KeyEvent -import java.nio.file.Paths import javax.swing.JPanel -import javax.swing.text.StyleContext -import javax.swing.text.StyledDocument class UserInputPanel( private val project: Project, @@ -39,12 +30,9 @@ class UserInputPanel( private val onStop: () -> Unit ) : JPanel(BorderLayout()) { - private val suggestionsPopupManager = SuggestionsPopupManager(project) { - handleFileSelection(it) - } - private val textPane = CustomTextPane { handleSubmit() }.apply { - addKeyListener(CustomTextPaneKeyAdapter()) - } + private val textPane = CustomTextPane { handleSubmit() } + .apply { addKeyListener(CustomTextPaneKeyAdapter(project, this)) } + private val submitButton = IconActionButton( object : AnAction( CodeGPTBundle.get("smartTextPane.submitButton.title"), @@ -78,6 +66,46 @@ class UserInputPanel( add(getFooter(), BorderLayout.SOUTH) } + fun setSubmitEnabled(enabled: Boolean) { + submitButton.isEnabled = enabled + stopButton.isEnabled = !enabled + } + + override fun requestFocus() { + textPane.requestFocus() + textPane.requestFocusInWindow() + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = background + g2.fillRoundRect(0, 0, width - 1, height - 1, 16, 16) + super.paintComponent(g) + g2.dispose() + } + + override fun paintBorder(g: Graphics) { + val g2 = g.create() as Graphics2D + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = JBUI.CurrentTheme.ActionButton.focusedBorder() + if (textPane.isFocusOwner) { + g2.stroke = BasicStroke(1.5F) + } + g2.drawRoundRect(0, 0, width - 1, height - 1, 16, 16) + g2.dispose() + } + + override fun getInsets(): Insets = JBUI.insets(4) + + private fun handleSubmit() { + val text = textPane.text.trim() + if (text.isNotEmpty()) { + onSubmit(text) + textPane.text = "" + } + } + private fun getFooter(): JPanel { val attachImageLink = AnActionLink(CodeGPTBundle.get("shared.image"), AttachImageAction()) .apply { @@ -115,118 +143,4 @@ class UserInputPanel( private fun isImageActionSupported(): Boolean { return service().state.selectedService.isImageActionSupported } - - fun setSubmitEnabled(enabled: Boolean) { - submitButton.isEnabled = enabled - stopButton.isEnabled = !enabled - } - - override fun requestFocus() { - textPane.requestFocus() - textPane.requestFocusInWindow() - } - - override fun paintComponent(g: Graphics) { - val g2 = g.create() as Graphics2D - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = background - g2.fillRoundRect(0, 0, width - 1, height - 1, 16, 16) - super.paintComponent(g) - g2.dispose() - } - - override fun paintBorder(g: Graphics) { - val g2 = g.create() as Graphics2D - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = JBUI.CurrentTheme.ActionButton.focusedBorder() - if (textPane.isFocusOwner) { - g2.stroke = BasicStroke(1.5F) - } - g2.drawRoundRect(0, 0, width - 1, height - 1, 16, 16) - g2.dispose() - } - - override fun getInsets(): Insets = JBUI.insets(4) - - private fun updateSuggestions() { - CoroutineScope(Dispatchers.Default).launch { - val lastAtIndex = textPane.text.lastIndexOf('@') - if (lastAtIndex != -1) { - val searchText = textPane.text.substring(lastAtIndex + 1) - if (searchText.isNotEmpty()) { - val filePaths = project.service().searchFiles(searchText) - suggestionsPopupManager.updateSuggestions(filePaths) - } - } else { - suggestionsPopupManager.hidePopup() - } - } - } - - private fun handleSubmit() { - val text = textPane.text.trim() - if (text.isNotEmpty()) { - onSubmit(text) - textPane.text = "" - } - } - - private fun handleFileSelection(filePath: String) { - val selectedFile = service().findFileByNioPath(Paths.get(filePath)) - selectedFile?.let { file -> - textPane.highlightText(file.name) - project.service().addFileToSession(file) - } - suggestionsPopupManager.hidePopup() - } - - inner class CustomTextPaneKeyAdapter : KeyAdapter() { - private val defaultStyle = - StyleContext.getDefaultStyleContext().getStyle(StyleContext.DEFAULT_STYLE) - - override fun keyReleased(e: KeyEvent) { - if (text.isEmpty()) { - project.service().removeFilesFromSession() - } - - // todo - if (!text.contains('@')) { - suggestionsPopupManager.hidePopup() - return - } - - when (e.keyCode) { - KeyEvent.VK_UP, KeyEvent.VK_DOWN -> { - suggestionsPopupManager.requestFocus() - suggestionsPopupManager.selectNext() - e.consume() - } - - else -> { - if (suggestionsPopupManager.isPopupVisible()) { - updateSuggestions() - } - } - } - } - - override fun keyTyped(e: KeyEvent) { - val popupVisible = suggestionsPopupManager.isPopupVisible() - if (e.keyChar == '@' && !popupVisible) { - suggestionsPopupManager.showPopup(textPane) - return - } else if (e.keyChar == '\t') { - suggestionsPopupManager.requestFocus() - suggestionsPopupManager.selectNext() - return - } else if (popupVisible) { - updateSuggestions() - } - - val doc = textPane.document as StyledDocument - if (textPane.caretPosition >= 0) { - doc.setCharacterAttributes(textPane.caretPosition, 1, defaultStyle, true) - } - } - } } \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/ResourceUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/ResourceUtil.kt new file mode 100644 index 00000000..9dff3037 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/ResourceUtil.kt @@ -0,0 +1,29 @@ +package ee.carlrobert.codegpt.util + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import ee.carlrobert.codegpt.settings.persona.PersonaDetails +import ee.carlrobert.codegpt.ui.textarea.DefaultAction +import ee.carlrobert.codegpt.ui.textarea.SuggestionItem +import ee.carlrobert.codegpt.util.file.FileUtil.getResourceContent + +object ResourceUtil { + + fun getFilteredPersonaSuggestions( + filterPredicate: ((PersonaDetails) -> Boolean)? = null + ): List { + var personaDetails = getFilteredPersonaSuggestions() + if (filterPredicate != null) { + personaDetails = personaDetails.filter(filterPredicate).toMutableList() + } + return personaDetails + .map { SuggestionItem.PersonaItem(it) } + .take(10) + listOf(SuggestionItem.ActionItem(DefaultAction.CREATE_NEW_PERSONA)) + } + + fun getFilteredPersonaSuggestions(): MutableList { + return ObjectMapper().readValue( + getResourceContent("/prompts.json"), + object : TypeReference>() {}) + } +} \ 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 f655ac67..e23fd558 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -48,6 +48,8 @@ instance="ee.carlrobert.codegpt.settings.configuration.ConfigurationConfigurable"/> +