diff --git a/.changeset/jetbrains-rules-settings.md b/.changeset/jetbrains-rules-settings.md new file mode 100644 index 00000000000..a5eb8b6fb87 --- /dev/null +++ b/.changeset/jetbrains-rules-settings.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add Rules settings for instruction files and Claude Code compatibility. Fix cloud session history import failing with an HTTP 400 error. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 79e186b29d4..23443265e1b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -58,6 +58,12 @@ class KiloAppService internal constructor( val version: String? get() = info?.version + /** + * App-lifetime scope for fire-and-forget work that must outlive transient UIs such as the + * settings dialog (whose own scope is cancelled the moment it closes on OK). + */ + internal val scope: CoroutineScope get() = cs + internal val _state = MutableStateFlow(init) val state: StateFlow = _state.asStateFlow() private val _models = MutableStateFlow(ModelStateDto()) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt index e127e610da7..e70fc418447 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.settings.agents import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.rules.RulesConfigurable import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import com.intellij.ide.DataManager @@ -26,6 +27,7 @@ class AgentBehaviorConfigurable : SearchableConfigurable { KiloBundle.message("settings.agentBehavior.agents.displayName") to AgentsConfigurable.ID, KiloBundle.message("settings.agentBehavior.mcp.displayName") to McpConfigurable.ID, KiloBundle.message("settings.agentBehavior.skills.displayName") to SkillsConfigurable.ID, + KiloBundle.message("settings.agentBehavior.rules.displayName") to RulesConfigurable.ID, ).forEach { (label, id) -> panel.next(ActionLink(label) { e -> val src = e.source as? JComponent ?: return@ActionLink diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt index b0078e2f284..5d58e207556 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt @@ -5,7 +5,6 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.KiloNotifications import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.settings.base.SettingsBadge import ai.kilocode.client.settings.base.SettingsDraftPage import ai.kilocode.client.settings.base.SettingsDraftState @@ -15,7 +14,13 @@ import ai.kilocode.client.settings.base.SettingsListItem import ai.kilocode.client.settings.base.SettingsListPanel import ai.kilocode.client.settings.base.SettingsListSelection import ai.kilocode.client.settings.base.SettingsListView +import ai.kilocode.client.settings.base.SettingsContentField import ai.kilocode.client.settings.base.SettingsMessageException +import ai.kilocode.client.settings.base.SettingsPathDialog +import ai.kilocode.client.settings.base.SettingsPathDialogHandle +import ai.kilocode.client.settings.base.settingsChoosePath +import ai.kilocode.client.settings.base.settingsContentScroll +import ai.kilocode.client.settings.base.settingsEditorFileType import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.log.KiloLog @@ -34,25 +39,15 @@ import com.intellij.openapi.application.EDT import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.asContextElement import com.intellij.openapi.components.service -import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.event.DocumentEvent import com.intellij.openapi.editor.event.DocumentListener -import com.intellij.openapi.fileChooser.FileChooser import com.intellij.openapi.fileChooser.FileChooserDescriptor import com.intellij.openapi.fileTypes.FileType -import com.intellij.openapi.fileTypes.FileTypeManager -import com.intellij.openapi.fileTypes.PlainTextFileType -import com.intellij.openapi.fileTypes.UnknownFileType import com.intellij.openapi.project.DumbAwareAction -import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.ui.DialogWrapper import com.intellij.openapi.ui.Messages -import com.intellij.openapi.ui.TextFieldWithBrowseButton -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.ui.EditorTextField import com.intellij.ui.TitledSeparator import com.intellij.ui.components.JBScrollPane -import com.intellij.ui.components.JBTextField import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -83,7 +78,9 @@ internal class SkillsSettingsUi( scope: CoroutineScope, dir: String, private val choose: (JComponent) -> String? = ::chooseSkillPath, - private val input: (String, String) -> String? = ::inputSkillUrl, + private val source: (Boolean, Boolean, String) -> SettingsPathDialogHandle = { adding, path, value -> + SettingsPathDialog(sourceDialogTitle(adding, path), value, if (path) choose else null) + }, private val edit: (SkillDto, Boolean) -> SkillEditDialogHandle = ::SkillEditDialog, ) : SettingsListPanel(scope, SettingsListConfig.Equal.copy(tooltip = false)), SettingsDraftPage { private val cs = scope @@ -96,7 +93,7 @@ internal class SkillsSettingsUi( set(value) { state.draft = value } - internal val sources = SkillSourcesView(this, choose, input) + internal val sources = SkillSourcesView(this, source) init { start() @@ -323,7 +320,7 @@ private fun saved(base: SkillsDraft, draft: SkillsDraft): Boolean = base == draf internal class SkillEditDialog(private val skill: SkillDto, private val savable: Boolean) : DialogWrapper(true), SkillEditDialogHandle { private val base = initial() - private val editor = SkillEditor(base, skill.location, savable) + private val editor = SettingsContentField(base, skillFileType(skill.location, base), savable) init { title = skill.name @@ -338,12 +335,7 @@ internal class SkillEditDialog(private val skill: SkillDto, private val savable: }) } - override fun createCenterPanel(): JComponent = JBScrollPane(editor).apply { - viewportBorder = editorPad() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - preferredSize = JBUI.size(720, 520) - } + override fun createCenterPanel(): JComponent = settingsContentScroll(editor) override fun createActions() = if (savable) arrayOf(okAction, cancelAction) else arrayOf(cancelAction) @@ -352,35 +344,11 @@ internal class SkillEditDialog(private val skill: SkillDto, private val savable: private fun initial() = skill.content?.takeIf { it.isNotBlank() } ?: skill.description?.takeIf { it.isNotBlank() } ?: KiloBundle.message("settings.agentBehavior.skills.content.empty") - - private class SkillEditor(value: String, location: String, editable: Boolean) : EditorTextField( - EditorFactory.getInstance().createDocument(value), - ProjectManager.getInstance().defaultProject, - skillFileType(location, value), - false, - !editable, - ) { - init { - border = JBUI.Borders.empty() - setOneLineMode(false) - addSettingsProvider { ed -> - ed.setBorder(JBUI.Borders.empty()) - ed.scrollPane.border = JBUI.Borders.empty() - ed.scrollPane.viewportBorder = JBUI.Borders.empty() - ed.settings.isUseSoftWraps = true - ed.settings.isPaintSoftWraps = false - ed.settings.isAdditionalPageAtBottom = false - ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } - } - } } internal class SkillSourcesView( private val parent: SkillsSettingsUi, - private val choose: (JComponent) -> String?, - private val input: (String, String) -> String?, + private val source: (Boolean, Boolean, String) -> SettingsPathDialogHandle, ) : Stack(ai.kilocode.client.ui.layout.StackAxis.VERTICAL, UiStyle.Gap.sm()) { private val view = SettingsListView( KiloBundle.message("settings.agentBehavior.skills.sources.empty"), @@ -423,16 +391,17 @@ internal class SkillSourcesView( } internal fun addPath() { - val path = choose(parent)?.trim()?.takeIf { it.isNotBlank() } ?: return + val dialog = source(true, true, "") + if (!dialog.showAndGet()) return + val path = dialog.value().trim().takeIf { it.isNotBlank() } ?: return if (path in cfg.paths) return parent.updateSources(cfg.paths + path, cfg.urls) } internal fun addUrl() { - val url = input( - KiloBundle.message("settings.agentBehavior.skills.sources.addUrl.title"), - KiloBundle.message("settings.agentBehavior.skills.sources.addUrl.prompt"), - )?.trim()?.takeIf { it.isNotBlank() } ?: return + val dialog = source(true, false, "") + if (!dialog.showAndGet()) return + val url = dialog.value().trim().takeIf { it.isNotBlank() } ?: return if (url in cfg.urls) return parent.updateSources(cfg.paths, cfg.urls + url) } @@ -460,7 +429,7 @@ internal class SkillSourcesView( private fun edit(key: String) { val path = key.startsWith(PATH_PREFIX) val old = key.removePrefix(if (path) PATH_PREFIX else URL_PREFIX) - val dialog = SkillSourceDialog(old, path, choose) + val dialog = source(false, path, old) if (!dialog.showAndGet()) return val next = dialog.value().trim().takeIf { it.isNotBlank() } ?: return if (path) { @@ -507,34 +476,17 @@ internal class SkillSourcesView( } } -private class SkillSourceDialog( - value: String, - private val path: Boolean, - private val choose: (JComponent) -> String?, -) : DialogWrapper(true) { - private val field = JBTextField(value) - - init { - title = if (path) KiloBundle.message("settings.agentBehavior.skills.sources.editPath.title") - else KiloBundle.message("settings.agentBehavior.skills.sources.editUrl.title") - setOKButtonText(KiloBundle.message("common.save")) - init() - } - - override fun createCenterPanel(): JComponent { - if (!path) return field.apply { columns = SOURCE_COLUMNS } - val component = TextFieldWithBrowseButton(field.apply { columns = SOURCE_COLUMNS }) - component.addActionListener { - choose(component)?.let { field.text = it } - } - return component - } - - fun value() = field.text -} +private fun sourceDialogTitle(adding: Boolean, path: Boolean): String = KiloBundle.message( + when { + adding && path -> "settings.agentBehavior.skills.sources.addPath.title" + adding -> "settings.agentBehavior.skills.sources.addUrl.title" + path -> "settings.agentBehavior.skills.sources.editPath.title" + else -> "settings.agentBehavior.skills.sources.editUrl.title" + }, +) private fun chooseSkillPath(parent: JComponent): String? { - return FileChooser.chooseFile(skillPathDescriptor(), parent, null, null as VirtualFile?)?.path + return settingsChoosePath(parent, skillPathDescriptor()) } internal fun skillPathDescriptor() = FileChooserDescriptor(false, true, false, false, false, false).apply { @@ -542,42 +494,8 @@ internal fun skillPathDescriptor() = FileChooserDescriptor(false, true, false, f description = KiloBundle.message("settings.agentBehavior.skills.sources.addPath.prompt") } -internal fun skillFileType(location: String, content: String? = null): FileType { - val syntax = content?.syntaxName() - val name = syntax ?: location.substringAfterLast('/').substringAfterLast('\\').ifBlank { SKILL_FILE } - val type = FileTypeManager.getInstance().getFileTypeByFileName(name) - if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE - return type -} - -private fun String.syntaxName(): String? { - val text = trimStart() - if (text.isBlank()) return null - if (text.looksHtml()) return "index.html" - if (text.looksMarkdown()) return SKILL_FILE - return null -} - -private fun String.looksHtml() = contains(Regex("^\\s*( - line.matches(Regex("\\s{0,3}(#{1,6}\\s+.+|[-*+]\\s+.+|\\d+\\.\\s+.+|```.*|>\\s+.+)")) || - line.contains(Regex("(`[^`]+`|\\[[^]]+][(][^)]+[)])")) -} - -private fun inputSkillUrl(title: String, prompt: String): String? = Messages.showInputDialog( - prompt, - title, - Messages.getQuestionIcon(), -) - -private fun editorPad() = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), -) +internal fun skillFileType(location: String, content: String? = null): FileType = + settingsEditorFileType(location.ifBlank { SKILL_FILE }, content) -private const val SOURCE_COLUMNS = 60 private const val SKILL_FILE = "SKILL.md" private const val SKILL_LOAD_TIMEOUT_MS = 10_000L diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsContentEditor.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsContentEditor.kt new file mode 100644 index 00000000000..2ce0f6a0f32 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsContentEditor.kt @@ -0,0 +1,85 @@ +package ai.kilocode.client.settings.base + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.UnknownFileType +import com.intellij.openapi.project.ProjectManager +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import javax.swing.ScrollPaneConstants + +/** + * Shared code-editor primitives for settings dialogs (skill content, instruction files). + * + * Keeps the tuned [EditorTextField] configuration, scroll chrome, and content-aware file-type + * detection in one place so pages don't each hand-roll their own editor. + */ +internal class SettingsContentField( + content: String, + fileType: FileType, + editable: Boolean, +) : EditorTextField( + EditorFactory.getInstance().createDocument(content), + ProjectManager.getInstance().defaultProject, + fileType, + !editable, + false, +) { + init { + border = JBUI.Borders.empty() + setOneLineMode(false) + addSettingsProvider { ed -> + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.settings.isUseSoftWraps = true + ed.settings.isPaintSoftWraps = false + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + } +} + +internal fun settingsContentScroll(field: SettingsContentField) = JBScrollPane(field).apply { + viewportBorder = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + ) + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + preferredSize = JBUI.size(720, 520) +} + +/** + * Resolve a [FileType] for editor highlighting. Content syntax wins over the file name so + * extension-less locations still highlight correctly; unknown types fall back to plain text. + */ +internal fun settingsEditorFileType(name: String, content: String? = null): FileType { + val syntax = content?.syntaxName() + val fileName = syntax ?: name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file.txt" } + val type = FileTypeManager.getInstance().getFileTypeByFileName(fileName) + if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE + return type +} + +private fun String.syntaxName(): String? { + val text = trimStart() + if (text.isBlank()) return null + if (text.looksHtml()) return "index.html" + if (text.looksMarkdown()) return "content.md" + return null +} + +private fun String.looksHtml() = contains(Regex("^\\s*( + line.matches(Regex("\\s{0,3}(#{1,6}\\s+.+|[-*+]\\s+.+|\\d+\\.\\s+.+|```.*|>\\s+.+)")) || + line.contains(Regex("(`[^`]+`|\\[[^]]+][(][^)]+[)])")) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt index 0246af99dd2..dcad8c68633 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt @@ -85,7 +85,10 @@ internal fun settingsListCellBounds( @Suppress("UNCHECKED_CAST") val renderer = list.cellRenderer as? ListCellRenderer ?: return emptyMap() val cell = list.getCellBounds(index, index) ?: return emptyMap() - val comp = renderer.getListCellRendererComponent(list, model.getElementAt(index), index, selected, list.hasFocus()) + // Render as focused so the action-cell geometry is available for hit-testing even when the + // list is not the focus owner. Painting still hides the cells on an unfocused list; this only + // resolves click targets and keeps them stable regardless of focus. + val comp = renderer.getListCellRendererComponent(list, model.getElementAt(index), index, selected, true) comp.setBounds(0, 0, cell.width, cell.height) settingsListLayout(comp) val out = linkedMapOf() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt index 4174ba902e3..bd6affa2b60 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt @@ -68,7 +68,7 @@ internal class SettingsListRenderer( selected: Boolean, focused: Boolean, ): JPanel { - val active = selected && (list.hasFocus() || (list as? SettingsListActive)?.active() == true) + val active = selected && (focused || list.hasFocus() || (list as? SettingsListActive)?.active() == true) val fg = UIUtil.getListForeground(active, active || focused) val weak = if (active) fg else UiStyle.Colors.weak() val current = model.items.getOrNull(index) @@ -99,7 +99,9 @@ internal class SettingsListRenderer( } desc.foreground = weak - syncCells(value, selected && list.isEnabled, list.isEnabled) + // In-place action buttons follow the selection highlight: only when the selection is + // visible (list focused, or an owned popup is active). An unfocused list hides them. + syncCells(value, active && list.isEnabled, list.isEnabled) top.invalidate() return this } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt index 49506f2d29f..f8115269354 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt @@ -240,7 +240,7 @@ internal class SettingsListView( return } val height = rows.indices.maxOfOrNull { idx -> - list.cellRenderer.getListCellRendererComponent(list, rows[idx], idx, true, list.hasFocus()).preferredSize.height + list.cellRenderer.getListCellRendererComponent(list, rows[idx], idx, true, true).preferredSize.height } ?: -1 if (list.fixedCellHeight == height) return list.fixedCellHeight = height diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPathDialog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPathDialog.kt new file mode 100644 index 00000000000..9d330951ed5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPathDialog.kt @@ -0,0 +1,44 @@ +package ai.kilocode.client.settings.base + +import com.intellij.CommonBundle +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBTextField +import javax.swing.JComponent + +/** Testable handle over [SettingsPathDialog] so callers can stub the modal dialog in tests. */ +internal interface SettingsPathDialogHandle { + fun showAndGet(): Boolean + fun value(): String +} + +/** + * Single-line text entry dialog for a path, glob, or URL. When [browse] is provided the field gains + * the standard "..." file-chooser button; otherwise it is a plain text field. Confirms with "OK" + * (the value is persisted later by the owning settings page) and focuses the field on open. + */ +internal class SettingsPathDialog( + title: String, + value: String = "", + private val browse: ((JComponent) -> String?)? = null, +) : DialogWrapper(true), SettingsPathDialogHandle { + private val field = JBTextField(value) + + init { + this.title = title + setOKButtonText(CommonBundle.getOkButtonText()) + init() + } + + override fun createCenterPanel(): JComponent { + field.columns = COLUMNS + return browse?.let { settingsPathInput(field, it) } ?: field + } + + override fun getPreferredFocusedComponent(): JComponent = field + + override fun value(): String = field.text + + private companion object { + const val COLUMNS = 60 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPathInput.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPathInput.kt new file mode 100644 index 00000000000..f123a32d08a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsPathInput.kt @@ -0,0 +1,21 @@ +package ai.kilocode.client.settings.base + +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptor +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.components.JBTextField +import javax.swing.JComponent + +internal fun settingsPathInput( + field: JBTextField, + choose: (JComponent) -> String?, +): TextFieldWithBrowseButton = TextFieldWithBrowseButton(field).apply { + addActionListener { + choose(this)?.let { field.text = it } + } +} + +internal fun settingsChoosePath(parent: JComponent, descriptor: FileChooserDescriptor): String? { + return FileChooser.chooseFile(descriptor, parent, null, null as VirtualFile?)?.path +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/rules/RulesConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/rules/RulesConfigurable.kt new file mode 100644 index 00000000000..e5df92acbf0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/rules/RulesConfigurable.kt @@ -0,0 +1,20 @@ +package ai.kilocode.client.settings.rules + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.DraftReadyConfigurable +import kotlinx.coroutines.CoroutineScope +import javax.swing.JComponent + +class RulesConfigurable : DraftReadyConfigurable() { + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.agentBehavior.rules.displayName") + + override fun create(cs: CoroutineScope): JComponent = RulesSettingsUi(cs, root = project?.basePath) + + override fun scrollReadyShell() = false + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.agentBehavior.rules" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/rules/RulesSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/rules/RulesSettingsState.kt new file mode 100644 index 00000000000..a489ccf5fd2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/rules/RulesSettingsState.kt @@ -0,0 +1,40 @@ +package ai.kilocode.client.settings.rules + +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.ConfigPatchDto + +internal data class RulesDraft( + val instructions: List = emptyList(), + val compat: Boolean = false, + /** Staged file-content edits, keyed by instruction path, written to disk on apply. */ + val edited: Map = emptyMap(), +) + +internal data class RulesChange( + val config: ConfigPatchDto? = null, + val compat: Boolean? = null, + val edited: Map = emptyMap(), +) + +internal fun rulesDraft(config: ConfigDto?, compat: Boolean): RulesDraft = RulesDraft( + instructions = config?.instructions ?: emptyList(), + compat = compat, +) + +internal fun configPatch(from: RulesDraft, to: RulesDraft): ConfigPatchDto? { + if (from.instructions == to.instructions) return null + return ConfigPatchDto(instructions = to.instructions) +} + +internal fun rulesChange(from: RulesDraft, to: RulesDraft): RulesChange? { + val config = configPatch(from, to) + val compat = to.compat.takeIf { it != from.compat } + val edited = to.edited + if (config == null && compat == null && edited.isEmpty()) return null + return RulesChange(config, compat, edited) +} + +// Structural equality: the baseline always carries an empty [RulesDraft.edited], so any staged +// content edit makes the draft unequal (and therefore modified). This symmetric form is required by +// SettingsDraftState.complete, which also uses it to compare returned/target drafts. +internal fun savedMatches(base: RulesDraft, draft: RulesDraft): Boolean = base == draft diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/rules/RulesSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/rules/RulesSettingsUi.kt new file mode 100644 index 00000000000..89bc6e57e16 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/rules/RulesSettingsUi.kt @@ -0,0 +1,367 @@ +package ai.kilocode.client.settings.rules + +import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.app.KiloAgentBehaviorService +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.SettingsContentField +import ai.kilocode.client.settings.base.SettingsDraftPage +import ai.kilocode.client.settings.base.SettingsDraftState +import ai.kilocode.client.settings.base.SettingsListCell +import ai.kilocode.client.settings.base.SettingsListConfig +import ai.kilocode.client.settings.base.SettingsListItem +import ai.kilocode.client.settings.base.SettingsListPanel +import ai.kilocode.client.settings.base.SettingsListSelection +import ai.kilocode.client.settings.base.SettingsRow +import ai.kilocode.client.settings.base.SettingsToggle +import ai.kilocode.client.settings.base.SettingsToolbarAction +import ai.kilocode.client.settings.base.SettingsPathDialog +import ai.kilocode.client.settings.base.settingsChoosePath +import ai.kilocode.client.settings.base.settingsContentScroll +import ai.kilocode.client.settings.base.settingsEditorFileType +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.KiloAppStateDto +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.service +import com.intellij.openapi.fileChooser.FileChooserDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.ui.TitledSeparator +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.BorderLayout +import java.nio.charset.StandardCharsets +import java.nio.file.InvalidPathException +import java.nio.file.Path +import javax.swing.JComponent +import javax.swing.ScrollPaneConstants + +private val edt = Dispatchers.EDT + ModalityState.any().asContextElement() + +internal class RulesSettingsUi( + scope: CoroutineScope, + private val root: String? = null, + private val choose: (JComponent) -> String? = ::chooseRulePath, + private val input: () -> String? = { promptRulePath(choose) }, + private val read: (String) -> String? = { path -> readInstruction(root, path) }, + private val write: (String, String) -> Boolean = { path, text -> writeInstruction(root, path, text) }, + private val editor: (String, String) -> RuleContentDialogHandle = { title, content -> InstructionEditDialog(title, content) }, + private val app: KiloAppService = service(), + private val workspaces: KiloWorkspaceService = service(), + private val agent: KiloAgentBehaviorService = service(), +) : SettingsListPanel(scope, SettingsListConfig.Equal.copy(tooltip = false)), SettingsDraftPage { + private val cs = scope + private val state = SettingsDraftState(rulesDraft(app.state.value.config, false), ::savedMatches) + private val draft get() = state.draft + private var closed = false + internal val footer = RulesFooterView { value -> updateCompat(value) } + + init { + start() + setCenter(ruleScroll()) + content.add(footer, BorderLayout.SOUTH) + reload() + } + + override suspend fun fetch(): List { + val compat = agent.claudeCodeCompat() + return withContext(edt) { + state.accept(rulesDraft(app.state.value.config, compat)) + footer.refresh(draft.compat) + rows() + } + } + + override fun onCell(key: String, cellId: String) { + when (cellId) { + OPEN_CELL -> open(key) + EDIT_CELL -> editFile(key) + DELETE_CELL -> remove(key) + } + } + + override fun extraActions(): List = listOf( + SettingsToolbarAction( + KiloBundle.message("settings.rules.files.add"), + KiloBundle.message("settings.rules.files.add.description"), + AllIcons.General.Add, + { !busy }, + ) { addFile() }, + ) + + override fun showRefresh(): Boolean = false + + override fun searchPlaceholder() = KiloBundle.message("settings.rules.files.search") + + override fun emptyText() = KiloBundle.message("settings.rules.files.empty") + + override fun modified(): Boolean = state.modified() + + override fun resetDraft() { + state.reset() + footer.refresh(draft.compat) + view.update(rows()) + clearProgress() + } + + override fun applyDraft() { + val change = rulesChange(state.baseline, draft) ?: return + val token = state.start() ?: return + val target = token.target + showProgress(KiloBundle.message("settings.rules.save.pending")) + setBusy(true) + app.scope.launch { + val wrote = withContext(edt) { target.edited.all { (path, text) -> write(path, text) } } + val next = when { + !wrote -> null + change.config != null -> app.updateConfig(change.config) + else -> app.state.value + } + val ok = next != null && (change.compat == null || agent.setClaudeCodeCompat(change.compat) == change.compat) + withContext(edt) { finish(token, target, next.takeIf { ok }) } + } + } + + @RequiresEdt + override fun dispose() { + closed = true + super.dispose() + } + + @RequiresEdt + private fun finish(token: ai.kilocode.client.settings.base.SettingsDraftSave, target: RulesDraft, next: KiloAppStateDto?) { + if (closed) { + if (next != null) KiloNotifications.info(KiloBundle.message("settings.rules.saved.notification")) + else KiloNotifications.error(KiloBundle.message("settings.rules.save.failed")) + return + } + if (next != null) { + state.complete(token, rulesDraft(next.config, target.compat)) + LOG.info("rules settings apply succeeded") + } else { + state.fail(token, KiloBundle.message("settings.rules.save.failed")) + showError(KiloBundle.message("settings.rules.save.failed")) + LOG.warn("rules settings apply failed") + } + footer.refresh(draft.compat) + view.update(rows()) + if (next != null) clearProgress() + setBusy(false) + } + + internal fun addFile() { + val value = input()?.trim()?.takeIf { it.isNotBlank() } ?: return + if (value in draft.instructions) { + view.select(value) + return + } + state.update { copy(instructions = instructions + value) } + view.update(rows(), SettingsListSelection.Key(value)) + } + + private fun editFile(path: String) { + val content = draft.edited[path] ?: read(path) + if (content == null) { + KiloNotifications.info(KiloBundle.message("settings.rules.files.cannotEdit")) + return + } + val dialog = editor(path, content) + if (!dialog.showAndGet()) return + state.update { copy(edited = edited + (path to dialog.content())) } + view.update(rows(), SettingsListSelection.Key(path)) + } + + private fun remove(path: String) { + val result = Messages.showYesNoDialog( + KiloBundle.message("settings.rules.files.delete.message", path), + KiloBundle.message("settings.rules.files.delete.title"), + KiloBundle.message("common.delete"), + Messages.getCancelButton(), + Messages.getQuestionIcon(), + ) + if (result != Messages.YES) return + state.update { copy(instructions = instructions - path, edited = edited - path) } + view.update(rows(), selectionIndex()) + } + + private fun open(path: String) { + val abs = resolveInstructionPath(root, path) + if (abs == null) { + KiloNotifications.error(KiloBundle.message("settings.rules.files.openInEditor.failed")) + return + } + showProgress(KiloBundle.message("settings.rules.files.openInEditor.pending")) + cs.launch { + val opened = workspaces.openFile(abs) + withContext(edt) { + if (closed) return@withContext + clearProgress() + if (!opened) KiloNotifications.error(KiloBundle.message("settings.rules.files.openInEditor.failed")) + } + } + } + + private fun updateCompat(value: Boolean) { + state.update { copy(compat = value) } + footer.refresh(draft.compat) + } + + private fun ruleScroll() = JBScrollPane(view).apply { + border = null + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + + private fun rows(): List = draft.instructions.map { item(it) } + + private fun item(value: String) = object : SettingsListItem { + override val key = value + override val title = value + override val doubleClick = EDIT_CELL + override val cells = listOf( + SettingsListCell( + OPEN_CELL, + KiloBundle.message("settings.rules.files.openInEditor"), + primary = true, + ), + SettingsListCell( + EDIT_CELL, + KiloBundle.message("settings.agentBehavior.edit"), + ), + SettingsListCell( + DELETE_CELL, + KiloBundle.message("common.delete"), + icon = AllIcons.Actions.GC, + iconOnly = true, + ), + ) + } + + private companion object { + const val OPEN_CELL = "open" + const val EDIT_CELL = "edit" + const val DELETE_CELL = "delete" + val LOG = KiloLog.create(RulesSettingsUi::class.java) + } +} + +internal class RulesFooterView( + private val update: (Boolean) -> Unit, +) : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()) { + private val compat = SettingsToggle { value -> update(value) } + + init { + border = JBUI.Borders.empty(UiStyle.Gap.pad(), 0, 0, UiStyle.Gap.xl()) + next(TitledSeparator(KiloBundle.message("settings.rules.claude.heading"))) + next(SettingsRow( + KiloBundle.message("settings.rules.claude.title"), + KiloBundle.message("settings.rules.claude.description"), + compat, + )) + } + + @RequiresEdt + fun refresh(value: Boolean) { + compat.isSelected = value + } +} + +internal interface RuleContentDialogHandle { + fun showAndGet(): Boolean + fun content(): String +} + +/** In-dialog content editor for an instruction file, mirroring the Skills skill editor. */ +internal class InstructionEditDialog( + private val heading: String, + content: String, +) : DialogWrapper(true), RuleContentDialogHandle { + private val base = content + private val field = SettingsContentField(base, settingsEditorFileType(heading, base), true) + + init { + title = heading + setOKButtonText(com.intellij.CommonBundle.getOkButtonText()) + init() + isOKActionEnabled = false + field.document.addDocumentListener(object : com.intellij.openapi.editor.event.DocumentListener { + override fun documentChanged(event: com.intellij.openapi.editor.event.DocumentEvent) { + isOKActionEnabled = field.text != base + } + }) + } + + override fun createCenterPanel(): JComponent = settingsContentScroll(field) + + override fun content() = field.text +} + +private fun chooseRulePath(parent: JComponent): String? = settingsChoosePath(parent, rulePathDescriptor()) + +private fun promptRulePath(choose: (JComponent) -> String?): String? { + val dialog = SettingsPathDialog(KiloBundle.message("settings.rules.files.input.title"), browse = choose) + return if (dialog.showAndGet()) dialog.value() else null +} + +internal fun rulePathDescriptor() = FileChooserDescriptor(true, false, false, false, false, false).apply { + title = KiloBundle.message("settings.rules.files.input.title") + description = KiloBundle.message("settings.rules.files.input.prompt") +} + +private fun resolveInstructionPath(root: String?, path: String): String? = try { + val nio = Path.of(path.trim()) + when { + nio.isAbsolute -> nio.normalize().toString() + root != null -> Path.of(root).resolve(nio).normalize().toString() + else -> null + } +} catch (e: InvalidPathException) { + null +} + +@RequiresEdt +private fun readInstruction(root: String?, path: String): String? { + val abs = resolveInstructionPath(root, path) ?: return null + val vf = LocalFileSystem.getInstance().findFileByPath(abs) + ?: LocalFileSystem.getInstance().refreshAndFindFileByPath(abs) + ?: return null + if (vf.isDirectory) return null + return String(vf.contentsToByteArray(), StandardCharsets.UTF_8) +} + +@RequiresEdt +private fun writeInstruction(root: String?, path: String, text: String): Boolean { + val abs = resolveInstructionPath(root, path) ?: return false + var ok = false + WriteCommandAction.runWriteCommandAction(null as Project?) { + val nio = Path.of(abs) + val lfs = LocalFileSystem.getInstance() + val target = lfs.refreshAndFindFileByPath(abs) ?: run { + val parent = nio.parent ?: return@runWriteCommandAction + val dir = VfsUtil.createDirectoryIfMissing(parent.toString()) ?: return@runWriteCommandAction + dir.createChildData(RulesSettingsUi::class.java, nio.fileName.toString()) + } + VfsUtil.saveText(target, text) + ok = true + } + return ok +} + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index f05d7f30910..e5e67c902ca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -99,6 +99,13 @@ bundle="messages.KiloBundle" key="settings.agentBehavior.skills.displayName"/> + + url }) + val panel = panel(source = { _, isPath, _ -> FakeSourceDialog(if (isPath) path else url) }) flushUntil { rows(panel).size == 3 } edt { panel.sources.addPath(); true } @@ -313,7 +314,7 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { val path = "/extra/skills" val url = "https://skills.test/index.json" val extra = "$path/extra/SKILL.md" - val panel = panel(choose = { path }, input = { _, _ -> url }) + val panel = panel(source = { _, isPath, _ -> FakeSourceDialog(if (isPath) path else url) }) appRpc.configUpdateReturnStale = true appRpc.afterConfig = { agentRpc.skills = agentRpc.skills + SkillDto("extra", "Extra skill", extra) } flushUntil { rows(panel).size == 3 } @@ -335,7 +336,7 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { fun `test blocked reload completes apply with warning`() { val path = "/extra/skills" - val panel = panel(choose = { path }) + val panel = panel(source = { _, isPath, _ -> FakeSourceDialog(if (isPath) path else null) }) agentRpc.reloadSkillResult = false flushUntil { rows(panel).size == 3 } @@ -365,7 +366,7 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { fun `test source reset discards staged changes`() { val path = "/extra/skills" - val panel = panel(choose = { path }) + val panel = panel(source = { _, isPath, _ -> FakeSourceDialog(if (isPath) path else null) }) flushUntil { rows(panel).size == 3 } edt { panel.sources.addPath(); true } @@ -460,11 +461,11 @@ class SkillsSettingsUiTest : BasePlatformTestCase() { private fun panel( choose: (JComponent) -> String? = { null }, - input: (String, String) -> String? = { _, _ -> null }, + source: (Boolean, Boolean, String) -> SettingsPathDialogHandle = { _, _, _ -> FakeSourceDialog(null) }, edit: (SkillDto, Boolean) -> SkillEditDialogHandle = { _, _ -> FakeSkillDialog("# Plan\nUse steps") }, ): SkillsSettingsUi { install() - val panel = edt { SkillsSettingsUi(scope!!, DIR, choose, input, edit) } + val panel = edt { SkillsSettingsUi(scope!!, DIR, choose, source, edit) } ui = panel edt { panel.reload(); true } return panel @@ -613,3 +614,8 @@ private class FakeSkillDialog(private val text: String, private val show: () -> } override fun content() = text } + +private class FakeSourceDialog(private val text: String?) : SettingsPathDialogHandle { + override fun showAndGet() = text != null + override fun value() = text ?: "" +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index a6e1fcabf18..bc08ff92498 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -253,6 +253,34 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test in-place action cells are hidden on unfocused selected row`() { + edt { + val row = item("with", "Alpha", "Description", SettingsListCell("edit", "Edit")) + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = SettingsListRenderer(model, SettingsListConfig.Equal) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + assertTrue(actionCells(renderer).none { it.isVisible }) + + renderer.getListCellRendererComponent(list, row, 0, true, true) + assertEquals(listOf("edit"), actionCells(renderer).filter { it.isVisible }.map { it.cellId }) + } + } + + fun `test always visible action cells stay on unfocused row`() { + edt { + val row = item("with", "Alpha", "Description", SettingsListCell("level", "Allow", alwaysVisible = true)) + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = SettingsListRenderer(model, SettingsListConfig.Equal) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + + assertEquals(listOf("level"), actionCells(renderer).filter { it.isVisible }.map { it.cellId }) + } + } + fun `test active popup paints selected row as active without focus`() { edt { val row = item("with", "Alpha", "Description") @@ -368,6 +396,9 @@ class SettingsListViewTest : BasePlatformTestCase() { UIUtil.dispatchAllInvocationEvents() } + private fun actionCells(root: java.awt.Component): List = + components(root).filterIsInstance() + private fun components(root: java.awt.Component): List { val out = mutableListOf() fun visit(item: java.awt.Component) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsPathDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsPathDialogTest.kt new file mode 100644 index 00000000000..e9d4f30dfd5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsPathDialogTest.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.settings.base + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBTextField + +class SettingsPathDialogTest : BasePlatformTestCase() { + fun `test browse input wraps the field and writes the chosen path`() { + val field = JBTextField() + val input = settingsPathInput(field) { "/chosen" } + assertSame(field, input.childComponent) + @Suppress("DEPRECATION") + input.button.doClick() + assertEquals("/chosen", field.text) + } + + fun `test browse variant dialog focuses the field`() { + val dialog = SettingsPathDialog("Add Instruction File", "", browse = { "/chosen" }) + try { + assertTrue(dialog.preferredFocusedComponent is JBTextField) + } finally { + dialog.close(0) + } + } + + fun `test plain variant dialog focuses the field and exposes its value`() { + val dialog = SettingsPathDialog("Add Skill URL", "https://x") + try { + assertTrue(dialog.preferredFocusedComponent is JBTextField) + assertEquals("https://x", dialog.value()) + } finally { + dialog.close(0) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 84c823e1519..6654fbc536b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -1215,7 +1215,8 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { private fun render(renderer: SettingsListRenderer, list: JBList, row: ProviderListRow, selected: Boolean) { @Suppress("UNCHECKED_CAST") - renderer.getListCellRendererComponent(list as JList, row, 0, selected, false) + // A selected row exposes its in-place actions only when the selection is visible (focused). + renderer.getListCellRendererComponent(list as JList, row, 0, selected, selected) } private fun actionTexts(renderer: SettingsListRenderer): List = components(renderer) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/rules/RulesSettingsStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/rules/RulesSettingsStateTest.kt new file mode 100644 index 00000000000..c68d7d42326 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/rules/RulesSettingsStateTest.kt @@ -0,0 +1,64 @@ +package ai.kilocode.client.settings.rules + +import ai.kilocode.rpc.dto.ConfigDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RulesSettingsStateTest { + @Test + fun `draft reads instructions and compat`() { + val draft = rulesDraft(ConfigDto(instructions = listOf("./RULES.md")), true) + + assertEquals(listOf("./RULES.md"), draft.instructions) + assertTrue(draft.compat) + } + + @Test + fun `unchanged instructions emit no config patch`() { + val draft = RulesDraft(instructions = listOf("./RULES.md"), compat = true) + + assertNull(configPatch(draft, draft)) + } + + @Test + fun `changed instructions emit full list`() { + val from = RulesDraft(instructions = listOf("./RULES.md")) + val to = RulesDraft(instructions = listOf("./RULES.md", "./TEAM.md")) + + assertEquals(listOf("./RULES.md", "./TEAM.md"), configPatch(from, to)?.instructions) + } + + @Test + fun `empty instructions list is emitted`() { + val from = RulesDraft(instructions = listOf("./RULES.md")) + val to = RulesDraft(instructions = emptyList()) + + assertEquals(emptyList(), configPatch(from, to)?.instructions) + } + + @Test + fun `saved match compares instructions compat and staged edits`() { + assertTrue(savedMatches(RulesDraft(listOf("a"), true), RulesDraft(listOf("a"), true))) + assertFalse(savedMatches(RulesDraft(listOf("a"), true), RulesDraft(listOf("b"), true))) + assertFalse(savedMatches(RulesDraft(listOf("a"), true), RulesDraft(listOf("a"), false))) + assertFalse(savedMatches(RulesDraft(listOf("a"), true), RulesDraft(listOf("a"), true, mapOf("a" to "x")))) + } + + @Test + fun `change captures config compat and edits`() { + val from = RulesDraft(listOf("a"), false) + assertNull(rulesChange(from, from)) + + val edited = rulesChange(from, from.copy(edited = mapOf("a" to "x"))) + assertNull(edited?.config) + assertNull(edited?.compat) + assertEquals(mapOf("a" to "x"), edited?.edited) + + val both = rulesChange(from, RulesDraft(listOf("a", "b"), true)) + assertEquals(listOf("a", "b"), both?.config?.instructions) + assertEquals(true, both?.compat) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/rules/RulesSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/rules/RulesSettingsUiTest.kt new file mode 100644 index 00000000000..70097d24043 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/rules/RulesSettingsUiTest.kt @@ -0,0 +1,401 @@ +package ai.kilocode.client.settings.rules + +import ai.kilocode.client.app.KiloAgentBehaviorService +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.settings.base.SettingsListItem +import ai.kilocode.client.settings.base.SettingsToggle +import ai.kilocode.client.settings.base.settingsListCellBounds +import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.testing.TestCoroutines +import ai.kilocode.client.testing.fire +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import com.intellij.openapi.actionSystem.impl.ActionButton +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.TestDialog +import com.intellij.openapi.ui.TestDialogManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.TitledSeparator +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Container +import java.awt.Dimension +import java.awt.Point +import java.awt.event.InputEvent +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.ScrollPaneConstants + +class RulesSettingsUiTest : BasePlatformTestCase() { + private lateinit var appCoroutines: TestCoroutines + private lateinit var uiCoroutines: TestCoroutines + private lateinit var rpc: FakeAppRpcApi + private lateinit var workspaceRpc: FakeWorkspaceRpcApi + private lateinit var agentRpc: FakeAgentBehaviorRpcApi + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService + private lateinit var agent: KiloAgentBehaviorService + private val writes = mutableListOf>() + private var ui: RulesSettingsUi? = null + + override fun tearDown() { + try { + TestDialogManager.setTestDialog(TestDialog.DEFAULT) + val panel = ui + if (panel != null) edt { panel.dispose() } + ui = null + if (::uiCoroutines.isInitialized) uiCoroutines.close(::pump) + if (::appCoroutines.isInitialized) appCoroutines.close(::pump) + } finally { + super.tearDown() + } + } + + fun `test rules list is center with claude footer south and right padding`() { + val panel = panel() + flushUntil { rows(panel).size == 1 } + + edt { + val pane = scrollFor(panel, rulesList(panel)) + val layout = panel.content.layout as BorderLayout + assertSame(pane, layout.getLayoutComponent(BorderLayout.CENTER)) + assertSame(panel.footer, layout.getLayoutComponent(BorderLayout.SOUTH)) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, pane.horizontalScrollBarPolicy) + assertTrue(panel.footer.insets.right > 0) + assertTrue(components(panel.footer).filterIsInstance().any { it.text == "Claude Code Compatibility" }) + } + } + + fun `test toolbar has add action and no refresh action`() { + val panel = panel() + flushUntil { rows(panel).size == 1 } + + edt { + val texts = components(panel).filterIsInstance().mapNotNull { it.presentation.text } + assertTrue(texts.any { it == "Add file" }) + assertFalse(texts.any { it.contains("Refresh", ignoreCase = true) }) + } + } + + fun `test rows use standard action cells`() { + val panel = panel() + flushUntil { rows(panel).size == 1 } + + edt { + val row = rows(panel).single() + assertEquals("./RULES.md", row.title) + assertEquals("edit", row.doubleClick) + assertEquals(listOf("open", "edit", "delete"), row.cells.map { it.id }) + assertTrue(row.cells.single { it.id == "open" }.primary) + assertEquals("Edit", row.cells.single { it.id == "edit" }.label) + assertTrue(row.cells.single { it.id == "delete" }.iconOnly) + } + } + + fun `test add file stages instructions patch only`() { + val panel = panel(input = { "./TEAM.md" }) + flushUntil { rows(panel).size == 1 } + + edt { + panel.addFile() + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() && !edt { panel.modified() } } + assertEquals(listOf("./RULES.md", "./TEAM.md"), rpc.configPatches.single().instructions) + assertTrue(agentRpc.compatSaves.isEmpty()) + assertTrue(writes.isEmpty()) + } + + fun `test edit opens content editor and writes file on apply without config patch`() { + val edited = mutableListOf>() + val panel = panel( + read = { path -> "# $path" }, + editor = { title, content -> + edited += title to content + FakeContentDialog("# edited") + }, + ) + flushUntil { rows(panel).size == 1 } + + doubleClick(rulesList(panel), panel, "./RULES.md") + assertEquals(listOf("./RULES.md" to "# ./RULES.md"), edited) + assertTrue(edt { panel.modified() }) + assertTrue(rpc.configPatches.isEmpty()) + + edt { panel.applyDraft() } + flushUntil { writes.isNotEmpty() && !edt { panel.modified() } } + assertEquals(listOf("./RULES.md" to "# edited"), writes) + assertTrue(rpc.configPatches.isEmpty()) + } + + fun `test reopening staged edit shows draft content`() { + val seen = mutableListOf() + val panel = panel( + read = { "# disk" }, + editor = { _, content -> + seen += content + FakeContentDialog("# draft") + }, + ) + flushUntil { rows(panel).size == 1 } + + doubleClick(rulesList(panel), panel, "./RULES.md") + doubleClick(rulesList(panel), panel, "./RULES.md") + + assertEquals(listOf("# disk", "# draft"), seen) + } + + fun `test edit is a no-op when file content is unavailable`() { + var opened = false + val panel = panel(read = { null }, editor = { _, _ -> opened = true; FakeContentDialog("x") }) + flushUntil { rows(panel).size == 1 } + + doubleClick(rulesList(panel), panel, "./RULES.md") + + assertFalse(opened) + assertFalse(edt { panel.modified() }) + } + + fun `test open in editor action opens instruction file`() { + val panel = panel(root = "/repo") + flushUntil { rows(panel).size == 1 } + + click(rulesList(panel), panel, "./RULES.md", "open") + + flushUntil { workspaceRpc.openedFiles.size == 1 } + assertEquals(FakeWorkspaceRpcApi.Opened("/repo/RULES.md", null, null), workspaceRpc.openedFiles.single()) + } + + fun `test delete action stages removal until apply`() { + val panel = panel() + flushUntil { rows(panel).size == 1 } + TestDialogManager.setTestDialog(TestDialog.YES) + + click(rulesList(panel), panel, "./RULES.md", "delete") + assertTrue(edt { rows(panel).isEmpty() }) + edt { panel.applyDraft() } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(emptyList(), rpc.configPatches.single().instructions) + } + + fun `test delete action requires confirmation`() { + val panel = panel() + flushUntil { rows(panel).size == 1 } + TestDialogManager.setTestDialog { Messages.NO } + + click(rulesList(panel), panel, "./RULES.md", "delete") + + assertEquals(listOf("./RULES.md"), edt { rows(panel).map { it.key } }) + assertFalse(edt { panel.modified() }) + } + + fun `test toggling compat saves compat only`() { + val panel = panel() + flushUntil { rows(panel).size == 1 } + + edt { + toggle(panel).doClick() + panel.applyDraft() + } + + flushUntil { agentRpc.compatSaves.isNotEmpty() && !edt { panel.modified() } } + assertEquals(listOf(false), agentRpc.compatSaves) + assertTrue(rpc.configPatches.isEmpty()) + } + + fun `test save survives dialog dispose on ok`() { + val panel = panel(input = { "./TEAM.md" }) + flushUntil { rows(panel).size == 1 } + + edt { + panel.addFile() + panel.applyDraft() + // Emulate the platform disposing the configurable immediately after apply() on OK. + panel.dispose() + } + ui = null + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(listOf("./RULES.md", "./TEAM.md"), rpc.configPatches.single().instructions) + } + + fun `test reset restores seeded baseline`() { + val panel = panel(input = { "./TEAM.md" }, read = { "# disk" }, editor = { _, _ -> FakeContentDialog("# edited") }) + flushUntil { rows(panel).size == 1 } + + edt { + panel.addFile() + toggle(panel).doClick() + assertTrue(panel.modified()) + panel.resetDraft() + assertFalse(panel.modified()) + assertEquals(listOf("./RULES.md"), rows(panel).map { it.key }) + assertTrue(toggle(panel).isSelected) + } + } + + fun `test content editor dialog exposes content`() { + edt { + val dialog = InstructionEditDialog("./RULES.md", "# Rules") + try { + assertEquals("# Rules", dialog.content()) + } finally { + dialog.close(0) + } + } + } + + fun `test content scroll renders an editor field`() { + edt { + val field = ai.kilocode.client.settings.base.SettingsContentField( + "# Rules", + ai.kilocode.client.settings.base.settingsEditorFileType("./RULES.md", "# Rules"), + true, + ) + val scroll = ai.kilocode.client.settings.base.settingsContentScroll(field) + assertTrue(components(scroll).any { it is com.intellij.ui.EditorTextField }) + } + } + + fun `test rule path descriptor chooses files`() { + assertTrue(rulePathDescriptor().isChooseFiles) + assertFalse(rulePathDescriptor().isChooseFolders) + } + + private fun panel( + root: String? = null, + choose: (JComponent) -> String? = { null }, + input: () -> String? = { null }, + read: (String) -> String? = { null }, + editor: (String, String) -> RuleContentDialogHandle = { _, _ -> FakeContentDialog("") }, + ): RulesSettingsUi { + install() + val write: (String, String) -> Boolean = { path, text -> writes += path to text; true } + val panel = edt { RulesSettingsUi(uiCoroutines.scope, root, choose, input, read, write, editor, app, workspaces, agent) } + ui = panel + return panel + } + + private fun install() { + appCoroutines = TestCoroutines() + uiCoroutines = TestCoroutines() + rpc = FakeAppRpcApi() + workspaceRpc = FakeWorkspaceRpcApi() + agentRpc = FakeAgentBehaviorRpcApi() + agentRpc.claudeCodeCompat = true + app = KiloAppService(appCoroutines.scope, rpc) + workspaces = KiloWorkspaceService(appCoroutines.scope, workspaceRpc) + agent = KiloAgentBehaviorService(appCoroutines.scope, agentRpc) + val state = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(instructions = listOf("./RULES.md")), + ) + rpc.state.value = state + app._state.value = state + } + + private fun click(list: JBList, panel: RulesSettingsUi, key: String, id: String) { + edt { + list.size = Dimension(520, 320) + list.doLayout() + val idx = rows(panel).indexOfFirst { it.key == key } + list.selectedIndex = idx + val area = settingsListCellBounds(list, idx, selected = true).getValue(id) + click(list, center(area)) + } + } + + private fun doubleClick(list: JBList, panel: RulesSettingsUi, key: String) { + edt { + list.size = Dimension(520, 320) + list.doLayout() + val idx = rows(panel).indexOfFirst { it.key == key } + list.selectedIndex = idx + val area = list.getCellBounds(idx, idx) + fire(list, mouse(list, MouseEvent.MOUSE_CLICKED, center(area), count = 2)) + } + } + + private fun rows(panel: RulesSettingsUi): List { + val list = rulesList(panel) + val model = list.model + return (0 until model.size).map { model.getElementAt(it) } + } + + private fun rulesList(panel: RulesSettingsUi) = components(panel).filterIsInstance>().single() + + private fun toggle(panel: RulesSettingsUi): SettingsToggle = components(panel).filterIsInstance().single() + + private fun scrollFor(panel: RulesSettingsUi, list: JBList) = components(panel) + .filterIsInstance() + .single { pane -> pane.viewport.view === list.parent } + + private fun center(rect: java.awt.Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2) + + private fun click(list: JBList, point: Point) { + fire(list, mouse(list, MouseEvent.MOUSE_PRESSED, point)) + fire(list, mouse(list, MouseEvent.MOUSE_RELEASED, point)) + } + + private fun mouse(list: JBList, id: Int, point: Point, count: Int = 1) = MouseEvent( + list, + id, + System.currentTimeMillis(), + if (id == MouseEvent.MOUSE_PRESSED) InputEvent.BUTTON1_DOWN_MASK else 0, + point.x, + point.y, + count, + false, + MouseEvent.BUTTON1, + ) + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) { + repeat(200) { + flush() + if (done()) return + } + flush() + assertTrue(done()) + } + + private fun flush() { + appCoroutines.drain(::pump) + uiCoroutines.drain(::pump) + pump() + } + + private fun pump() { + edt { UIUtil.dispatchAllInvocationEvents() } + } + + private fun components(root: java.awt.Component): List { + val out = mutableListOf() + fun visit(item: java.awt.Component) { + out += item + if (item is Container) item.components.forEach { visit(it) } + } + visit(root) + return out + } +} + +private class FakeContentDialog(private val text: String) : RuleContentDialogHandle { + override fun showAndGet() = true + override fun content() = text +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt index 1d5d5c5afb1..87f13a939c4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt @@ -45,6 +45,8 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var mcpConnectResult = true var mcpDisconnectResult = true var mcpAuthenticateResult = true + var claudeCodeCompat = false + val compatSaves = mutableListOf() override suspend fun agents(directory: String): List { assertNotEdt("agentBehavior.agents") @@ -163,11 +165,13 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { override suspend fun claudeCodeCompat(): Boolean { assertNotEdt("agentBehavior.claudeCodeCompat") - return false + return claudeCodeCompat } override suspend fun setClaudeCodeCompat(value: Boolean): Boolean { assertNotEdt("agentBehavior.setClaudeCodeCompat") + compatSaves.add(value) + claudeCodeCompat = value return value } } diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index e988f663c74..c030a926421 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.4.11", + "version": "7.4.13", "dependencies": {}, "devDependencies": {}, "peerDependencies": {}