From 67e8f1f190a46836a2e1847f6e09f4bd670dca03 Mon Sep 17 00:00:00 2001 From: Blarc Date: Fri, 7 Apr 2023 21:24:10 +0200 Subject: [PATCH] feat: Table for prompts. --- CHANGELOG.md | 1 + .../intellij/plugin/AICommitsExtensions.kt | 34 ++++ .../intellij/plugin/settings/AppSettings.kt | 98 +++++++----- .../settings/AppSettingsConfigurable.kt | 113 +++++++++---- .../settings/AppSettingsListCellRenderer.kt | 5 + .../intellij/plugin/settings/prompt/Prompt.kt | 8 + .../plugin/settings/prompt/PromptTable.kt | 151 ++++++++++++++++++ .../resources/messages/MyBundle.properties | 12 ++ 8 files changed, 348 insertions(+), 74 deletions(-) create mode 100644 src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/AICommitsExtensions.kt create mode 100644 src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/prompt/Prompt.kt create mode 100644 src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/prompt/PromptTable.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ee27105..f415b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] ### Added +- Table for setting prompts. - Different prompts to choose from. - Bug report link to settings. - Add generate commit action progress indicator. diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/AICommitsExtensions.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/AICommitsExtensions.kt new file mode 100644 index 0000000..4f9b5e3 --- /dev/null +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/AICommitsExtensions.kt @@ -0,0 +1,34 @@ +package com.github.blarc.ai.commits.intellij.plugin + +import com.github.blarc.ai.commits.intellij.plugin.AICommitsBundle.message +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.layout.ValidationInfoBuilder +import com.intellij.util.ui.ColumnInfo + +fun createColumn(name: String, formatter: (T) -> String) : ColumnInfo { + return object : ColumnInfo(name) { + override fun valueOf(item: T): String { + return formatter(item) + } + } +} + +fun ValidationInfoBuilder.notBlank(value: String): ValidationInfo? = + if (value.isBlank()) error(message("validation.required")) else null + +fun ValidationInfoBuilder.unique(value: String, existingValues: Set): ValidationInfo? = + if (existingValues.contains(value)) error(message("validation.unique")) else null + +fun ValidationInfoBuilder.isLong(value: String): ValidationInfo? { + if (value.isBlank()){ + return null + } + + value.toLongOrNull().let { + if (it == null) { + return error(message("validation.number")) + } else { + return null + } + } +} diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettings.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettings.kt index bde8a9b..0d79d5f 100644 --- a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettings.kt +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettings.kt @@ -2,6 +2,7 @@ package com.github.blarc.ai.commits.intellij.plugin.settings import com.github.blarc.ai.commits.intellij.plugin.notifications.Notification import com.github.blarc.ai.commits.intellij.plugin.notifications.sendNotification +import com.github.blarc.ai.commits.intellij.plugin.settings.prompt.Prompt import com.intellij.credentialStore.CredentialAttributes import com.intellij.credentialStore.Credentials import com.intellij.ide.passwordSafe.PasswordSafe @@ -15,8 +16,8 @@ import com.intellij.util.xmlb.annotations.OptionTag import java.util.* @State( - name = AppSettings.SERVICE_NAME, - storages = [Storage("AICommit.xml")] + name = AppSettings.SERVICE_NAME, + storages = [Storage("AICommit.xml")] ) class AppSettings : PersistentStateComponent { @@ -28,35 +29,8 @@ class AppSettings : PersistentStateComponent { var requestSupport = true var lastVersion: String? = null - var currentPrompt: String = "basic" - var prompts: MutableMap = mutableMapOf( - // Generate UUIDs for game objects in Mine.py and call the function in start_game(). - "basic" to "Write an insightful but concise Git commit message in a complete sentence in present tense for the " + - "following diff without prefacing it with anything, the response must be in the language {locale} and must" + - "not be longer than 74 characters. The sent text will be the differences between files, where deleted lines" + - " are prefixed with a single minus sign and added lines are prefixed with a single plus sign.\n" + - "{diff}", - // feat: generate unique UUIDs for game objects on Mine game start - "conventional" to "Write a clean and comprehensive commit message in the conventional commit convention. " + - "I'll send you an output of 'git diff --staged' command, and you convert " + - "it into a commit message. " + - "Do NOT preface the commit with anything. " + - "Do NOT add any descriptions to the commit, only commit message. " + - "Use the present tense. " + - "Lines must not be longer than 74 characters. " + - "Use {locale} language to answer.\n" + - "{diff}", - // ✨ feat(mine): Generate objects UUIDs and start team timers on game start - "emoji" to "Write a clean and comprehensive commit messages in the conventional commit convention. " + - "I'll send you an output of 'git diff --staged' command, and you convert " + - "it into a commit message. " + - "Use GitMoji convention to preface the commit. " + - "Do NOT add any descriptions to the commit, only commit message. " + - "Use the present tense. " + - "Lines must not be longer than 74 characters. " + - "Use {locale} language to answer.\n" + - "{diff}", - ) + var prompts: MutableMap = initPrompts() + var currentPrompt: Prompt = prompts["basic"]!! companion object { const val SERVICE_NAME = "com.github.blarc.ai.commits.intellij.plugin.settings.AppSettings" @@ -64,9 +38,16 @@ class AppSettings : PersistentStateComponent { get() = ApplicationManager.getApplication().getService(AppSettings::class.java) } - fun getPrompt(diff: String) = prompts.getOrDefault(currentPrompt, prompts["basic"]!!) - .replace("{locale}", locale.displayName) - .replace("{diff}", diff) + fun getPrompt(diff: String): String { + val content = currentPrompt.content + content.replace("{locale}", locale.displayName) + + return if (content.contains("{diff}")) { + content.replace("{diff}", diff) + } else { + "$content\n$diff" + } + } fun saveOpenAIToken(token: String) { try { @@ -84,10 +65,10 @@ class AppSettings : PersistentStateComponent { private fun getCredentialAttributes(title: String): CredentialAttributes { return CredentialAttributes( - title, - null, - this.javaClass, - false + title, + null, + this.javaClass, + false ) } @@ -104,6 +85,44 @@ class AppSettings : PersistentStateComponent { } } + private fun initPrompts() = mutableMapOf( + // Generate UUIDs for game objects in Mine.py and call the function in start_game(). + "basic" to Prompt("Basic", + "Basic prompt that generates a decent commit message.", + "Write an insightful but concise Git commit message in a complete sentence in present tense for the " + + "following diff without prefacing it with anything, the response must be in the language {locale} and must " + + "NOT be longer than 74 characters. The sent text will be the differences between files, where deleted lines" + + " are prefixed with a single minus sign and added lines are prefixed with a single plus sign.\n" + + "{diff}", + false), + // feat: generate unique UUIDs for game objects on Mine game start + "conventional" to Prompt("Conventional", + "Prompt for commit message in the conventional commit convention.", + "Write a clean and comprehensive commit message in the conventional commit convention. " + + "I'll send you an output of 'git diff --staged' command, and you convert " + + "it into a commit message. " + + "Do NOT preface the commit with anything. " + + "Do NOT add any descriptions to the commit, only commit message. " + + "Use the present tense. " + + "Lines must not be longer than 74 characters. " + + "Use {locale} language to answer.\n" + + "{diff}", + false), + // ✨ feat(mine): Generate objects UUIDs and start team timers on game start + "emoji" to Prompt("Emoji", + "Prompt for commit message in the conventional commit convention with GitMoji convention.", + "Write a clean and comprehensive commit message in the conventional commit convention. " + + "I'll send you an output of 'git diff --staged' command, and you convert " + + "it into a commit message. " + + "Use GitMoji convention to preface the commit. " + + "Do NOT add any descriptions to the commit, only commit message. " + + "Use the present tense. " + + "Lines must not be longer than 74 characters. " + + "Use {locale} language to answer.\n" + + "{diff}", + false) + ) + class LocaleConverter : Converter() { override fun toString(value: Locale): String? { return value.toLanguageTag() @@ -113,5 +132,4 @@ class AppSettings : PersistentStateComponent { return Locale.forLanguageTag(value) } } - -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettingsConfigurable.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettingsConfigurable.kt index c211ade..16fcde4 100644 --- a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettingsConfigurable.kt +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettingsConfigurable.kt @@ -4,76 +4,123 @@ import com.aallam.openai.api.exception.OpenAIAPIException import com.github.blarc.ai.commits.intellij.plugin.AICommitsBundle import com.github.blarc.ai.commits.intellij.plugin.AICommitsBundle.message import com.github.blarc.ai.commits.intellij.plugin.OpenAIService +import com.github.blarc.ai.commits.intellij.plugin.settings.prompt.Prompt +import com.github.blarc.ai.commits.intellij.plugin.settings.prompt.PromptTable import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.options.BoundConfigurable import com.intellij.openapi.progress.runBackgroundableTask +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.CommonActionsPanel +import com.intellij.ui.ToolbarDecorator import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBTextArea import com.intellij.ui.dsl.builder.* import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.util.* +import javax.swing.JComponent import javax.swing.JPasswordField +import javax.swing.JScrollPane class AppSettingsConfigurable : BoundConfigurable(message("settings.general.group.title")) { private val tokenPasswordField = JPasswordField() private val verifyLabel = JBLabel() - private val promptTextArea = JBTextArea() - init { - promptTextArea.wrapStyleWord = true - promptTextArea.lineWrap = true - promptTextArea.isEditable = false - } + private val promptTable = PromptTable() + private lateinit var toolbarDecorator: ToolbarDecorator + private lateinit var promptComboBox: Cell> override fun createPanel() = panel { row { cell(tokenPasswordField) - .label(message("settings.openAIToken")) - .bindText( - { AppSettings.instance.getOpenAIToken().orEmpty() }, - { AppSettings.instance.saveOpenAIToken(it)} - ) - .align(Align.FILL) - .resizableColumn() - .focused() + .label(message("settings.openAIToken")) + .bindText( + { AppSettings.instance.getOpenAIToken().orEmpty() }, + { AppSettings.instance.saveOpenAIToken(it) } + ) + .align(Align.FILL) + .resizableColumn() + .focused() button(message("settings.verifyToken")) { verifyToken() }.align(AlignX.RIGHT) } row { comment(message("settings.openAITokenComment")) - .align(AlignX.LEFT) + .align(AlignX.LEFT) cell(verifyLabel) - .align(AlignX.RIGHT) + .align(AlignX.RIGHT) } row { comboBox(Locale.getAvailableLocales().toList().sortedBy { it.displayName }, AppSettingsListCellRenderer()) - .label(message("settings.locale")) - .bindItem(AppSettings.instance::locale.toNullableProperty()) + .label(message("settings.locale")) + .bindItem(AppSettings.instance::locale.toNullableProperty()) } row { - comboBox(AppSettings.instance.prompts.keys.toList(), AppSettingsListCellRenderer()) - .label(message("settings.prompt")) - .bindItem(AppSettings.instance::currentPrompt.toNullableProperty()) - .onChanged { promptTextArea.text = AppSettings.instance.prompts[it.item] } + promptComboBox = comboBox(AppSettings.instance.prompts.values, AppSettingsListCellRenderer()) + .label(message("settings.prompt")) + .bindItem(AppSettings.instance::currentPrompt.toNullableProperty()) } row { - cell(promptTextArea) - .bindText( - { AppSettings.instance.getPrompt("") }, - { } - ) - .align(Align.FILL) - .resizableColumn() + toolbarDecorator = ToolbarDecorator.createDecorator(promptTable.table) + .setAddAction { + promptTable.addPrompt().let { + promptComboBox.component.addItem(it) + } + } + .setEditAction { + promptTable.editPrompt()?.let { + promptComboBox.component.removeItem(it.first) + promptComboBox.component.addItem(it.second) + } + } + .setEditActionUpdater { + updateActionAvailability(CommonActionsPanel.Buttons.EDIT) + true + } + .setRemoveAction { + promptTable.removePrompt()?.let { + promptComboBox.component.removeItem(it) + } + } + .setRemoveActionUpdater { + updateActionAvailability(CommonActionsPanel.Buttons.REMOVE) + true + } + .disableUpDownActions() + + cell(toolbarDecorator.createPanel()) + .align(Align.FILL) }.resizableRow() + row { browserLink(message("settings.report-bug"), AICommitsBundle.URL_BUG_REPORT.toString()) } } + private fun updateActionAvailability(action: CommonActionsPanel.Buttons) { + val selectedRow = promptTable.table.selectedRow + val selectedPrompt = promptTable.table.items[selectedRow] + toolbarDecorator.actionsPanel.setEnabled(action, selectedPrompt.canBeChanged) + } + + override fun isModified(): Boolean { + return super.isModified() || promptTable.isModified() + } + + override fun apply() { + promptTable.apply() + super.apply() + } + + override fun reset() { + promptTable.reset() + super.reset() + } + @OptIn(DelicateCoroutinesApi::class) private fun verifyToken() { runBackgroundableTask(message("settings.verify.running")) { @@ -89,12 +136,10 @@ class AppSettingsConfigurable : BoundConfigurable(message("settings.general.grou OpenAIService.instance.verifyToken(String(tokenPasswordField.password)) verifyLabel.text = message("settings.verify.valid") verifyLabel.icon = AllIcons.General.InspectionsOK - } - catch (e: OpenAIAPIException) { + } catch (e: OpenAIAPIException) { verifyLabel.text = message("settings.verify.invalid", e.statusCode) verifyLabel.icon = AllIcons.General.InspectionsError - } - catch (e: Exception) { + } catch (e: Exception) { verifyLabel.text = message("settings.verify.invalid", "Unknown") verifyLabel.icon = AllIcons.General.InspectionsError } diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettingsListCellRenderer.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettingsListCellRenderer.kt index 04af551..7248dd5 100644 --- a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettingsListCellRenderer.kt +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettingsListCellRenderer.kt @@ -1,5 +1,7 @@ package com.github.blarc.ai.commits.intellij.plugin.settings +import ai.grazie.utils.capitalize +import com.github.blarc.ai.commits.intellij.plugin.settings.prompt.Prompt import java.awt.Component import java.util.* import javax.swing.DefaultListCellRenderer @@ -17,6 +19,9 @@ class AppSettingsListCellRenderer : DefaultListCellRenderer() { if (value is Locale) { text = value.displayName } + if (value is Prompt) { + text = value.name + } return component } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/prompt/Prompt.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/prompt/Prompt.kt new file mode 100644 index 0000000..34ec005 --- /dev/null +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/prompt/Prompt.kt @@ -0,0 +1,8 @@ +package com.github.blarc.ai.commits.intellij.plugin.settings.prompt + +data class Prompt( + var name: String = "", + var description: String = "", + var content: String = "", + var canBeChanged: Boolean = true +) diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/prompt/PromptTable.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/prompt/PromptTable.kt new file mode 100644 index 0000000..8c696b8 --- /dev/null +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/prompt/PromptTable.kt @@ -0,0 +1,151 @@ +package com.github.blarc.ai.commits.intellij.plugin.settings.prompt + +import ai.grazie.utils.applyIf +import com.github.blarc.ai.commits.intellij.plugin.AICommitsBundle.message +import com.github.blarc.ai.commits.intellij.plugin.createColumn +import com.github.blarc.ai.commits.intellij.plugin.notBlank +import com.github.blarc.ai.commits.intellij.plugin.settings.AppSettings +import com.github.blarc.ai.commits.intellij.plugin.unique +import com.intellij.openapi.ui.DialogWrapper +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.bindText +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.table.TableView +import com.intellij.util.ui.ListTableModel +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.ListSelectionModel.SINGLE_SELECTION + +class PromptTable { + private var prompts = AppSettings.instance.prompts + private val tableModel = createTableModel() + + val table = TableView(tableModel).apply { + setShowColumns(true) + setSelectionMode(SINGLE_SELECTION) + + columnModel.getColumn(0).preferredWidth = 150 + columnModel.getColumn(0).maxWidth = 250 + + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent?) { + if (e?.clickCount == 2) { + editPrompt() + } + } + }) + } + + private fun createTableModel(): ListTableModel = ListTableModel( + arrayOf( + createColumn(message("settings.prompt.name")) { prompt -> prompt.name }, + createColumn(message("settings.prompt.description")) { prompt -> prompt.description }, + ), + prompts.values.toList() + ) + + fun addPrompt(): Prompt? { + val dialog = PromptDialog(prompts.keys.toSet()) + + if (dialog.showAndGet()) { + prompts = prompts.plus(dialog.prompt.name.lowercase() to dialog.prompt).toMutableMap() + refreshTableModel() + return dialog.prompt + } + return null + } + + fun removePrompt(): Prompt? { + val selectedPrompt = table.selectedObject ?: return null + prompts = prompts.minus(selectedPrompt.name.lowercase()).toMutableMap() + refreshTableModel() + return selectedPrompt + } + + fun editPrompt(): Pair? { + val selectedPrompt = table.selectedObject ?: return null + val dialog = PromptDialog(prompts.keys.toSet(), selectedPrompt.copy()) + + if (dialog.showAndGet()) { + prompts = prompts.minus(selectedPrompt.name.lowercase()).toMutableMap() + prompts[dialog.prompt.name.lowercase()] = dialog.prompt + refreshTableModel() + return selectedPrompt to dialog.prompt + } + return null + } + + private fun refreshTableModel() { + tableModel.items = prompts.values.toList() + } + + fun reset() { + prompts = AppSettings.instance.prompts + refreshTableModel() + } + + fun isModified() = prompts != AppSettings.instance.prompts + + fun apply() { + AppSettings.instance.prompts = prompts + } + + private class PromptDialog(val prompts: Set, val newPrompt: Prompt? = null) : DialogWrapper(true) { + + val prompt = newPrompt ?: Prompt("") + val promptNameTextField = JBTextField() + val promptDescriptionTextField = JBTextField() + val promptContentTextArea = JBTextArea() + + init { + title = newPrompt?.let { message("settings.prompt.edit.title") } ?: message("settings.prompt.add.title") + setOKButtonText(newPrompt?.let { message("actions.update") } ?: message("actions.add")) + setSize(700, 500) + + promptContentTextArea.wrapStyleWord = true + promptContentTextArea.lineWrap = true + + if (!prompt.canBeChanged) { + isOKActionEnabled = false + promptNameTextField.isEditable = false + promptDescriptionTextField.isEditable = false + promptContentTextArea.isEditable = false + } + + init() + } + + override fun createCenterPanel() = panel { + row(message("settings.prompt.name")) { + cell(promptNameTextField) + .align(Align.FILL) + .bindText(prompt::name) + .applyIf(prompt.canBeChanged) { focused() } + .validationOnApply { notBlank(it.text) } + .applyIf(newPrompt == null) { validationOnApply { unique(it.text.lowercase(), prompts) } } + } + row(message("settings.prompt.description")) { + cell(promptDescriptionTextField) + .align(Align.FILL) + .bindText(prompt::description) + .validationOnApply { notBlank(it.text) } + } + row { + label(message("settings.prompt.content")) + } + row() { + cell(promptContentTextArea) + .align(Align.FILL) + .bindText(prompt::content) + .validationOnApply { notBlank(it.text) } + .resizableColumn() + }.resizableRow() + row { + comment(message("settings.prompt.comment")) + } + } + + } +} \ No newline at end of file diff --git a/src/main/resources/messages/MyBundle.properties b/src/main/resources/messages/MyBundle.properties index e10cae7..9eabaf6 100644 --- a/src/main/resources/messages/MyBundle.properties +++ b/src/main/resources/messages/MyBundle.properties @@ -33,4 +33,16 @@ actions.sure-take-me-there=Sure, take me there. notifications.prompt-too-large=The diff is too large for the OpenAI API. Try reducing the number of staged changes, or write your own commit message. notifications.no-commit-message=Commit field has not been initialized correctly. notifications.unable-to-save-token=Token could not be saved. +settings.addPrompt=Add prompt +settings.prompt.name=Name +settings.prompt.content=Content +validation.required=This value is required. +validation.number=Value is not a number. +settings.prompt.comment=You can use {locale} and {diff} variables to customise your prompt. +actions.update=Update +actions.add=Add +settings.prompt.edit.title=Edit Prompt +settings.prompt.add.title=Add Prompt +settings.prompt.description=Description +validation.unique=Value already exists.