Merge pull request #13240 from Kilo-Org/plan-jetbrains-workflows-parity

feat(jetbrains): add workflows settings page
This commit is contained in:
Kirill Kalishev
2026-08-20 14:47:05 -04:00
committed by GitHub
10 changed files with 855 additions and 1 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Support opening, editing, and deleting workflows from JetBrains settings.
@@ -7,6 +7,7 @@ import ai.kilocode.rpc.KiloAgentBehaviorRpcApi
import ai.kilocode.rpc.dto.AgentDetailDto
import ai.kilocode.rpc.dto.AgentCreateDto
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.McpConfigDto
import ai.kilocode.rpc.dto.McpServerConfigDto
import ai.kilocode.rpc.dto.McpStatusDto
@@ -39,6 +40,11 @@ class KiloAgentBehaviorService internal constructor(
suspend fun commands(directory: String): List<CommandDto> = safe(emptyList()) { call { commands(directory) } }
suspend fun loadCommandFiles(directory: String): List<CommandFileDto> = call { commandFiles(directory) }
suspend fun refreshCommandFiles(directory: String, fallback: List<CommandFileDto>): List<CommandFileDto> =
safe(fallback) { call { commandFiles(directory) } }
suspend fun mcpStatus(directory: String): List<McpStatusDto> = try {
LOG.info("mcp status: requesting dir=$directory")
call { mcpStatus(directory) }.also { LOG.info("mcp status: received dir=$directory count=${it.size}") }
@@ -59,6 +65,13 @@ class KiloAgentBehaviorService internal constructor(
suspend fun saveSkills(directory: String, edits: Map<String, String>): Boolean =
safe(false) { call { saveSkills(directory, edits) } }
suspend fun removeCommand(directory: String, location: String): Boolean = safe(false) { call { removeCommand(directory, location) } }
suspend fun reloadCommands(directory: String): Boolean = safe(false) { call { reloadCommands(directory) } }
suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean =
safe(false) { call { saveCommands(directory, edits) } }
suspend fun removeAgent(directory: String, name: String): Boolean = safe(false) { call { removeAgent(directory, name) } }
suspend fun createAgent(directory: String, input: AgentCreateDto): Boolean = safe(false) { call { createAgent(directory, input) } }
@@ -27,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.workflows.displayName") to WorkflowsConfigurable.ID,
KiloBundle.message("settings.agentBehavior.rules.displayName") to RulesConfigurable.ID,
).forEach { (label, id) ->
panel.next(ActionLink(label) { e ->
@@ -0,0 +1,316 @@
package ai.kilocode.client.settings.agents
import ai.kilocode.client.KiloNotifications
import ai.kilocode.client.app.KiloAgentBehaviorService
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.SettingsListPanel
import ai.kilocode.client.settings.base.SettingsMessageException
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.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListCell
import ai.kilocode.client.ui.list.ActiveListConfig
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListSelection
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.CommandFileDto
import com.intellij.CommonBundle
import com.intellij.icons.AllIcons
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.event.DocumentEvent
import com.intellij.openapi.editor.event.DocumentListener
import com.intellij.openapi.fileTypes.FileType
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.Messages
import com.intellij.ui.components.JBScrollPane
import javax.swing.JComponent
import javax.swing.ScrollPaneConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
private val workflowEdt = Dispatchers.EDT + ModalityState.any().asContextElement()
class WorkflowsConfigurable : AgentBehaviorConfigurableBase<JComponent>() {
override fun getId(): String = ID
override fun getDisplayName(): String = KiloBundle.message("settings.agentBehavior.workflows.displayName")
override fun create(cs: CoroutineScope, dir: String): JComponent = WorkflowsSettingsUi(cs, dir)
override fun update(ui: JComponent, dir: String) {
(ui as? WorkflowsSettingsUi)?.setDirectory(dir)
}
override fun scrollReadyShell() = false
companion object { const val ID = "ai.kilocode.jetbrains.settings.agentBehavior.workflows" }
}
internal class WorkflowsSettingsUi(
scope: CoroutineScope,
dir: String,
private val edit: (CommandFileDto, Boolean) -> WorkflowEditDialogHandle = ::WorkflowEditDialog,
) : SettingsListPanel(scope, ActiveListConfig.Equal.copy(tooltip = false)), SettingsDraftPage {
private var dir = dir
private var flows = emptyMap<String, CommandFileDto>()
private val state = SettingsDraftState(workflowsDraft(), ::saved)
private var draft: WorkflowsDraft
get() = state.draft
set(value) {
state.draft = value
}
init {
start()
setCenter(workflowsScroll())
}
fun setDirectory(value: String) {
if (value == dir) return
dir = value
reload()
}
override suspend fun fetch(): List<ActiveListItem> {
val items = withTimeoutOrNull(WORKFLOW_LOAD_TIMEOUT_MS) {
service<KiloAgentBehaviorService>().loadCommandFiles(dir)
} ?: throw SettingsMessageException(KiloBundle.message("settings.agentBehavior.workflows.load.timeout"))
withContext(workflowEdt) {
val dirty = state.modified()
val edit = draft
state.accept(workflowsDraft())
if (dirty) draft = state.draft.copy(edited = edit.edited, deleted = edit.deleted)
flows = items.associateBy { key(it) }
}
LOG.info("workflows settings fetch dir=$dir total=${items.size}")
return rows(items)
}
override fun onCell(key: String, cellId: String) {
val flow = flows[key] ?: return
when (cellId) {
OPEN_CELL -> open(flow)
EDIT_CELL -> edit(flow)
DELETE_CELL -> remove(flow)
}
}
override fun searchPlaceholder() = KiloBundle.message("settings.agentBehavior.workflows.search")
override fun emptyText() = KiloBundle.message("settings.agentBehavior.workflows.empty")
override fun modified(): Boolean = state.modified()
override fun resetDraft() {
state.reset()
view.update(rows())
clearProgress()
}
override fun applyDraft() {
val token = state.start() ?: return
val fallback = workflowFallback(token.target)
if (!launch("apply") { id ->
val target = token.target
var failed: String? = null
val behavior = service<KiloAgentBehaviorService>()
LOG.info("workflows settings apply start dir=$dir edited=${target.edited.size} deleted=${target.deleted.size}")
if (target.edited.isNotEmpty() && !behavior.saveCommands(dir, target.edited)) {
failed = KiloBundle.message("settings.agentBehavior.save.failed")
}
if (failed == null) {
for (location in target.deleted) {
if (!behavior.removeCommand(dir, location)) {
failed = KiloBundle.message("settings.agentBehavior.workflows.delete.failed")
break
}
}
}
val reloaded = if (failed == null) behavior.reloadCommands(dir) else true
val items = behavior.refreshCommandFiles(dir, fallback)
withContext(workflowEdt) {
if (!active(id)) {
if (failed == null) KiloNotifications.info(KiloBundle.message("settings.agentBehavior.workflows.saved.notification"))
else KiloNotifications.error(failed)
return@withContext
}
if (failed == null) {
flows = items.associateBy { key(it) }
state.complete(token, workflowsDraft())
view.update(rows(items))
if (reloaded) clearProgress() else showProgress(KiloBundle.message("settings.agentBehavior.workflows.reload.blocked"))
LOG.info("workflows settings apply succeeded dir=$dir")
} else {
state.fail(token, failed)
view.update(rows(items))
showError(failed)
LOG.warn("workflows settings apply failed dir=$dir message=$failed")
}
setBusy(false)
}
}) {
val failed = KiloBundle.message("settings.agentBehavior.save.failed")
state.fail(token, failed)
showError(failed)
return
}
showProgress(KiloBundle.message("settings.agentBehavior.saving"))
}
private fun workflowsScroll() = JBScrollPane(view).apply {
border = null
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
private fun rows(items: List<CommandFileDto> = flows.values.toList()): List<ActiveListItem> = items.mapNotNull { flow ->
if (flow.location in draft.deleted) return@mapNotNull null
item(flow)
}
private fun workflowFallback(target: WorkflowsDraft): List<CommandFileDto> = flows.values.mapNotNull { flow ->
if (flow.location in target.deleted) return@mapNotNull null
target.edited[flow.location]?.let { flow.copy(content = it) } ?: flow
}
private fun item(flow: CommandFileDto) = object : ActiveListItem {
override val key = key(flow)
override val title = "/${flow.name}"
override val note = flow.location.takeUnless { builtin(flow) }
override val description = flow.description
override val doubleClick = EDIT_CELL
override val badges = listOf(
ActiveListBadge(KiloBundle.message("settings.agentBehavior.badge.builtin"), UiStyle.Badge.Secondary),
).takeIf { builtin(flow) } ?: emptyList()
override val cells = listOfNotNull(
ActiveListCell(
OPEN_CELL,
KiloBundle.message("settings.agentBehavior.workflows.openInEditor"),
primary = true,
).takeIf { flow.editable },
ActiveListCell(
EDIT_CELL,
KiloBundle.message(if (flow.editable) "settings.agentBehavior.edit" else "common.open"),
primary = !flow.editable,
),
ActiveListCell(
DELETE_CELL,
KiloBundle.message("common.delete"),
icon = AllIcons.Actions.GC,
iconOnly = true,
).takeIf { flow.editable },
)
}
private fun edit(flow: CommandFileDto) {
val current = flow.copy(content = content(flow))
val dialog = edit(current, flow.editable)
if (!flow.editable) {
dialog.showAndGet()
return
}
if (!dialog.showAndGet()) return
state.update { copy(edited = edited + (flow.location to dialog.content())) }
view.update(rows(), ActiveListSelection.Key(key(flow)))
}
private fun open(flow: CommandFileDto) {
if (!flow.editable) return
if (!launch("open") { id ->
val opened = service<KiloWorkspaceService>().openFile(flow.location)
withContext(workflowEdt) {
if (!active(id)) return@withContext
setBusy(false)
if (opened) return@withContext
clearProgress()
KiloNotifications.error(KiloBundle.message("settings.agentBehavior.workflows.openInEditor.failed"))
}
}) return
showProgress(KiloBundle.message("settings.agentBehavior.workflows.openInEditor.pending"))
}
private fun remove(flow: CommandFileDto) {
val result = Messages.showYesNoDialog(
KiloBundle.message("settings.agentBehavior.workflows.delete.message", flow.name),
KiloBundle.message("settings.agentBehavior.workflows.delete.title"),
KiloBundle.message("common.delete"),
Messages.getCancelButton(),
Messages.getQuestionIcon(),
)
if (result != Messages.YES) return
state.update { copy(deleted = deleted + flow.location, edited = edited - flow.location) }
view.update(rows(), selectionIndex())
}
private fun content(flow: CommandFileDto) = draft.edited[flow.location] ?: flow.content
private companion object {
const val EDIT_CELL = "edit"
const val OPEN_CELL = "open"
const val DELETE_CELL = "delete"
const val BUILTIN = "builtin"
const val LEGACY_BUILTIN = "<built-in>"
val LOG = KiloLog.create(WorkflowsSettingsUi::class.java)
fun key(flow: CommandFileDto) = if (builtin(flow)) {
listOf("builtin", flow.source.orEmpty(), flow.name).joinToString(":")
} else {
flow.location.ifBlank { flow.name }
}
fun builtin(flow: CommandFileDto) = flow.builtin || flow.location == BUILTIN || flow.location == LEGACY_BUILTIN
}
}
internal interface WorkflowEditDialogHandle {
fun showAndGet(): Boolean
fun content(): String
}
private data class WorkflowsDraft(
val edited: Map<String, String> = emptyMap(),
val deleted: Set<String> = emptySet(),
)
private fun workflowsDraft() = WorkflowsDraft()
private fun saved(base: WorkflowsDraft, draft: WorkflowsDraft): Boolean = base == draft
internal class WorkflowEditDialog(private val flow: CommandFileDto, private val savable: Boolean) : DialogWrapper(true), WorkflowEditDialogHandle {
private val base = initial()
private val editor = SettingsContentField(base, workflowFileType(flow.location, base), savable)
init {
title = "/${flow.name}"
setOKButtonText(CommonBundle.getOkButtonText())
setCancelButtonText(CommonBundle.getCloseButtonText())
init()
isOKActionEnabled = false
editor.document.addDocumentListener(object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
isOKActionEnabled = savable && editor.text != base
}
})
}
override fun createCenterPanel(): JComponent = settingsContentScroll(editor)
override fun createActions() = if (savable) arrayOf(okAction, cancelAction) else arrayOf(cancelAction)
override fun content() = editor.text
private fun initial() = flow.content?.takeIf { it.isNotBlank() }
?: flow.description?.takeIf { it.isNotBlank() }
?: KiloBundle.message("settings.agentBehavior.workflows.content.empty")
}
internal fun workflowFileType(location: String, content: String? = null): FileType =
settingsEditorFileType(location.ifBlank { WORKFLOW_FILE }, content)
private const val WORKFLOW_FILE = "workflow.md"
private const val WORKFLOW_LOAD_TIMEOUT_MS = 10_000L
@@ -112,6 +112,13 @@
bundle="messages.KiloBundle"
key="settings.agentBehavior.skills.displayName"/>
<applicationConfigurable
parentId="ai.kilocode.jetbrains.settings.agentBehavior"
id="ai.kilocode.jetbrains.settings.agentBehavior.workflows"
instance="ai.kilocode.client.settings.agents.WorkflowsConfigurable"
bundle="messages.KiloBundle"
key="settings.agentBehavior.workflows.displayName"/>
<applicationConfigurable
parentId="ai.kilocode.jetbrains.settings.agentBehavior"
id="ai.kilocode.jetbrains.settings.agentBehavior.rules"
@@ -655,6 +655,7 @@ settings.agentBehavior.mcp.status.needsAuth=needs auth
settings.agentBehavior.mcp.status.needsRegistration=needs registration
settings.agentBehavior.mcp.status.disabled=disabled
settings.agentBehavior.skills.displayName=Skills
settings.agentBehavior.workflows.displayName=Workflows
settings.agentBehavior.rules.displayName=Rules
settings.rules.files.title=Additional Instruction Files
settings.rules.files.description=Paths to additional instruction files that are included in the system prompt
@@ -701,6 +702,18 @@ settings.agentBehavior.skills.sources.addUrl.title=Add Skill URL
settings.agentBehavior.skills.sources.addUrl.prompt=Enter a skill source URL.
settings.agentBehavior.skills.sources.editPath.title=Edit Skill Path
settings.agentBehavior.skills.sources.editUrl.title=Edit Skill URL
settings.agentBehavior.workflows.search=Filter workflows
settings.agentBehavior.workflows.empty=No workflows found.
settings.agentBehavior.workflows.content.empty=No workflow content available.
settings.agentBehavior.workflows.load.timeout=Workflow loading timed out. Existing workflows were kept; refresh after slow sources recover.
settings.agentBehavior.workflows.reload.blocked=Workflows settings saved, but active sessions are present. Reload the core after those sessions finish to apply the new workflows.
settings.agentBehavior.workflows.saved.notification=Workflows settings saved
settings.agentBehavior.workflows.delete.title=Delete Workflow
settings.agentBehavior.workflows.delete.message=Delete workflow {0}? This removes the workflow file and cannot be undone.
settings.agentBehavior.workflows.delete.failed=Could not delete the workflow.
settings.agentBehavior.workflows.openInEditor=Open in Editor
settings.agentBehavior.workflows.openInEditor.pending=The workflow file will open after you close Settings.
settings.agentBehavior.workflows.openInEditor.failed=Could not open the workflow file in the editor.
settings.providers.loading=Loading providers...
settings.providers.connected=Connected providers
settings.providers.available=Available providers
@@ -2,6 +2,7 @@ package ai.kilocode.client.app
import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi
import ai.kilocode.rpc.dto.AgentCreateDto
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.McpStatusDto
import ai.kilocode.rpc.dto.SkillDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@@ -98,6 +99,35 @@ class KiloAgentBehaviorServiceTest : BasePlatformTestCase() {
assertEquals("# Saved", rpc.skills.single().content)
}
fun `test loadCommandFiles propagates rpc failure`() = runBlocking {
rpc.commandFilesError = RuntimeException("boom")
assertFailsWith<RuntimeException> {
withContext(Dispatchers.Default) { service.loadCommandFiles("/test") }
}
}
fun `test refreshCommandFiles returns previous rows on rpc failure`() = runBlocking {
val fallback = listOf(CommandFileDto("plan", location = "/test/.kilo/workflows/plan.md"))
rpc.commandFilesError = RuntimeException("boom")
val items = withContext(Dispatchers.Default) { service.refreshCommandFiles("/test", fallback) }
assertEquals(fallback, items)
}
fun `test saveCommands forwards all edits`() = runBlocking {
rpc.commandFiles = listOf(CommandFileDto("plan", location = "/test/.kilo/workflows/plan.md"))
val ok = withContext(Dispatchers.Default) {
service.saveCommands("/test", mapOf("/test/.kilo/workflows/plan.md" to "# Saved"))
}
assertTrue(ok)
assertEquals(listOf(Triple("/test", "/test/.kilo/workflows/plan.md", "# Saved")), rpc.commandSaves)
assertEquals("# Saved", rpc.commandFiles.single().content)
}
fun `test mcpStatus forwards directory`() = runBlocking {
rpc.mcps = listOf(McpStatusDto("filesystem", "connected"))
@@ -21,6 +21,7 @@ class AgentBehaviorConfigurableTest : BasePlatformTestCase() {
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.agents", AgentsConfigurable.ID)
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.mcp", McpConfigurable.ID)
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.skills", SkillsConfigurable.ID)
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.workflows", WorkflowsConfigurable.ID)
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.rules", RulesConfigurable.ID)
}
@@ -30,7 +31,7 @@ class AgentBehaviorConfigurableTest : BasePlatformTestCase() {
edt {
val panel = cfg.createComponent()
val labels = links(panel as Container).map { it.text }
assertEquals(listOf("Agents", "MCP Servers", "Skills", "Rules"), labels)
assertEquals(listOf("Agents", "MCP Servers", "Skills", "Workflows", "Rules"), labels)
}
}
@@ -0,0 +1,465 @@
package ai.kilocode.client.settings.agents
import ai.kilocode.client.app.KiloAgentBehaviorService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.client.testing.fire
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.activeListCellBounds
import ai.kilocode.client.util.edtWait
import ai.kilocode.rpc.dto.CommandFileDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.fileTypes.UnknownFileType
import com.intellij.openapi.ui.DialogWrapper
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.testFramework.replaceService
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.UIUtil
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.JTextField
import javax.swing.ScrollPaneConstants
import javax.swing.Scrollable
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
class WorkflowsSettingsUiTest : BasePlatformTestCase() {
private var scope: CoroutineScope? = null
private var ui: WorkflowsSettingsUi? = null
private lateinit var agentRpc: FakeAgentBehaviorRpcApi
private lateinit var workspaceRpc: FakeWorkspaceRpcApi
private var shown = 0
override fun tearDown() {
try {
TestDialogManager.setTestDialog(TestDialog.DEFAULT)
ui?.let { panel -> edt { panel.dispose(); true } }
ui = null
scope?.cancel()
scope = null
} finally {
super.tearDown()
}
}
fun `test loads workflows with location note and builtins have no actions`() {
val panel = panel()
flushUntil { rows(panel).size == 3 }
edt {
val rows = rows(panel)
val custom = rows.single { it.key == CUSTOM }
assertEquals("/plan", custom.title)
assertEquals(CUSTOM, custom.note)
assertEquals("Plan work", custom.description)
assertEquals("edit", custom.doubleClick)
assertEquals(listOf("open", "edit", "delete"), custom.cells.map { it.id })
assertTrue(custom.cells.single { it.id == "open" }.primary)
assertFalse(custom.cells.single { it.id == "edit" }.primary)
assertEquals("Edit", custom.cells.single { it.id == "edit" }.label)
assertTrue(custom.cells.single { it.id == "delete" }.iconOnly)
val builtin = rows.single { it.key == "builtin::init" }
assertEquals("/init", builtin.title)
assertNull(builtin.note)
assertEquals(listOf("built-in"), builtin.badges.map { it.text })
assertEquals(listOf("edit"), builtin.cells.map { it.id })
assertEquals("Open", builtin.cells.single().label)
val remote = rows.single { it.key == REMOTE }
assertEquals(listOf("edit"), remote.cells.map { it.id })
assertEquals("Open", remote.cells.single().label)
assertEquals(listOf(DIR), agentRpc.commandCalls)
true
}
}
fun `test workflows list is vertically scrolled without horizontal scrollbar`() {
val panel = panel()
flushUntil { rows(panel).size == 3 }
edt {
val pane = scrollFor(panel, workflowsList(panel))
val view = pane.viewport.view
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, pane.horizontalScrollBarPolicy)
assertTrue((view as Scrollable).getScrollableTracksViewportWidth())
assertFalse(view.getScrollableTracksViewportHeight())
true
}
}
fun `test renderer puts location on first line and description on preview line`() {
val panel = panel()
flushUntil { rows(panel).size == 3 }
edt {
val list = workflowsList(panel)
val row = rows(panel).single { it.key == CUSTOM }
val idx = rows(panel).indexOf(row)
val comp = list.cellRenderer.getListCellRendererComponent(list, row, idx, true, true)
comp.setSize(520, list.fixedCellHeight)
layout(comp)
val title = components(comp).filterIsInstance<SimpleColoredComponent>().single()
val labels = components(comp).filterIsInstance<JBLabel>().filter { it.isVisible }.map { it.text }
assertEquals("/plan $CUSTOM", title.toString())
assertTrue(labels.contains("Plan work"))
true
}
}
fun `test double click stages workflow content until apply`() {
val panel = panel(edit = { _, _ -> FakeWorkflowDialog("# Saved") })
flushUntil { rows(panel).size == 3 }
doubleClick(workflowsList(panel), panel, CUSTOM)
assertTrue(edt { panel.modified() })
assertTrue(agentRpc.commandSaves.isEmpty())
edt { panel.applyDraft(); true }
flushUntil { agentRpc.commandSaves.size == 1 }
assertEquals(Triple(DIR, CUSTOM, "# Saved"), agentRpc.commandSaves.single())
}
fun `test reopening staged workflow edit shows draft content before apply`() {
val seen = mutableListOf<String?>()
val panel = panel(edit = { flow, _ ->
seen += flow.content
FakeWorkflowDialog(if (seen.size == 1) "# Draft" else "# Draft 2")
})
flushUntil { rows(panel).size == 3 }
doubleClick(workflowsList(panel), panel, CUSTOM)
doubleClick(workflowsList(panel), panel, CUSTOM)
assertEquals(listOf("# Plan\nUse steps", "# Draft"), seen)
assertTrue(agentRpc.commandSaves.isEmpty())
}
fun `test open in editor action opens workflow file`() {
val panel = panel()
flushUntil { rows(panel).size == 3 }
click(workflowsList(panel), panel, CUSTOM, "open")
assertEquals("The workflow file will open after you close Settings.", edt { progressText(panel) })
flushUntil { workspaceRpc.openedFiles.size == 1 }
assertEquals(FakeWorkspaceRpcApi.Opened(CUSTOM, null, null), workspaceRpc.openedFiles.single())
}
fun `test read only workflows open without staging edits or editor file open`() {
shown = 0
val panel = panel(edit = { _, savable ->
assertFalse(savable)
FakeWorkflowDialog("# Ignored") { shown += 1 }
})
flushUntil { rows(panel).size == 3 }
click(workflowsList(panel), panel, REMOTE, "edit")
assertEquals(1, shown)
assertFalse(edt { panel.modified() })
assertTrue(agentRpc.commandSaves.isEmpty())
assertTrue(workspaceRpc.openedFiles.isEmpty())
}
fun `test workflow edit dialog shows content with fallback`() {
edt {
val content = WorkflowEditDialog(CommandFileDto("plan", "desc", location = CUSTOM, content = "# Plan\nUse steps"), true)
val fallback = WorkflowEditDialog(CommandFileDto("plan", "desc", location = CUSTOM), true)
val readonly = WorkflowEditDialog(CommandFileDto("init", "desc", location = "builtin", content = "Built in content"), false)
try {
assertEquals("# Plan\nUse steps", content.content())
assertEquals("desc", fallback.content())
assertEquals("Built in content", readonly.content())
assertEquals("OK", content.okText())
} finally {
content.close(DialogWrapper.CANCEL_EXIT_CODE)
fallback.close(DialogWrapper.CANCEL_EXIT_CODE)
readonly.close(DialogWrapper.CANCEL_EXIT_CODE)
}
true
}
}
fun `test delete action stages workflow removal until apply`() {
val panel = panel()
flushUntil { rows(panel).size == 3 }
TestDialogManager.setTestDialog(TestDialog.YES)
click(workflowsList(panel), panel, CUSTOM, "delete")
assertTrue(edt { rows(panel).none { it.key == CUSTOM } })
assertTrue(agentRpc.commandRemovals.isEmpty())
edt { panel.applyDraft(); true }
flushUntil { agentRpc.commandRemovals.size == 1 }
assertEquals(listOf(DIR to CUSTOM), agentRpc.commandRemovals)
}
fun `test delete action requires confirmation`() {
val panel = panel()
flushUntil { rows(panel).size == 3 }
TestDialogManager.setTestDialog { Messages.NO }
click(workflowsList(panel), panel, CUSTOM, "delete")
edt { UIUtil.dispatchAllInvocationEvents(); true }
assertTrue(agentRpc.commandRemovals.isEmpty())
assertTrue(edt { rows(panel).any { it.key == CUSTOM } })
}
fun `test blocked reload completes apply with warning`() {
val panel = panel(edit = { _, _ -> FakeWorkflowDialog("# Saved") })
agentRpc.reloadCommandResult = false
flushUntil { rows(panel).size == 3 }
doubleClick(workflowsList(panel), panel, CUSTOM)
edt { panel.applyDraft(); true }
flushUntil { agentRpc.commandSaves.size == 1 && !edt { panel.modified() } }
assertEquals(listOf(DIR), agentRpc.commandReloads)
assertEquals("Workflows settings saved, but active sessions are present. Reload the core after those sessions finish to apply the new workflows.", edt { progressText(panel) })
}
fun `test post apply workflows refresh failure keeps saved rows`() {
val panel = panel(edit = { _, _ -> FakeWorkflowDialog("# Saved") })
flushUntil { rows(panel).size == 3 }
doubleClick(workflowsList(panel), panel, CUSTOM)
agentRpc.commandFilesError = RuntimeException("timeout")
edt { panel.applyDraft(); true }
flushUntil { agentRpc.commandSaves.size == 1 && !edt { panel.modified() } }
assertEquals(listOf(CUSTOM, "builtin::init", REMOTE), edt { rows(panel).map { it.key } })
assertEquals("# Saved", agentRpc.commandFiles.single { it.location == CUSTOM }.content)
}
fun `test fileless workflow keys stay unique and route read only opens`() {
val seen = mutableListOf<String?>()
val panel = panel(edit = { flow, savable ->
assertFalse(savable)
seen += flow.content
FakeWorkflowDialog("# Ignored")
})
flushUntil { rows(panel).size == 3 }
agentRpc.commandFiles = listOf(
CommandFileDto("init", "Built in", builtin = true, location = "builtin", content = "Init content"),
CommandFileDto("review", "Built in", builtin = true, location = "builtin", content = "Review content"),
)
edt { panel.reload(); true }
flushUntil { rows(panel).size == 2 }
click(workflowsList(panel), panel, "builtin::init", "edit")
click(workflowsList(panel), panel, "builtin::review", "edit")
assertEquals(listOf("builtin::init", "builtin::review"), edt { rows(panel).map { it.key } })
assertEquals(listOf("Init content", "Review content"), seen)
}
fun `test apply while list busy preserves staged workflow edits`() {
val panel = panel(edit = { _, _ -> FakeWorkflowDialog("# Saved") })
flushUntil { rows(panel).size == 3 }
doubleClick(workflowsList(panel), panel, CUSTOM)
agentRpc.commandFilesGate = CompletableDeferred()
edt {
panel.reload()
panel.applyDraft()
true
}
assertTrue(edt { panel.modified() })
assertTrue(agentRpc.commandSaves.isEmpty())
agentRpc.commandFilesGate?.complete(Unit)
}
fun `test failed open in editor clears pending banner`() {
val panel = panel()
workspaceRpc.openResult = false
flushUntil { rows(panel).size == 3 }
click(workflowsList(panel), panel, CUSTOM, "open")
flushUntil { workspaceRpc.openedFiles.size == 1 && edt { !panel.progress.isVisible && progressText(panel).isBlank() } }
}
fun `test search filters workflows by name`() {
val panel = panel()
flushUntil { rows(panel).size == 3 }
edt {
components(panel).filterIsInstance<JTextField>().single().text = "init"
UIUtil.dispatchAllInvocationEvents()
true
}
flushUntil { rows(panel).map { it.key } == listOf("builtin::init") }
}
fun `test workflows reload failure keeps existing rows`() {
val panel = panel()
flushUntil { rows(panel).size == 3 }
agentRpc.commandFilesError = RuntimeException("timeout")
edt { panel.reload(); true }
flushUntil { edt { workflowsList(panel).isEnabled } }
assertEquals(listOf(CUSTOM, "builtin::init", REMOTE), edt { rows(panel).map { it.key } })
}
fun `test workflow editor file type follows location extension`() {
assertNotSame(UnknownFileType.INSTANCE, workflowFileType("/tmp/workflows/plan.md"))
assertEquals(
FileTypeManager.getInstance().getFileTypeByFileName("index.html"),
workflowFileType("/tmp/workflows/index.html"),
)
assertEquals(PlainTextFileType.INSTANCE, workflowFileType("/tmp/workflows/index.unknown"))
}
private fun panel(
edit: (CommandFileDto, Boolean) -> WorkflowEditDialogHandle = { _, _ -> FakeWorkflowDialog("# Plan\nUse steps") },
): WorkflowsSettingsUi {
install()
val panel = edt { WorkflowsSettingsUi(scope!!, DIR, edit) }
ui = panel
edt { panel.reload(); true }
return panel
}
private fun install() {
val cs = CoroutineScope(SupervisorJob())
scope = cs
workspaceRpc = FakeWorkspaceRpcApi()
agentRpc = FakeAgentBehaviorRpcApi().apply {
commandFiles = listOf(
CommandFileDto("plan", "Plan work", location = CUSTOM, editable = true, content = "# Plan\nUse steps"),
CommandFileDto("init", "Built in", builtin = true, location = "builtin", content = "Built in content"),
CommandFileDto("remote", "Remote workflow", location = REMOTE, content = "# Remote workflow"),
)
}
ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, KiloAgentBehaviorService(cs, agentRpc), testRootDisposable)
ApplicationManager.getApplication().replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(cs, workspaceRpc), testRootDisposable)
}
private fun click(list: JBList<ActiveListItem>, panel: WorkflowsSettingsUi, 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 = activeListCellBounds(list, idx, selected = true).getValue(id)
click(list, center(area))
true
}
}
private fun doubleClick(list: JBList<ActiveListItem>, panel: WorkflowsSettingsUi, 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))
true
}
}
private fun rows(panel: WorkflowsSettingsUi): List<ActiveListItem> = items(workflowsList(panel))
private fun items(list: JBList<ActiveListItem>): List<ActiveListItem> {
val model = list.model
return (0 until model.size).map { model.getElementAt(it) }
}
private fun workflowsList(panel: WorkflowsSettingsUi) = components(panel).filterIsInstance<JBList<ActiveListItem>>().first()
private fun scrollFor(panel: WorkflowsSettingsUi, list: JBList<ActiveListItem>) = components(panel)
.filterIsInstance<JBScrollPane>()
.single { pane -> pane.viewport.view === list.parent }
private fun progressText(panel: WorkflowsSettingsUi) = components(panel.progress).filterIsInstance<JBLabel>().single().text
private fun WorkflowEditDialog.okText(): String {
val method = DialogWrapper::class.java.getDeclaredMethod("getOKAction")
method.isAccessible = true
return (method.invoke(this) as javax.swing.Action).getValue(javax.swing.Action.NAME) as String
}
private fun components(root: java.awt.Component): List<java.awt.Component> {
val out = mutableListOf<java.awt.Component>()
fun visit(item: java.awt.Component) {
out += item
if (item is Container) item.components.forEach { visit(it) }
}
visit(root)
return out
}
private fun layout(root: java.awt.Component) {
root.doLayout()
if (root is Container) root.components.filterIsInstance<Container>().forEach { layout(it) }
UIUtil.dispatchAllInvocationEvents()
}
private fun center(rect: java.awt.Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2)
private fun click(list: JBList<ActiveListItem>, point: Point) {
fire(list, mouse(list, MouseEvent.MOUSE_PRESSED, point))
fire(list, mouse(list, MouseEvent.MOUSE_RELEASED, point))
}
private fun mouse(list: JBList<ActiveListItem>, 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 <T> edt(block: () -> T): T = edtWait(block)
private fun flushUntil(done: () -> Boolean) = runBlocking {
repeat(300) {
delay(10)
edt { UIUtil.dispatchAllInvocationEvents(); true }
if (done()) return@runBlocking
}
edt { UIUtil.dispatchAllInvocationEvents(); true }
assertTrue(done())
}
private companion object {
const val DIR = "/test"
const val CUSTOM = "/home/test/.kilo/workflows/plan.md"
const val REMOTE = "/home/test/.cache/kilo/commands/remote.md"
}
}
private class FakeWorkflowDialog(private val text: String, private val show: () -> Unit = {}) : WorkflowEditDialogHandle {
override fun showAndGet(): Boolean {
show()
return true
}
override fun content() = text
}
@@ -9,6 +9,7 @@ import ai.kilocode.rpc.dto.McpConfigDto
import ai.kilocode.rpc.dto.McpServerConfigDto
import ai.kilocode.rpc.dto.McpStatusDto
import ai.kilocode.rpc.dto.SkillDto
import kotlinx.coroutines.CompletableDeferred
class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
var agents = emptyList<AgentDetailDto>()
@@ -39,6 +40,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
var afterMcpConnect: (suspend (String, String) -> Unit)? = null
var createError: Exception? = null
var skillsError: Exception? = null
var commandFilesGate: CompletableDeferred<Unit>? = null
var commandFilesError: Exception? = null
var removeError: Exception? = null
var removeSkillError: Exception? = null
@@ -135,6 +137,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
override suspend fun commandFiles(directory: String): List<CommandFileDto> {
assertNotEdt("agentBehavior.commandFiles")
commandFilesGate?.await()
commandFilesError?.let { throw it }
commandCalls.add(directory)
return commandFiles