From 91c0eac87c2f73a2cffdc4331bf3715e88a611e2 Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Thu, 26 Mar 2026 10:20:13 +0800 Subject: [PATCH 1/6] docs: analyze prompt storage strategy for prompt window --- doc/prompt_storage_strategy_analysis.md | 76 +++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 doc/prompt_storage_strategy_analysis.md diff --git a/doc/prompt_storage_strategy_analysis.md b/doc/prompt_storage_strategy_analysis.md new file mode 100644 index 0000000..e95bf0d --- /dev/null +++ b/doc/prompt_storage_strategy_analysis.md @@ -0,0 +1,76 @@ +# Prompt 儲存策略分析(branch/prompt_window) + +## 1) 儲存位置與格式 + +目前 Prompt 設定集中儲存在專案根目錄的 `.ai-test.yaml` / `.ai-test.yml`,由 `LlmSettingsLoader` 讀寫。 + +- 讀取:優先找現有檔案,找不到會建立 `.ai-test.yaml`。 +- 格式:YAML,主要節點為 `prompts`。 +- 內容分為兩層: + - 單一模板欄位(例如 `prompts.commitMessage`) + - 多 Profile 欄位(例如 `prompts.commitMessageProfiles.selected + items`) + +## 2) 內建預設值來源 + +內建 prompt 文案放在 `AiPromptDefaults`(程式常數),包含: + +- `COMMIT_MESSAGE` +- `PULL_REQUEST` +- `BRANCH_DIFF_SUMMARY` +- Generation wrapper / framework rules + +當 YAML 缺值或空字串時,會 fallback 到這些預設值。 + +## 3) Profile 結構與解析規則 + +Prompt profile 使用 `PromptProfileSet`: + +- `selected`: 目前選用 profile 名稱 +- `items`: `Map`(profileName -> template) +- 預設 profile 名稱固定為 `default` + +解析規則重點: + +1. 僅保留「名稱非空 + 模板非空白」的項目。 +2. 若 `items` 為空,會自動補上 `default: `。 +3. 若 `items` 非空但沒有 `default`,也會自動補 `default`。 +4. 實際使用時會先取 `selected`,不存在或空白就退回 `default`,再不行才退回函式傳入的 fallback template。 + +## 4) 寫回策略(Settings UI) + +Settings 畫面儲存時,`buildPromptsMap` 會把 prompts 整包重建後寫回 YAML: + +- `prompts.generation`(wrapper + framework rules) +- `prompts.commitMessage` / `pullRequest` / `branchDiffSummary` +- 三類 profile:`generationProfiles` / `commitMessageProfiles` / `branchDiffSummaryProfiles` + +Profile 在 UI 內採「YAML 文字」編輯,儲存時再 parse 成 map。 + +## 5) 執行時使用策略 + +- Commit message 與 Branch diff 都支援從 profile menu 選 prompt。 +- 使用者點選某個 profile 後,會立即把該名稱寫回 `selected`(持久化到 `.ai-test.yaml`)。 +- 之後若未指定 override,系統會用 `PromptProfileResolver` 依 `selected -> default -> fallback` 決策。 + +## 6) 優點 + +- 可版本控管:`.ai-test.yaml` 可跟 repo 一起管理。 +- 有穩定 fallback:缺值不至於崩潰,能退回內建 prompt。 +- 支援多 profile:可快速切換不同提示詞風格。 + +## 7) 目前風險 / 限制 + +1. **專案級儲存,不是使用者級儲存**:不同開發者可能互相覆蓋 `selected`。 +2. **整包重寫 prompts 區塊**:手動註解或格式可能在儲存後丟失(YAML dump 行為)。 +3. **`selected` 不保證一定存在於 `items`**:雖有 runtime fallback,但設定值可能漂移。 +4. **UI 以原始 YAML 編輯 profiles**:可用性高彈性,但格式錯誤風險高。 +5. **Pull Request prompt 目前只有單模板欄位,沒有 profile set**:與 commit/branch diff 能力不一致。 + +## 8) 若要做 prompt window 的建議方向 + +- 在視窗層加「preview 最終 prompt」:顯示 selected profile 套變數後的內容。 +- 優化 profile 管理:改為結構化列表編輯(新增/複製/刪除),降低 YAML 手誤。 +- 增加驗證:儲存時檢查 `selected` 必須存在於 `items`,否則自動回落 `default`。 +- 規劃儲存分層: + - repo 層(共享) + - user 層(本機偏好,不進版控) From ae24fb9e5bb87c48722def2a336d507389711fed Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Thu, 26 Mar 2026 10:29:36 +0800 Subject: [PATCH 2/6] feat: add prompt manager to context sidebar --- doc/prompt_storage_strategy_analysis.md | 76 ---------------- .../ai/plugin/ContextBoxToolWindowFactory.kt | 89 ++++++++++++++++++- 2 files changed, 87 insertions(+), 78 deletions(-) delete mode 100644 doc/prompt_storage_strategy_analysis.md diff --git a/doc/prompt_storage_strategy_analysis.md b/doc/prompt_storage_strategy_analysis.md deleted file mode 100644 index e95bf0d..0000000 --- a/doc/prompt_storage_strategy_analysis.md +++ /dev/null @@ -1,76 +0,0 @@ -# Prompt 儲存策略分析(branch/prompt_window) - -## 1) 儲存位置與格式 - -目前 Prompt 設定集中儲存在專案根目錄的 `.ai-test.yaml` / `.ai-test.yml`,由 `LlmSettingsLoader` 讀寫。 - -- 讀取:優先找現有檔案,找不到會建立 `.ai-test.yaml`。 -- 格式:YAML,主要節點為 `prompts`。 -- 內容分為兩層: - - 單一模板欄位(例如 `prompts.commitMessage`) - - 多 Profile 欄位(例如 `prompts.commitMessageProfiles.selected + items`) - -## 2) 內建預設值來源 - -內建 prompt 文案放在 `AiPromptDefaults`(程式常數),包含: - -- `COMMIT_MESSAGE` -- `PULL_REQUEST` -- `BRANCH_DIFF_SUMMARY` -- Generation wrapper / framework rules - -當 YAML 缺值或空字串時,會 fallback 到這些預設值。 - -## 3) Profile 結構與解析規則 - -Prompt profile 使用 `PromptProfileSet`: - -- `selected`: 目前選用 profile 名稱 -- `items`: `Map`(profileName -> template) -- 預設 profile 名稱固定為 `default` - -解析規則重點: - -1. 僅保留「名稱非空 + 模板非空白」的項目。 -2. 若 `items` 為空,會自動補上 `default: `。 -3. 若 `items` 非空但沒有 `default`,也會自動補 `default`。 -4. 實際使用時會先取 `selected`,不存在或空白就退回 `default`,再不行才退回函式傳入的 fallback template。 - -## 4) 寫回策略(Settings UI) - -Settings 畫面儲存時,`buildPromptsMap` 會把 prompts 整包重建後寫回 YAML: - -- `prompts.generation`(wrapper + framework rules) -- `prompts.commitMessage` / `pullRequest` / `branchDiffSummary` -- 三類 profile:`generationProfiles` / `commitMessageProfiles` / `branchDiffSummaryProfiles` - -Profile 在 UI 內採「YAML 文字」編輯,儲存時再 parse 成 map。 - -## 5) 執行時使用策略 - -- Commit message 與 Branch diff 都支援從 profile menu 選 prompt。 -- 使用者點選某個 profile 後,會立即把該名稱寫回 `selected`(持久化到 `.ai-test.yaml`)。 -- 之後若未指定 override,系統會用 `PromptProfileResolver` 依 `selected -> default -> fallback` 決策。 - -## 6) 優點 - -- 可版本控管:`.ai-test.yaml` 可跟 repo 一起管理。 -- 有穩定 fallback:缺值不至於崩潰,能退回內建 prompt。 -- 支援多 profile:可快速切換不同提示詞風格。 - -## 7) 目前風險 / 限制 - -1. **專案級儲存,不是使用者級儲存**:不同開發者可能互相覆蓋 `selected`。 -2. **整包重寫 prompts 區塊**:手動註解或格式可能在儲存後丟失(YAML dump 行為)。 -3. **`selected` 不保證一定存在於 `items`**:雖有 runtime fallback,但設定值可能漂移。 -4. **UI 以原始 YAML 編輯 profiles**:可用性高彈性,但格式錯誤風險高。 -5. **Pull Request prompt 目前只有單模板欄位,沒有 profile set**:與 commit/branch diff 能力不一致。 - -## 8) 若要做 prompt window 的建議方向 - -- 在視窗層加「preview 最終 prompt」:顯示 selected profile 套變數後的內容。 -- 優化 profile 管理:改為結構化列表編輯(新增/複製/刪除),降低 YAML 手誤。 -- 增加驗證:儲存時檢查 `selected` 必須存在於 `items`,否則自動回落 `default`。 -- 規劃儲存分層: - - repo 層(共享) - - user 層(本機偏好,不進版控) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt index b29db5a..420fcae 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt @@ -10,12 +10,23 @@ import java.awt.BorderLayout import java.awt.Color import java.awt.Font import javax.swing.BorderFactory +import javax.swing.JList import javax.swing.JPanel +import javax.swing.JSplitPane +import javax.swing.JTabbedPane import javax.swing.JTextArea +import javax.swing.ListSelectionModel import javax.swing.UIManager class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { + data class PromptLeaf( + val name: String, + val content: String + ) { + override fun toString(): String = name + } + override fun shouldBeAvailable(project: Project): Boolean = true override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { @@ -39,6 +50,23 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { border = BorderFactory.createEmptyBorder(10, 10, 10, 10) } + val promptArea = JTextArea().apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + font = commonFont + background = bgColor + foreground = fgColor + caretColor = fgColor + border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + text = "Select a prompt from the list to view full content." + } + + val promptCategoryTabs = JTabbedPane().apply { + background = bgColor + foreground = fgColor + } + fun styledScrollPane(component: java.awt.Component): JBScrollPane = JBScrollPane(component).apply { viewport.background = bgColor @@ -46,15 +74,72 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { border = BorderFactory.createLineBorder(borderColor) } - val panel = JPanel(BorderLayout()).apply { + fun buildPromptLeafMap(): LinkedHashMap> { + val config = runCatching { LlmSettingsLoader.loadConfig(project) }.getOrNull() + ?: return linkedMapOf("Error" to listOf(PromptLeaf("Load failed", "Unable to load .ai-test.yaml"))) + val prompts = config.prompts + return linkedMapOf( + "Test Generation" to prompts.profiles.generation.items.entries.map { PromptLeaf(it.key, it.value) }, + "Commit Message" to prompts.profiles.commitMessage.items.entries.map { PromptLeaf(it.key, it.value) }, + "Branch Diff" to prompts.profiles.branchDiffSummary.items.entries.map { PromptLeaf(it.key, it.value) }, + "Pull Request" to listOf(PromptLeaf("default", prompts.pullRequest)) + ) + } + + fun refreshPromptTabs() { + promptCategoryTabs.removeAll() + val promptMap = buildPromptLeafMap() + promptMap.forEach { (category, items) -> + val list = JList(items.toTypedArray()).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + font = commonFont + background = bgColor + foreground = fgColor + border = BorderFactory.createEmptyBorder(8, 8, 8, 8) + selectionBackground = Color(0x2A, 0x2A, 0x2A) + selectionForeground = fgColor + } + list.addListSelectionListener { + val selected = list.selectedValue ?: return@addListSelectionListener + promptArea.text = selected.content + promptArea.caretPosition = 0 + } + val split = JSplitPane(JSplitPane.HORIZONTAL_SPLIT).apply { + leftComponent = styledScrollPane(list) + rightComponent = styledScrollPane(promptArea) + resizeWeight = 0.35 + border = BorderFactory.createEmptyBorder() + background = bgColor + } + promptCategoryTabs.addTab(category, split) + } + } + + refreshPromptTabs() + + val resultPanel = JPanel(BorderLayout()).apply { add(styledScrollPane(resultArea), BorderLayout.CENTER) background = bgColor foreground = fgColor } + val promptPanel = JPanel(BorderLayout()).apply { + add(promptCategoryTabs, BorderLayout.CENTER) + background = bgColor + foreground = fgColor + } + + val rootTabs = JTabbedPane().apply { + addTab("Latest Result", resultPanel) + addTab("Prompt Manager", promptPanel) + background = bgColor + foreground = fgColor + } + fun render(snapshot: ContextBoxStateService.Snapshot) { resultArea.text = snapshot.latestResult resultArea.caretPosition = 0 + refreshPromptTabs() } render(stateService.snapshot()) @@ -66,7 +151,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { } ) - val content = ContentFactory.getInstance().createContent(panel, "", false) + val content = ContentFactory.getInstance().createContent(rootTabs, "", false) toolWindow.contentManager.addContent(content) } } From d839d9a43972c5e95f24d494ad2687097f249cac Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Thu, 26 Mar 2026 10:41:30 +0800 Subject: [PATCH 3/6] fix: show prompts in all categories with vertical layout --- .../ai/plugin/ContextBoxToolWindowFactory.kt | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt index 420fcae..690a469 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt @@ -50,18 +50,6 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { border = BorderFactory.createEmptyBorder(10, 10, 10, 10) } - val promptArea = JTextArea().apply { - isEditable = false - lineWrap = true - wrapStyleWord = true - font = commonFont - background = bgColor - foreground = fgColor - caretColor = fgColor - border = BorderFactory.createEmptyBorder(10, 10, 10, 10) - text = "Select a prompt from the list to view full content." - } - val promptCategoryTabs = JTabbedPane().apply { background = bgColor foreground = fgColor @@ -90,6 +78,17 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { promptCategoryTabs.removeAll() val promptMap = buildPromptLeafMap() promptMap.forEach { (category, items) -> + val promptArea = JTextArea().apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + font = commonFont + background = bgColor + foreground = fgColor + caretColor = fgColor + border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + text = "Select a prompt from the list to view full content." + } val list = JList(items.toTypedArray()).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION font = commonFont @@ -104,13 +103,16 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { promptArea.text = selected.content promptArea.caretPosition = 0 } - val split = JSplitPane(JSplitPane.HORIZONTAL_SPLIT).apply { - leftComponent = styledScrollPane(list) - rightComponent = styledScrollPane(promptArea) - resizeWeight = 0.35 + val split = JSplitPane(JSplitPane.VERTICAL_SPLIT).apply { + topComponent = styledScrollPane(list) + bottomComponent = styledScrollPane(promptArea) + resizeWeight = 0.4 border = BorderFactory.createEmptyBorder() background = bgColor } + if (items.isNotEmpty()) { + list.selectedIndex = 0 + } promptCategoryTabs.addTab(category, split) } } From bab506dc7634363478161ff73ebf9cc25599c98a Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Thu, 26 Mar 2026 10:41:34 +0800 Subject: [PATCH 4/6] feat: add prompt CRUD controls in settings and sidebar --- .../ai/plugin/AiTestSettingsConfigurable.kt | 195 ++++++++++++------ .../ai/plugin/ContextBoxToolWindowFactory.kt | 194 +++++++++++++++-- 2 files changed, 308 insertions(+), 81 deletions(-) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt index 89cf41e..d11f8be 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt @@ -17,13 +17,17 @@ import javax.swing.JButton import javax.swing.JCheckBox import javax.swing.JComboBox import javax.swing.JComponent +import javax.swing.DefaultListModel import javax.swing.JLabel +import javax.swing.JList import javax.swing.JPanel import javax.swing.JPasswordField import javax.swing.JScrollPane +import javax.swing.JSplitPane import javax.swing.JTabbedPane import javax.swing.JTextArea import javax.swing.JTextField +import javax.swing.ListSelectionModel import javax.swing.SwingConstants import org.yaml.snakeyaml.DumperOptions import org.yaml.snakeyaml.Yaml @@ -305,83 +309,148 @@ class AiTestSettingsConfigurable( }).apply { border = BorderFactory.createEmptyBorder() } private fun generationPromptManagerSection(): JComponent { - val addButton = JButton("Add Test Prompt").apply { + return buildInteractivePromptManagerSection( + title = "Test Prompt Manager", + typeLabel = "Test", + profilesYamlField = generationPromptProfilesYamlField, + defaultField = generationPromptProfileDefaultField + ) + } + + private fun commitPromptManagerSection(): JComponent { + return buildInteractivePromptManagerSection( + title = "Commit Prompt Manager", + typeLabel = "Commit", + profilesYamlField = commitPromptProfilesYamlField, + defaultField = commitPromptProfileDefaultField + ) + } + + private fun branchDiffPromptManagerSection(): JComponent { + return buildInteractivePromptManagerSection( + title = "Branch Diff Prompt Manager", + typeLabel = "Branch Diff", + profilesYamlField = branchDiffPromptProfilesYamlField, + defaultField = branchDiffPromptProfileDefaultField + ) + } + + private fun buildInteractivePromptManagerSection( + title: String, + typeLabel: String, + profilesYamlField: JTextArea, + defaultField: JTextField + ): JComponent { + val listModel = DefaultListModel() + val nameList = JList(listModel).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + } + val editorArea = textArea(8) + val editorScroll = JScrollPane(editorArea) + val editorPanel = JPanel(BorderLayout(0, 8)).apply { + add(editorScroll, BorderLayout.CENTER) + isVisible = false + } + + fun refreshList(selectedName: String? = null) { + val map = parseYamlMap(profilesYamlField.text) + listModel.clear() + map.keys.forEach { listModel.addElement(it) } + when { + selectedName != null && map.containsKey(selectedName) -> nameList.setSelectedValue(selectedName, true) + listModel.size() > 0 -> nameList.selectedIndex = 0 + else -> { + nameList.clearSelection() + editorPanel.isVisible = false + } + } + } + + nameList.addListSelectionListener { + val selected = nameList.selectedValue + if (selected == null) { + editorPanel.isVisible = false + editorArea.text = "" + } else { + editorPanel.isVisible = true + editorArea.text = parseYamlMap(profilesYamlField.text)[selected].orEmpty() + editorArea.caretPosition = 0 + } + } + + val addButton = JButton("Add").apply { addActionListener { - addPromptProfile( - typeLabel = "Test", - nameField = generationPromptNewNameField, - valueField = generationPromptNewValueField, - profilesYamlField = generationPromptProfilesYamlField, - defaultField = generationPromptProfileDefaultField - ) + val name = Messages.showInputDialog( + project, + "Enter $typeLabel prompt name", + "Add Prompt", + null + )?.trim().orEmpty() + if (name.isBlank()) return@addActionListener + val map = parseYamlMap(profilesYamlField.text).toMutableMap() + if (map.containsKey(name)) { + Messages.showErrorDialog(project, "$typeLabel prompt '$name' already exists.", "AI Test Generator") + return@addActionListener + } + map[name] = "" + profilesYamlField.text = dumpYamlMap(map) + if (defaultField.text.isBlank()) { + defaultField.text = name + } + refreshList(name) } } - return formSection("Test Prompt Manager", listOf( - "New prompt name" to generationPromptNewNameField, - "New prompt value" to JScrollPane(generationPromptNewValueField), - "Action" to JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { add(addButton) } - )) - } - private fun commitPromptManagerSection(): JComponent { - val addButton = JButton("Add Commit Prompt").apply { + val deleteButton = JButton("Delete").apply { addActionListener { - addPromptProfile( - typeLabel = "Commit", - nameField = commitPromptNewNameField, - valueField = commitPromptNewValueField, - profilesYamlField = commitPromptProfilesYamlField, - defaultField = commitPromptProfileDefaultField - ) + val selected = nameList.selectedValue ?: return@addActionListener + val map = parseYamlMap(profilesYamlField.text).toMutableMap() + map.remove(selected) + if (map.isEmpty()) { + map[PromptProfileSet.DEFAULT_NAME] = "" + } + profilesYamlField.text = dumpYamlMap(map) + if (defaultField.text == selected) { + defaultField.text = map.keys.firstOrNull().orEmpty() + } + refreshList() } } - return formSection("Commit Prompt Manager", listOf( - "New prompt name" to commitPromptNewNameField, - "New prompt value" to JScrollPane(commitPromptNewValueField), - "Action" to JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { add(addButton) } - )) - } - private fun branchDiffPromptManagerSection(): JComponent { - val addButton = JButton("Add Branch Diff Prompt").apply { + val saveButton = JButton("Save").apply { addActionListener { - addPromptProfile( - typeLabel = "Branch Diff", - nameField = branchDiffPromptNewNameField, - valueField = branchDiffPromptNewValueField, - profilesYamlField = branchDiffPromptProfilesYamlField, - defaultField = branchDiffPromptProfileDefaultField - ) + val selected = nameList.selectedValue ?: return@addActionListener + val map = parseYamlMap(profilesYamlField.text).toMutableMap() + map[selected] = editorArea.text + profilesYamlField.text = dumpYamlMap(map) + refreshList(selected) } } - return formSection("Branch Diff Prompt Manager", listOf( - "New prompt name" to branchDiffPromptNewNameField, - "New prompt value" to JScrollPane(branchDiffPromptNewValueField), - "Action" to JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { add(addButton) } - )) - } - private fun addPromptProfile( - typeLabel: String, - nameField: JTextField, - valueField: JTextArea, - profilesYamlField: JTextArea, - defaultField: JTextField - ) { - val name = nameField.text.trim() - val value = valueField.text.trim() - if (name.isBlank() || value.isBlank()) { - Messages.showErrorDialog(project, "$typeLabel prompt name and value are required.", "AI Test Generator") - return + val buttonRow = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)).apply { + add(addButton) + add(deleteButton) + add(saveButton) + } + + val split = JSplitPane(JSplitPane.VERTICAL_SPLIT).apply { + topComponent = JScrollPane(nameList) + bottomComponent = editorPanel + resizeWeight = 0.4 + border = BorderFactory.createEmptyBorder() } - val map = parseYamlMap(profilesYamlField.text).toMutableMap() - map[name] = value - profilesYamlField.text = dumpYamlMap(map) - if (defaultField.text.isBlank()) { - defaultField.text = name + + refreshList() + + return JPanel(BorderLayout(0, 8)).apply { + border = BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder(title), + BorderFactory.createEmptyBorder(8, 8, 8, 8) + ) + add(buttonRow, BorderLayout.NORTH) + add(split, BorderLayout.CENTER) + maximumSize = Dimension(Int.MAX_VALUE, 420) } - nameField.text = "" - valueField.text = "" } private fun formSection(title: String, rows: List>): JComponent { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt index 690a469..4cf21b0 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt @@ -2,14 +2,18 @@ package org.openprojectx.ai.plugin import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.components.JBScrollPane import com.intellij.ui.content.ContentFactory import java.awt.BorderLayout import java.awt.Color +import java.awt.FlowLayout import java.awt.Font import javax.swing.BorderFactory +import javax.swing.DefaultListModel +import javax.swing.JButton import javax.swing.JList import javax.swing.JPanel import javax.swing.JSplitPane @@ -17,6 +21,8 @@ import javax.swing.JTabbedPane import javax.swing.JTextArea import javax.swing.ListSelectionModel import javax.swing.UIManager +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.Yaml class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { @@ -27,6 +33,13 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { override fun toString(): String = name } + enum class PromptCategory { + GENERATION, + COMMIT, + BRANCH_DIFF, + PULL_REQUEST + } + override fun shouldBeAvailable(project: Project): Boolean = true override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { @@ -62,24 +75,112 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { border = BorderFactory.createLineBorder(borderColor) } - fun buildPromptLeafMap(): LinkedHashMap> { + fun parseYamlMap(text: String): Map { + val parsed = Yaml().load(text) as? Map<*, *> ?: emptyMap() + val result = linkedMapOf() + parsed.forEach { (k, v) -> + val key = k?.toString()?.trim().orEmpty() + if (key.isNotBlank()) { + result[key] = v?.toString().orEmpty() + } + } + return result + } + + fun dumpYamlMap(value: Map): String { + val options = DumperOptions().apply { + defaultFlowStyle = DumperOptions.FlowStyle.BLOCK + indent = 2 + isPrettyFlow = true + } + return Yaml(options).dump(value).trimEnd() + } + + fun updateYamlProfile(yaml: String, name: String, value: String): String { + val map = parseYamlMap(yaml).toMutableMap() + map[name] = value + return dumpYamlMap(map) + } + + fun removeYamlProfile(yaml: String, name: String): String { + val map = parseYamlMap(yaml).toMutableMap() + map.remove(name) + if (map.isEmpty()) { + map[PromptProfileSet.DEFAULT_NAME] = "" + } + return dumpYamlMap(map) + } + + fun savePromptValue(category: PromptCategory, name: String, value: String) { + val model = LlmSettingsLoader.loadSettingsModel(project) + val updated = when (category) { + PromptCategory.GENERATION -> model.copy( + generationPromptProfilesYaml = updateYamlProfile(model.generationPromptProfilesYaml, name, value) + ) + PromptCategory.COMMIT -> model.copy( + commitPromptProfilesYaml = updateYamlProfile(model.commitPromptProfilesYaml, name, value) + ) + PromptCategory.BRANCH_DIFF -> model.copy( + branchDiffPromptProfilesYaml = updateYamlProfile(model.branchDiffPromptProfilesYaml, name, value) + ) + PromptCategory.PULL_REQUEST -> model.copy(pullRequestPrompt = value) + } + LlmSettingsLoader.saveSettingsModel(project, updated) + } + + fun addPrompt(category: PromptCategory, name: String) { + val model = LlmSettingsLoader.loadSettingsModel(project) + val updated = when (category) { + PromptCategory.GENERATION -> model.copy( + generationPromptProfilesYaml = updateYamlProfile(model.generationPromptProfilesYaml, name, "") + ) + PromptCategory.COMMIT -> model.copy( + commitPromptProfilesYaml = updateYamlProfile(model.commitPromptProfilesYaml, name, "") + ) + PromptCategory.BRANCH_DIFF -> model.copy( + branchDiffPromptProfilesYaml = updateYamlProfile(model.branchDiffPromptProfilesYaml, name, "") + ) + PromptCategory.PULL_REQUEST -> return + } + LlmSettingsLoader.saveSettingsModel(project, updated) + } + + fun deletePrompt(category: PromptCategory, name: String) { + val model = LlmSettingsLoader.loadSettingsModel(project) + val updated = when (category) { + PromptCategory.GENERATION -> model.copy( + generationPromptProfilesYaml = removeYamlProfile(model.generationPromptProfilesYaml, name) + ) + PromptCategory.COMMIT -> model.copy( + commitPromptProfilesYaml = removeYamlProfile(model.commitPromptProfilesYaml, name) + ) + PromptCategory.BRANCH_DIFF -> model.copy( + branchDiffPromptProfilesYaml = removeYamlProfile(model.branchDiffPromptProfilesYaml, name) + ) + PromptCategory.PULL_REQUEST -> return + } + LlmSettingsLoader.saveSettingsModel(project, updated) + } + + fun buildPromptLeafMap(): LinkedHashMap>> { val config = runCatching { LlmSettingsLoader.loadConfig(project) }.getOrNull() - ?: return linkedMapOf("Error" to listOf(PromptLeaf("Load failed", "Unable to load .ai-test.yaml"))) + ?: return linkedMapOf("Error" to (PromptCategory.PULL_REQUEST to listOf(PromptLeaf("Load failed", "Unable to load .ai-test.yaml")))) val prompts = config.prompts return linkedMapOf( - "Test Generation" to prompts.profiles.generation.items.entries.map { PromptLeaf(it.key, it.value) }, - "Commit Message" to prompts.profiles.commitMessage.items.entries.map { PromptLeaf(it.key, it.value) }, - "Branch Diff" to prompts.profiles.branchDiffSummary.items.entries.map { PromptLeaf(it.key, it.value) }, - "Pull Request" to listOf(PromptLeaf("default", prompts.pullRequest)) + "Test Generation" to (PromptCategory.GENERATION to prompts.profiles.generation.items.entries.map { PromptLeaf(it.key, it.value) }), + "Commit Message" to (PromptCategory.COMMIT to prompts.profiles.commitMessage.items.entries.map { PromptLeaf(it.key, it.value) }), + "Branch Diff" to (PromptCategory.BRANCH_DIFF to prompts.profiles.branchDiffSummary.items.entries.map { PromptLeaf(it.key, it.value) }), + "Pull Request" to (PromptCategory.PULL_REQUEST to listOf(PromptLeaf("default", prompts.pullRequest))) ) } fun refreshPromptTabs() { promptCategoryTabs.removeAll() val promptMap = buildPromptLeafMap() - promptMap.forEach { (category, items) -> + promptMap.forEach { (categoryName, categoryAndItems) -> + val (category, items) = categoryAndItems val promptArea = JTextArea().apply { - isEditable = false + isEditable = true lineWrap = true wrapStyleWord = true font = commonFont @@ -87,9 +188,12 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { foreground = fgColor caretColor = fgColor border = BorderFactory.createEmptyBorder(10, 10, 10, 10) - text = "Select a prompt from the list to view full content." + text = "" + } + val listModel = DefaultListModel().apply { + items.forEach { addElement(it) } } - val list = JList(items.toTypedArray()).apply { + val list = JList(listModel).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION font = commonFont background = bgColor @@ -98,22 +202,76 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { selectionBackground = Color(0x2A, 0x2A, 0x2A) selectionForeground = fgColor } + + val editorPanel = JPanel(BorderLayout()).apply { + add(styledScrollPane(promptArea), BorderLayout.CENTER) + isVisible = false + } + list.addListSelectionListener { - val selected = list.selectedValue ?: return@addListSelectionListener - promptArea.text = selected.content - promptArea.caretPosition = 0 + val selected = list.selectedValue + if (selected == null) { + promptArea.text = "" + editorPanel.isVisible = false + } else { + promptArea.text = selected.content + promptArea.caretPosition = 0 + editorPanel.isVisible = true + } + } + + val addButton = JButton("Add").apply { + addActionListener { + if (category == PromptCategory.PULL_REQUEST) return@addActionListener + val name = Messages.showInputDialog( + project, + "Enter prompt name for $categoryName", + "Add Prompt", + null + )?.trim().orEmpty() + if (name.isBlank()) return@addActionListener + addPrompt(category, name) + refreshPromptTabs() + } } + val deleteButton = JButton("Delete").apply { + addActionListener { + if (category == PromptCategory.PULL_REQUEST) return@addActionListener + val selected = list.selectedValue ?: return@addActionListener + deletePrompt(category, selected.name) + refreshPromptTabs() + } + } + val saveButton = JButton("Save").apply { + addActionListener { + val selected = list.selectedValue ?: return@addActionListener + savePromptValue(category, selected.name, promptArea.text) + refreshPromptTabs() + } + } + + val buttonRow = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)).apply { + background = bgColor + add(addButton) + add(deleteButton) + add(saveButton) + } + val split = JSplitPane(JSplitPane.VERTICAL_SPLIT).apply { topComponent = styledScrollPane(list) - bottomComponent = styledScrollPane(promptArea) - resizeWeight = 0.4 + bottomComponent = editorPanel + resizeWeight = 0.45 border = BorderFactory.createEmptyBorder() background = bgColor } - if (items.isNotEmpty()) { - list.selectedIndex = 0 + + val categoryPanel = JPanel(BorderLayout(0, 8)).apply { + background = bgColor + add(buttonRow, BorderLayout.NORTH) + add(split, BorderLayout.CENTER) } - promptCategoryTabs.addTab(category, split) + + promptCategoryTabs.addTab(categoryName, categoryPanel) } } From 5d53b626810484ecf6b4870ee90164065f374fad Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Thu, 26 Mar 2026 11:25:13 +0800 Subject: [PATCH 5/6] fix: use prompt buttons and add rename in sidebar manager --- .../ai/plugin/ContextBoxToolWindowFactory.kt | 101 +++++++++++++----- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt index 4cf21b0..1f14830 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt @@ -12,14 +12,12 @@ import java.awt.Color import java.awt.FlowLayout import java.awt.Font import javax.swing.BorderFactory -import javax.swing.DefaultListModel +import javax.swing.BoxLayout import javax.swing.JButton -import javax.swing.JList import javax.swing.JPanel import javax.swing.JSplitPane import javax.swing.JTabbedPane import javax.swing.JTextArea -import javax.swing.ListSelectionModel import javax.swing.UIManager import org.yaml.snakeyaml.DumperOptions import org.yaml.snakeyaml.Yaml @@ -111,6 +109,20 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { return dumpYamlMap(map) } + fun renameYamlProfile(yaml: String, oldName: String, newName: String): String { + val map = parseYamlMap(yaml) + if (!map.containsKey(oldName) || map.containsKey(newName)) return yaml + val renamed = linkedMapOf() + map.forEach { (key, value) -> + if (key == oldName) { + renamed[newName] = value + } else { + renamed[key] = value + } + } + return dumpYamlMap(renamed) + } + fun savePromptValue(category: PromptCategory, name: String, value: String) { val model = LlmSettingsLoader.loadSettingsModel(project) val updated = when (category) { @@ -190,34 +202,32 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { border = BorderFactory.createEmptyBorder(10, 10, 10, 10) text = "" } - val listModel = DefaultListModel().apply { - items.forEach { addElement(it) } - } - val list = JList(listModel).apply { - selectionMode = ListSelectionModel.SINGLE_SELECTION - font = commonFont + val buttonListPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) background = bgColor - foreground = fgColor border = BorderFactory.createEmptyBorder(8, 8, 8, 8) - selectionBackground = Color(0x2A, 0x2A, 0x2A) - selectionForeground = fgColor } + var selectedPromptName: String? = null val editorPanel = JPanel(BorderLayout()).apply { add(styledScrollPane(promptArea), BorderLayout.CENTER) isVisible = false } - list.addListSelectionListener { - val selected = list.selectedValue - if (selected == null) { - promptArea.text = "" - editorPanel.isVisible = false - } else { - promptArea.text = selected.content - promptArea.caretPosition = 0 - editorPanel.isVisible = true + fun selectPrompt(leaf: PromptLeaf) { + selectedPromptName = leaf.name + promptArea.text = leaf.content + promptArea.caretPosition = 0 + editorPanel.isVisible = true + } + + items.forEach { leaf -> + val button = JButton(leaf.name).apply { + alignmentX = 0.0f + horizontalAlignment = JButton.LEFT + addActionListener { selectPrompt(leaf) } } + buttonListPanel.add(button) } val addButton = JButton("Add").apply { @@ -237,15 +247,53 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { val deleteButton = JButton("Delete").apply { addActionListener { if (category == PromptCategory.PULL_REQUEST) return@addActionListener - val selected = list.selectedValue ?: return@addActionListener - deletePrompt(category, selected.name) + val selected = selectedPromptName ?: return@addActionListener + deletePrompt(category, selected) + refreshPromptTabs() + } + } + val renameButton = JButton("Rename").apply { + addActionListener { + if (category == PromptCategory.PULL_REQUEST) return@addActionListener + val selected = selectedPromptName ?: return@addActionListener + val newName = Messages.showInputDialog( + project, + "Rename prompt '$selected' to:", + "Rename Prompt", + null + )?.trim().orEmpty() + if (newName.isBlank() || newName == selected) return@addActionListener + + val model = LlmSettingsLoader.loadSettingsModel(project) + val renamedYaml = when (category) { + PromptCategory.GENERATION -> renameYamlProfile(model.generationPromptProfilesYaml, selected, newName) + PromptCategory.COMMIT -> renameYamlProfile(model.commitPromptProfilesYaml, selected, newName) + PromptCategory.BRANCH_DIFF -> renameYamlProfile(model.branchDiffPromptProfilesYaml, selected, newName) + PromptCategory.PULL_REQUEST -> return@addActionListener + } + val updated = when (category) { + PromptCategory.GENERATION -> model.copy( + generationPromptProfilesYaml = renamedYaml, + generationPromptProfileDefault = if (model.generationPromptProfileDefault == selected) newName else model.generationPromptProfileDefault + ) + PromptCategory.COMMIT -> model.copy( + commitPromptProfilesYaml = renamedYaml, + commitPromptProfileDefault = if (model.commitPromptProfileDefault == selected) newName else model.commitPromptProfileDefault + ) + PromptCategory.BRANCH_DIFF -> model.copy( + branchDiffPromptProfilesYaml = renamedYaml, + branchDiffPromptProfileDefault = if (model.branchDiffPromptProfileDefault == selected) newName else model.branchDiffPromptProfileDefault + ) + PromptCategory.PULL_REQUEST -> model + } + LlmSettingsLoader.saveSettingsModel(project, updated) refreshPromptTabs() } } val saveButton = JButton("Save").apply { addActionListener { - val selected = list.selectedValue ?: return@addActionListener - savePromptValue(category, selected.name, promptArea.text) + val selected = selectedPromptName ?: return@addActionListener + savePromptValue(category, selected, promptArea.text) refreshPromptTabs() } } @@ -254,11 +302,12 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { background = bgColor add(addButton) add(deleteButton) + add(renameButton) add(saveButton) } val split = JSplitPane(JSplitPane.VERTICAL_SPLIT).apply { - topComponent = styledScrollPane(list) + topComponent = styledScrollPane(buttonListPanel) bottomComponent = editorPanel resizeWeight = 0.45 border = BorderFactory.createEmptyBorder() From b768e83e795b4024f79268a6ad41b9c5e73c8294 Mon Sep 17 00:00:00 2001 From: Idddd <956020859@qq.com> Date: Thu, 26 Mar 2026 14:47:58 +0800 Subject: [PATCH 6/6] feat: increase configurable LLM timeout and apply request timeout --- .../kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt | 2 +- .../org/openprojectx/ai/plugin/AiTestSettingsModel.kt | 2 +- .../org/openprojectx/ai/plugin/LlmProviderFactory.kt | 2 +- .../kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt | 7 ++++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt index cef08bb..a0ccb3e 100644 --- a/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt +++ b/llm-client/src/main/kotlin/org/openprojectx/ai/plugin/llm/LlmSettings.kt @@ -3,7 +3,7 @@ package org.openprojectx.ai.plugin.llm data class LlmSettings( val provider: String, val model: String, - val timeoutSeconds: Long = 60, + val timeoutSeconds: Long = 180, val apiKey: String? = null, val endpoint: String? = null, val template: TemplateRequestConfig? = null, diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt index 87b921c..03cfd6c 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt @@ -6,7 +6,7 @@ data class AiTestSettingsModel( val llmProvider: String = "openai-compatible", val llmModel: String = "", val llmEndpoint: String = "", - val llmTimeoutSeconds: String = "60", + val llmTimeoutSeconds: String = "180", val llmApiKey: String = "", val llmApiKeyEnv: String = "", val llmTemplateEnabled: Boolean = false, diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt index 260eeb9..dab1bff 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmProviderFactory.kt @@ -31,7 +31,7 @@ object LlmProviderFactory { install(HttpTimeout) { connectTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) socketTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) -// requestTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) + requestTimeoutMillis = TimeUnit.SECONDS.toMillis(settings.timeoutSeconds) } install(Logging) { logger = Logger.DEFAULT // prints to stdout diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt index 90c67c6..d1d3261 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt @@ -49,7 +49,7 @@ object LlmSettingsLoader { llmProvider = llm.string("provider").ifBlank { "openai-compatible" }, llmModel = llm.string("model"), llmEndpoint = llm.string("endpoint"), - llmTimeoutSeconds = llm.string("timeoutSeconds").ifBlank { "60" }, + llmTimeoutSeconds = llm.string("timeoutSeconds").ifBlank { "180" }, llmApiKey = llm.string("apiKey"), llmApiKeyEnv = llm.string("apiKeyEnv"), llmTemplateEnabled = template != null, @@ -183,9 +183,9 @@ object LlmSettingsLoader { val timeoutSeconds = when (val v = llm["timeoutSeconds"]) { is Number -> v.toLong() is String -> v.trim().toLongOrNull() - null -> 60L + null -> 180L else -> error("Invalid YAML: llm.timeoutSeconds must be a number or string number") - } ?: 60L + } ?: 180L val endpoint = (llm["endpoint"] as? String)?.trim()?.takeIf { it.isNotEmpty() } @@ -302,6 +302,7 @@ object LlmSettingsLoader { endpoint: https://api.openai.com/v1/chat/completions model: gpt-4.1 apiKeyEnv: OPENAI_API_KEY + timeoutSeconds: 180 generation: defaults: