fix(jetbrains): open completed plan links

This commit is contained in:
kirillk
2026-05-25 15:00:52 -04:00
parent a9b90cae3a
commit 435232d5ba
22 changed files with 328 additions and 49 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Open completed plan file links from JetBrains session transcripts.
@@ -15,6 +15,7 @@ import ai.kilocode.backend.workspace.ModelInfo
import ai.kilocode.backend.workspace.ProviderData
import ai.kilocode.backend.workspace.ProviderInfo
import ai.kilocode.backend.workspace.SkillInfo
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.AgentsDto
@@ -28,14 +29,28 @@ import ai.kilocode.rpc.dto.ModelLimitDto
import ai.kilocode.rpc.dto.ProviderDto
import ai.kilocode.rpc.dto.ProvidersDto
import ai.kilocode.rpc.dto.SkillDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.fileEditor.OpenFileDescriptor
import com.intellij.openapi.project.Project
import com.intellij.openapi.components.service
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.LocalFileSystem
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.nio.file.InvalidPathException
import java.nio.file.Path
import kotlin.coroutines.resume
/**
* Backend implementation of [KiloWorkspaceRpcApi].
@@ -45,6 +60,9 @@ import kotlinx.coroutines.flow.map
* directory (including worktrees) can get a workspace.
*/
class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
companion object {
private val LOG = KiloLog.create(KiloWorkspaceRpcApiImpl::class.java)
}
private val app: KiloBackendAppService get() = service()
@@ -83,6 +101,72 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
manager.get(directory).reload()
}
override suspend fun files(directory: String, path: String): List<WorkspaceFileDto> {
val item = clean(path) ?: return emptyList()
val file = file(item) ?: return emptyList()
val bases = listOf(directory) + ProjectManager.getInstance().openProjects
.asSequence()
.filter { !it.isDefault }
.mapNotNull { it.basePath }
.filter { it != directory }
.toList()
val paths = if (file.isAbsolute) listOf(file) else bases.mapNotNull { base ->
file(base)?.resolve(file)?.normalize()
}
val found = linkedMapOf<String, WorkspaceFileDto>()
for (target in paths) {
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: continue
found[vf.path] = WorkspaceFileDto(vf.path, vf.name, vf.isDirectory)
}
return found.values.toList()
}
override suspend fun openFile(path: String): Boolean {
val item = clean(path) ?: return false
val target = file(item)?.takeIf { it.isAbsolute } ?: return false
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false
val project = project(target) ?: run {
LOG.warn("No project available to open file: $path")
return false
}
navigate(project, vf)
return true
}
private fun clean(path: String): String? {
val raw = path.trim().takeIf { it.isNotBlank() } ?: return null
return try {
val cut = raw.substringBefore('#').substringBefore('?')
val decoded = if (cut.startsWith("file:")) URI(cut).path else URLDecoder.decode(cut, StandardCharsets.UTF_8)
Path.of(decoded.replace('\\', '/')).normalize().toString()
} catch (e: Exception) {
LOG.debug { "Failed to normalize workspace file path: $path (${e.message})" }
null
}
}
private fun file(path: String): Path? = try {
Path.of(path).normalize()
} catch (e: InvalidPathException) {
LOG.debug { "Invalid workspace file path: $path (${e.message})" }
null
}
private suspend fun navigate(project: Project, file: VirtualFile) = suspendCancellableCoroutine { cont ->
ApplicationManager.getApplication().invokeLater({
OpenFileDescriptor(project, file).navigate(true)
if (cont.isActive) cont.resume(Unit)
}, ModalityState.any())
}
private fun project(path: Path): Project? {
val projects = ProjectManager.getInstance().openProjects.filter { !it.isDefault }
return projects.firstOrNull { item ->
val base = item.basePath?.let(::file) ?: return@firstOrNull false
path.startsWith(base)
} ?: projects.firstOrNull()
}
// ------ mapping: domain model → DTO ------
private fun dto(state: KiloWorkspaceState): KiloWorkspaceStateDto =
@@ -5,6 +5,7 @@ package ai.kilocode.client.app
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.openapi.components.Service
import ai.kilocode.log.KiloLog
import fleet.rpc.client.durable
@@ -98,4 +99,23 @@ class KiloWorkspaceService internal constructor(
}
}
}
suspend fun files(directory: String, path: String): List<WorkspaceFileDto> {
return try {
call { files(directory, path) }
} catch (e: Exception) {
LOG.warn("workspace file lookup failed for directory=$directory path=$path", e)
emptyList()
}
}
suspend fun openPath(directory: String, path: String): Boolean {
val match = files(directory, path).firstOrNull() ?: return false
return try {
call { openFile(match.path) }
} catch (e: Exception) {
LOG.warn("workspace file open failed for path=${match.path}", e)
false
}
}
}
@@ -2,6 +2,7 @@ package ai.kilocode.client.session
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
@@ -35,6 +36,7 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.colors.EditorColorsListener
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.service
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ConfigurableWithId
import com.intellij.openapi.options.ShowSettingsUtil
@@ -64,6 +66,7 @@ class SessionUi(
ref: SessionRef? = null,
displayMs: Long = SessionController.DISPLAY_DELAY_MS,
private val manager: SessionManager? = null,
private val workspaces: KiloWorkspaceService = service(),
) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget {
companion object {
@@ -71,6 +74,7 @@ class SessionUi(
}
private val project = project
private val workspace = workspace
private val app = app
private var opening = ref != null
private var pending = false
@@ -185,7 +189,7 @@ class SessionUi(
reply = { id, dto -> controller.replyPermission(id, dto) },
)
login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() })
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login)
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile)
header = SessionHeaderPanel(controller, this)
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
@@ -354,6 +358,12 @@ class SessionUi(
prompt.clear()
}
private fun openFile(path: String) {
cs.launch {
workspaces.openPath(workspace.directory, path)
}
}
private fun onStateChanged(state: SessionState) {
prompt.setBusy(state.isBusy())
refresh()
@@ -46,6 +46,7 @@ class SessionMessageListPanel(
private val question: QuestionView? = null,
private val permission: PermissionView? = null,
private val login: LoginRequiredView? = null,
private val openFile: (String) -> Unit,
) : SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
JBUI.insets(
@@ -173,7 +174,7 @@ class SessionMessageListPanel(
// ------ private event handlers ------
private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) {
val tv = TurnView(turn.id, style)
val tv = TurnView(turn.id, openFile, style)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -224,7 +225,7 @@ class SessionMessageListPanel(
removeAll()
for (turn in model.turns()) {
val tv = TurnView(turn.id, style)
val tv = TurnView(turn.id, openFile, style)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -27,12 +27,13 @@ import com.intellij.util.ui.JBUI
*/
class MessageView(
val msg: Message,
private val openFile: (String) -> Unit,
private var style: SessionEditorStyle = SessionEditorStyle.current(),
) : ai.kilocode.client.session.ui.SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
), SessionEditorStyleTarget, SessionView {
constructor(msg: Message) : this(msg, SessionEditorStyle.current())
constructor(msg: Message, openFile: (String) -> Unit) : this(msg, openFile, SessionEditorStyle.current())
val role: String get() = msg.info.role
@@ -54,7 +55,7 @@ class MessageView(
for ((_, content) in msg.parts) {
if (content is StepFinish) continue
if (isHidden(content)) continue
val view = ViewFactory.create(content)
val view = ViewFactory.create(content, openFile)
view.applyStyle(style)
parts[content.id] = view
add(view)
@@ -94,7 +95,7 @@ class MessageView(
refresh()
return
}
val view = ViewFactory.create(content)
val view = ViewFactory.create(content, openFile)
view.applyStyle(style)
parts[content.id] = view
add(view)
@@ -106,7 +107,7 @@ class MessageView(
val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount
parts.remove(content.id)
remove(existing)
val view = ViewFactory.create(content)
val view = ViewFactory.create(content, openFile)
view.applyStyle(style)
parts[content.id] = view
add(view, at)
@@ -144,7 +145,7 @@ class MessageView(
for ((_, content) in msg.parts) {
if (content is StepFinish) continue
if (isHidden(content)) continue
val view = ViewFactory.create(content)
val view = ViewFactory.create(content, openFile)
view.applyStyle(style)
parts[content.id] = view
add(view)
@@ -9,7 +9,7 @@ import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.md.MdView
import java.awt.BorderLayout
class PlanExitView(tool: Tool) : PartView() {
class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() {
companion object {
fun canRender(tool: Tool): Boolean = tool.name == "plan_exit" && tool.state == ToolExecState.COMPLETED
}
@@ -22,6 +22,7 @@ class PlanExitView(tool: Tool) : PartView() {
init {
layout = BorderLayout()
isOpaque = false
md.addLinkListener { openFile(it.href) }
add(md.component, BorderLayout.CENTER)
applyStyle(SessionEditorStyle.current())
sync()
@@ -43,6 +44,8 @@ class PlanExitView(tool: Tool) : PartView() {
fun markdown(): String = md.markdown()
internal fun simulateLink(href: String) = md.simulateLink(href)
private fun sync() {
val plan = plan(item)
val text = listOf(KiloBundle.message("session.part.plan.ready"), link(plan))
@@ -18,10 +18,11 @@ import com.intellij.util.ui.JBUI
*/
class TurnView(
val id: String,
private val openFile: (String) -> Unit,
private var style: SessionEditorStyle = SessionEditorStyle.current(),
) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget {
constructor(id: String) : this(id, SessionEditorStyle.current())
constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current())
private val messages = LinkedHashMap<String, MessageView>()
@@ -31,7 +32,7 @@ class TurnView(
/** Add a new [MessageView] for [msg] at the end of this turn. */
fun addMessage(msg: Message): MessageView {
val view = MessageView(msg, style)
val view = MessageView(msg, openFile, style)
messages[msg.info.id] = view
add(view)
revalidate()
@@ -20,11 +20,11 @@ import ai.kilocode.client.session.model.Tool
* 3. Add a branch here — the exhaustive `when` will surface the gap as a compile error.
*/
object ViewFactory {
fun create(content: Content): PartView = when (content) {
fun create(content: Content, openFile: (String) -> Unit): PartView = when (content) {
is Text -> TextView(content)
is Reasoning -> ReasoningView(content)
is Tool -> when {
PlanExitView.canRender(content) -> PlanExitView(content)
PlanExitView.canRender(content) -> PlanExitView(content, openFile)
QuestionResultView.canRender(content) -> QuestionResultView(content)
else -> ToolView(content)
}
@@ -0,0 +1,70 @@
package ai.kilocode.client.app
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@Suppress("UnstableApiUsage")
class KiloWorkspaceServiceTest : BasePlatformTestCase() {
private lateinit var scope: CoroutineScope
private lateinit var rpc: FakeWorkspaceRpcApi
private lateinit var service: KiloWorkspaceService
override fun setUp() {
super.setUp()
scope = CoroutineScope(SupervisorJob())
rpc = FakeWorkspaceRpcApi()
service = KiloWorkspaceService(scope, rpc)
}
override fun tearDown() {
try {
scope.cancel()
} finally {
super.tearDown()
}
}
fun `test openPath opens first file match`() = runBlocking {
rpc.fileMatches = listOf(
WorkspaceFileDto("/test/.kilo/plans/a.md", "a.md"),
WorkspaceFileDto("/other/.kilo/plans/a.md", "a.md"),
)
val ok = withContext(Dispatchers.Default) {
service.openPath("/test", ".kilo/plans/a.md")
}
assertTrue(ok)
assertEquals(listOf("/test" to ".kilo/plans/a.md"), rpc.fileCalls)
assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened)
}
fun `test openPath returns false when no match exists`() = runBlocking {
val ok = withContext(Dispatchers.Default) {
service.openPath("/test", ".kilo/plans/missing.md")
}
assertFalse(ok)
assertEquals(listOf("/test" to ".kilo/plans/missing.md"), rpc.fileCalls)
assertTrue(rpc.opened.isEmpty())
}
fun `test openPath returns false when backend open fails`() = runBlocking {
rpc.fileMatches = listOf(WorkspaceFileDto("/test/.kilo/plans/a.md", "a.md"))
rpc.openResult = false
val ok = withContext(Dispatchers.Default) {
service.openPath("/test", ".kilo/plans/a.md")
}
assertFalse(ok)
assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened)
}
}
@@ -382,7 +382,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
created.add(workspace.directory to id)
refs.add(ref)
SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner).also {
SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner, workspaces = workspaces).also {
ui.add(it)
Disposer.register(it) { ui.remove(it) }
}
@@ -23,6 +23,7 @@ import kotlinx.coroutines.cancel
class SessionUiFactoryTest : BasePlatformTestCase() {
private lateinit var scope: CoroutineScope
private lateinit var workspace: Workspace
private lateinit var workspaces: KiloWorkspaceService
private lateinit var sessions: KiloSessionService
private lateinit var app: KiloAppService
@@ -33,7 +34,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
app = KiloAppService(scope, FakeAppRpcApi().also {
it.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
})
val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also {
workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also {
it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)
})
workspace = workspaces.workspace("/test")
@@ -56,7 +57,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
fun `test factory wires open callback`() {
val manager = FakeManager()
val rpc = session("ses_1")
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager)
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces)
val controller = controller(ui)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
@@ -69,7 +70,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
fun `test empty panel opens through SessionRef via controller`() {
val manager = FakeManager()
val rpc = session("ses_1")
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager)
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces)
val controller = controller(ui)
val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc))
@@ -81,7 +82,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
fun `test empty panel show history routes through manager`() {
val manager = FakeManager()
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager)
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces)
val controller = controller(ui)
val panel = ai.kilocode.client.session.ui.EmptySessionPanel(
testRootDisposable,
@@ -280,7 +280,12 @@ class SessionUiLayoutTest : SessionUiTestBase() {
fun `test existing session history shows header above scroll pane`() {
rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList()))
ui = SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.Local("ses_test"), displayMs = 0).apply {
ui = SessionUi(
project, workspace, sessions, app, scope,
ref = SessionRef.Local("ses_test"),
displayMs = 0,
workspaces = workspaces,
).apply {
setSize(800, 600)
}
settle()
@@ -88,7 +88,13 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
override fun openSession(ref: SessionRef) = fn(ref)
}
}
return SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, manager = manager).apply {
return SessionUi(
project, workspace, sessions, app, scope,
ref = SessionRef.from(id),
displayMs = displayMs,
manager = manager,
workspaces = workspaces,
).apply {
setSize(800, 600)
}
}
@@ -10,6 +10,7 @@ import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.PlanExitView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.client.session.views.question.QuestionView
@@ -37,12 +38,13 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
private lateinit var model: SessionModel
private lateinit var parent: Disposable
private lateinit var panel: SessionMessageListPanel
private val openFile: (String) -> Unit = {}
override fun setUp() {
super.setUp()
parent = Disposer.newDisposable("test")
model = SessionModel()
panel = SessionMessageListPanel(model, parent)
panel = SessionMessageListPanel(model, parent, openFile = openFile)
}
override fun tearDown() {
@@ -449,6 +451,29 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertEquals(listOf("tp1"), mv.partIds())
}
fun `test completed plan update replaces tool view and keeps open file action`() {
val opened = mutableListOf<String>()
val item = SessionMessageListPanel(model, parent, openFile = { opened.add(it) })
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", toolPart("tp1", "a1", "plan_exit", "call1", state = "running"))
val mv = item.findMessage("a1")!!
assertTrue(mv.part("tp1") is ToolView)
model.updateContent(
"a1",
toolPart(
"tp1", "a1", "plan_exit", "call1", state = "completed",
metadata = mapOf("plan" to ".kilo/plans/x.md"),
),
)
val view = mv.part("tp1") as PlanExitView
view.simulateLink(".kilo/plans/x.md")
assertEquals(listOf(".kilo/plans/x.md"), opened)
}
// ------ helpers ------
private fun panelWithPrompts(): SessionMessageListPanel {
@@ -461,7 +486,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
reply = { _, _ -> },
)
val l = LoginRequiredView(openProfile = {}, dismiss = {})
return SessionMessageListPanel(model, parent, q, p, l)
return SessionMessageListPanel(model, parent, q, p, l, openFile)
}
private inline fun <reified T> find(root: Container): T? = findCls(root, T::class.java)
@@ -28,7 +28,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
super.setUp()
parent = Disposer.newDisposable("test")
model = SessionModel()
panel = SessionMessageListPanel(model, parent)
panel = SessionMessageListPanel(model, parent, openFile = {})
}
override fun tearDown() {
@@ -12,14 +12,14 @@ class PlanExitViewTest : BasePlatformTestCase() {
metadata = mapOf("plan" to ".kilo/plans/x.md")
}
val view = PlanExitView(tool)
val view = PlanExitView(tool) {}
assertEquals("Plan is ready [.kilo/plans/x.md](.kilo/plans/x.md)", view.markdown())
}
fun `test view factory replaces running tool with plan exit view when completed`() {
val running = tool(ToolExecState.RUNNING)
val existing = ViewFactory.create(running)
val existing = ViewFactory.create(running) {}
assertTrue(existing is ToolView)
val done = tool(ToolExecState.COMPLETED).apply {
@@ -27,7 +27,19 @@ class PlanExitViewTest : BasePlatformTestCase() {
}
assertTrue(ViewFactory.shouldReplace(existing, done))
assertTrue(ViewFactory.create(done) is PlanExitView)
assertTrue(ViewFactory.create(done) {} is PlanExitView)
}
fun `test clicking plan link opens href`() {
val opened = mutableListOf<String>()
val tool = tool(ToolExecState.COMPLETED).apply {
metadata = mapOf("plan" to ".kilo/plans/my%20plan.md")
}
val view = PlanExitView(tool) { opened.add(it) }
view.simulateLink(".kilo/plans/my%20plan.md")
assertEquals(listOf(".kilo/plans/my%20plan.md"), opened)
}
private fun tool(state: ToolExecState) = Tool("prt_plan", "plan_exit", toolKind("plan_exit")).apply {
@@ -129,7 +129,7 @@ class QuestionResultViewTest : BasePlatformTestCase() {
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = ViewFactory.create(tool)
val view = ViewFactory.create(tool) {}
assertTrue(view is QuestionResultView)
}
@@ -139,14 +139,14 @@ class QuestionResultViewTest : BasePlatformTestCase() {
input = emptyMap(),
metadata = emptyMap(),
)
val view = ViewFactory.create(tool)
val view = ViewFactory.create(tool) {}
assertTrue(view is ToolView)
}
fun `test view factory falls back to tool view for running question`() {
val tool = runningTool("question")
val view = ViewFactory.create(tool)
val view = ViewFactory.create(tool) {}
assertTrue(view is ToolView)
}
@@ -17,23 +17,24 @@ import com.intellij.util.ui.JBUI
*/
@Suppress("UnstableApiUsage")
class TurnViewTest : BasePlatformTestCase() {
private val openFile: (String) -> Unit = {}
// ------ TurnView ------
fun `test new TurnView is empty`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
assertTrue(tv.messageIds().isEmpty())
}
fun `test addMessage appends and returns view`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
val mv = tv.addMessage(msg("u1", "user"))
assertEquals("u1", mv.msg.info.id)
assertEquals(listOf("u1"), tv.messageIds())
}
fun `test addMessage preserves insertion order`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
tv.addMessage(msg("u1", "user"))
tv.addMessage(msg("a1", "assistant"))
tv.addMessage(msg("a2", "assistant"))
@@ -41,7 +42,7 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test messageView returns the view for a given id`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
tv.addMessage(msg("u1", "user"))
val mv = tv.messageView("u1")
assertNotNull(mv)
@@ -49,12 +50,12 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test messageView returns null for unknown id`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
assertNull(tv.messageView("missing"))
}
fun `test removeMessage removes the view`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
tv.addMessage(msg("u1", "user"))
tv.addMessage(msg("a1", "assistant"))
@@ -65,14 +66,14 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test removeMessage unknown id is noop`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
tv.addMessage(msg("u1", "user"))
tv.removeMessage("nope")
assertEquals(listOf("u1"), tv.messageIds())
}
fun `test dump produces correct format`() {
val tv = TurnView("u1")
val tv = TurnView("u1", openFile)
tv.addMessage(msg("u1", "user"))
tv.addMessage(msg("a1", "assistant"))
assertEquals("user#u1, assistant#a1", tv.dump())
@@ -81,22 +82,22 @@ class TurnViewTest : BasePlatformTestCase() {
// ------ MessageView ------
fun `test new MessageView is empty`() {
val mv = MessageView(msg("u1", "user"))
val mv = MessageView(msg("u1", "user"), openFile)
assertTrue(mv.partIds().isEmpty())
}
fun `test MessageView for user message has user role`() {
val mv = MessageView(msg("u1", "user"))
val mv = MessageView(msg("u1", "user"), openFile)
assertEquals("user", mv.role)
}
fun `test MessageView for assistant message has assistant role`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
assertEquals("assistant", mv.role)
}
fun `test upsertPart adds a new TextView for Text content`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
val text = ai.kilocode.client.session.model.Text("p1")
text.content.append("hello")
mv.upsertPart(text)
@@ -106,7 +107,7 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test upsertPart updates existing part rather than adding duplicate`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
val t1 = ai.kilocode.client.session.model.Text("p1").also { it.content.append("v1") }
mv.upsertPart(t1)
@@ -119,7 +120,7 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test removePart removes the renderer`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("x") })
mv.removePart("p1")
@@ -128,13 +129,13 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test removePart unknown id is noop`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
mv.removePart("none")
assertTrue(mv.partIds().isEmpty())
}
fun `test appendDelta reaches TextView`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("hello ") })
mv.appendDelta("p1", "world")
@@ -144,7 +145,7 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test appendDelta for unknown part id is noop`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
// Must not throw
mv.appendDelta("unknown", "delta")
}
@@ -154,7 +155,7 @@ class TurnViewTest : BasePlatformTestCase() {
val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") }
message.parts["p1"] = text
val mv = MessageView(message)
val mv = MessageView(message, openFile)
assertEquals(listOf("p1"), mv.partIds())
assertTrue(mv.part("p1") is TextView)
@@ -166,7 +167,7 @@ class TurnViewTest : BasePlatformTestCase() {
val tool = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED }
message.parts["r1"] = reasoning
message.parts["t1"] = tool
val mv = MessageView(message)
val mv = MessageView(message, openFile)
mv.setSize(400, 200)
mv.doLayout()
@@ -178,7 +179,7 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test consecutive messages use shared compact gap`() {
val tv = TurnView("u1")
val tv = TurnView("u1", openFile)
tv.addMessage(msg("u1", "user").also { msg ->
msg.parts["t1"] = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED }
})
@@ -3,6 +3,7 @@ package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,6 +21,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING))
var reloads = 0
private set
var fileMatches = emptyList<WorkspaceFileDto>()
var openResult = true
val fileCalls = mutableListOf<Pair<String, String>>()
val opened = mutableListOf<String>()
override suspend fun resolveProjectDirectory(hint: String): String {
assertNotEdt("resolveProjectDirectory")
@@ -35,4 +40,16 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
assertNotEdt("reload")
reloads += 1
}
override suspend fun files(directory: String, path: String): List<WorkspaceFileDto> {
assertNotEdt("files")
fileCalls.add(directory to path)
return fileMatches
}
override suspend fun openFile(path: String): Boolean {
assertNotEdt("openFile")
opened.add(path)
return openResult
}
}
@@ -1,6 +1,7 @@
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.platform.rpc.RemoteApiProviderService
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
@@ -36,4 +37,10 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
/** Trigger a full reload of workspace data. */
suspend fun reload(directory: String)
/** Resolve [path] to matching files, scoped primarily to [directory]. */
suspend fun files(directory: String, path: String): List<WorkspaceFileDto>
/** Open an absolute backend file path in the IDE. */
suspend fun openFile(path: String): Boolean
}
@@ -0,0 +1,10 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
data class WorkspaceFileDto(
val path: String,
val name: String,
val directory: Boolean = false,
)