diff --git a/.changeset/jetbrains-checklist-styling.md b/.changeset/jetbrains-checklist-styling.md new file mode 100644 index 00000000000..97b74153486 --- /dev/null +++ b/.changeset/jetbrains-checklist-styling.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render JetBrains todo checklists with consistent text weight and higher-contrast checkboxes. diff --git a/.changeset/jetbrains-inline-code-foreground.md b/.changeset/jetbrains-inline-code-foreground.md new file mode 100644 index 00000000000..a2fc6bc6e1d --- /dev/null +++ b/.changeset/jetbrains-inline-code-foreground.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Match JetBrains inline code and file-reference link styling with VS Code, and render quotes with muted theme-aware styling. diff --git a/.changeset/jetbrains-popups.md b/.changeset/jetbrains-popups.md new file mode 100644 index 00000000000..f8e8a9d8cba --- /dev/null +++ b/.changeset/jetbrains-popups.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show JetBrains missing-file warnings without animation and always show shell command header popups for collapsed shell runs. diff --git a/.changeset/jetbrains-session-file-links.md b/.changeset/jetbrains-session-file-links.md new file mode 100644 index 00000000000..27fd17272a7 --- /dev/null +++ b/.changeset/jetbrains-session-file-links.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Open JetBrains session file links in the active workspace and hide sibling worktree matches. diff --git a/.changeset/jetbrains-session-link-hover.md b/.changeset/jetbrains-session-link-hover.md new file mode 100644 index 00000000000..2c93a0c0af1 --- /dev/null +++ b/.changeset/jetbrains-session-link-hover.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Clear stale session link hover styling when the transcript is scrolled. diff --git a/.changeset/jetbrains-tool-header-clipping.md b/.changeset/jetbrains-tool-header-clipping.md new file mode 100644 index 00000000000..6e10df4a239 --- /dev/null +++ b/.changeset/jetbrains-tool-header-clipping.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep JetBrains tool headers to a single clipped line. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 9d2ceab3f82..7d0311b6c61 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -13,6 +13,7 @@ import ai.kilocode.backend.workspace.KiloWorkspaceState import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.KiloWorkspaceRpcApi +import ai.kilocode.rpc.isManagedWorktreeStorage import ai.kilocode.rpc.dto.ConfigTargetDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -181,17 +182,11 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { override suspend fun files(directory: String, path: String): List { 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 base = file(clean(directory) ?: directory) ?: return emptyList() + val paths = if (file.isAbsolute) listOf(file) else listOf(base.resolve(file).normalize()) val found = linkedMapOf() for (target in paths) { + relativeWithinWorkspace(base, target) ?: continue val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: continue found[vf.path] = WorkspaceFileDto(vf.path, vf.name, vf.isDirectory) } @@ -223,7 +218,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { text.takeIf { it.isNotBlank() }?.take(DIFF_CAP) } - override suspend fun openFile(path: String): Boolean { + override suspend fun openFile(path: String, line: Int?, column: Int?): 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 @@ -231,7 +226,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { LOG.warn("No project available to open file: $path") return false } - navigate(project, vf) + navigate(project, vf, line, column) return true } @@ -302,9 +297,19 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { null } - private suspend fun navigate(project: Project, file: VirtualFile) = suspendCancellableCoroutine { cont -> + private suspend fun navigate(project: Project, file: VirtualFile, line: Int? = null, column: Int? = null) = suspendCancellableCoroutine { cont -> ApplicationManager.getApplication().invokeLater({ - OpenFileDescriptor(project, file).navigate(true) + val descriptor = if (line == null) { + OpenFileDescriptor(project, file) + } else { + OpenFileDescriptor( + project, + file, + (line - 1).coerceAtLeast(0), + (column?.minus(1))?.coerceAtLeast(0) ?: 0, + ) + } + descriptor.navigate(true) if (cont.isActive) cont.resume(Unit) }, ModalityState.any()) } @@ -329,7 +334,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { override fun acceptItem(item: NavigationItem): Boolean { val psi = item as? PsiFileSystemItem ?: return false val path = file(psi.virtualFile.path) ?: return false - return path.startsWith(base) && super.acceptItem(item) + return relativeWithinWorkspace(base, path) != null && super.acceptItem(item) } override fun loadInitialCheckBoxState(): Boolean = false @@ -390,7 +395,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { private fun fileDto(base: Path, vf: VirtualFile): WorkspaceFileDto? { val path = file(vf.path) ?: return null - val rel = relativeWithinBase(base, path) ?: return null + val rel = relativeWithinWorkspace(base, path) ?: return null return WorkspaceFileDto(rel, vf.name, vf.isDirectory) } @@ -488,3 +493,9 @@ internal fun relativeWithinBase(base: Path, target: Path): String? { val rel = base.relativize(path).toString().replace('\\', '/') return rel.ifBlank { null } } + +internal fun relativeWithinWorkspace(base: Path, target: Path): String? { + val rel = relativeWithinBase(base, target) ?: return null + if (isManagedWorktreeStorage(rel)) return null + return rel +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt index 3da5f00e307..e52355ecaa5 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/WorkspacePathScopingTest.kt @@ -58,6 +58,40 @@ class WorkspacePathScopingTest { assertNull(relativeWithinBase(base, sibling.resolve("A.kt"))) } + @Test + fun `workspace scope keeps normal project and kilo plan files`() { + assertEquals("backend/src/Main.java", relativeWithinWorkspace(base, at("backend", "src", "Main.java"))) + assertEquals(".kilo/plans/x.md", relativeWithinWorkspace(base, at(".kilo", "plans", "x.md"))) + } + + @Test + fun `workspace scope rejects managed worktree storage from main checkout`() { + assertNull(relativeWithinWorkspace(base, at(".kilo", "worktrees"))) + assertNull(relativeWithinWorkspace(base, at(".kilo", "worktrees", "foo", "backend", "src", "Main.java"))) + } + + @Test + fun `workspace scope allows files inside the active worktree`() { + val root = at(".kilo", "worktrees", "foo") + + assertEquals("backend/src/Main.java", relativeWithinWorkspace(root, root.resolve("backend/src/Main.java"))) + } + + @Test + fun `workspace scope rejects sibling worktrees from active worktree`() { + val root = at(".kilo", "worktrees", "foo") + val sibling = at(".kilo", "worktrees", "bar", "backend", "src", "Main.java") + + assertNull(relativeWithinWorkspace(root, sibling)) + } + + @Test + fun `workspace scope rejects nested managed worktree storage`() { + val root = at(".kilo", "worktrees", "foo") + + assertNull(relativeWithinWorkspace(root, root.resolve(".kilo/worktrees/bar/backend/src/Main.java"))) + } + @Test fun `normalizes encoded file URLs`() { val path = base.resolve("dir with spaces").resolve("A.kt") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index b19cc6a76e4..5836ae0cead 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -158,10 +158,10 @@ class KiloWorkspaceService internal constructor( } } - suspend fun openPath(directory: String, path: String): Boolean { + suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null): Boolean { val match = files(directory, path).firstOrNull() ?: return false return try { - call { openFile(match.path) } + call { openFile(match.path, line, column) } } catch (e: Exception) { LOG.warn("workspace file open failed for path=${match.path}", e) false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt new file mode 100644 index 00000000000..0ba88641c5c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt @@ -0,0 +1,200 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.rpc.isManagedWorktreeStorage +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.icons.AllIcons +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.ui.MessageType +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.awt.RelativePoint +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.xml.util.XmlStringUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JList + +typealias SessionFileOpener = (href: String, anchor: RelativePoint?) -> Unit + +fun MdView.LinkEvent.anchor(): RelativePoint? { + val component = component ?: return null + val point = point ?: return null + return RelativePoint(component, point) +} + +fun openSessionLink(event: MdView.LinkEvent, openFile: SessionFileOpener, openUrl: (String) -> Unit) { + if (SessionFileLinks.isFileHref(event.href)) { + openFile(event.href, event.anchor()) + return + } + openUrl(event.href) +} + +class SessionFileLinks( + private val dir: String, + private val service: KiloWorkspaceService, + private val scope: CoroutineScope, + private val root: JComponent, + private val openUrl: (String) -> Unit, + private val send: (String, Map) -> Unit = Telemetry::send, +) { + fun open(href: String, anchor: RelativePoint?) { + if (!isFileHref(href)) { + openUrl(href) + return + } + val target = parse(href) + scope.launch { + val ok = service.openPath(dir, target.path, target.line, target.column) + if (ok) { + track(target, "direct") + return@launch + } + val found = service.searchFiles(dir, decode(name(target.path)), FILE_SEARCH_LIMIT) + .files + .filterNot { it.directory } + .filterNot { isManagedWorktreeStorage(it.path) } + .ranked(target.path) + when (val result = decide(false, found)) { + Resolution.Opened -> Unit + is Resolution.OpenDirect -> { + val opened = service.openPath(dir, result.file.path, target.line, target.column) + track(target, if (opened) "search_direct" else "missing") + } + is Resolution.Choose -> { + track(target, "chooser") + withContext(Dispatchers.Main) { choose(result.files, target, anchor) } + } + Resolution.Missing -> { + track(target, "missing") + withContext(Dispatchers.Main) { missing(target.path, anchor) } + } + } + } + } + + private fun track(target: Target, result: String) = send( + "File Link Opened", + mapOf( + "surface" to "session", + "kind" to "file", + "hasLine" to (target.line != null).toString(), + "hasColumn" to (target.column != null).toString(), + "result" to result, + ), + ) + + @RequiresEdt + private fun choose(files: List, target: Target, anchor: RelativePoint?) { + val popup = JBPopupFactory.getInstance() + .createPopupChooserBuilder(files) + .setRenderer(FileRenderer()) + .setItemChosenCallback { file -> + scope.launch { service.openPath(dir, file.path, target.line, target.column) } + } + .createPopup() + popup.show(anchor ?: RelativePoint.getCenterOf(root)) + } + + @RequiresEdt + private fun missing(path: String, anchor: RelativePoint?) { + JBPopupFactory.getInstance() + .createHtmlTextBalloonBuilder(KiloBundle.message("session.file.missing", XmlStringUtil.escapeString(path)), MessageType.WARNING, null) + .setAnimationCycle(0) + .createBalloon() + .also { it.setAnimationEnabled(false) } + .show(anchor ?: RelativePoint.getCenterOf(root), Balloon.Position.above) + } + + private class FileRenderer : ColoredListCellRenderer() { + override fun customizeCellRenderer( + list: JList, + value: WorkspaceFileDto?, + index: Int, + selected: Boolean, + hasFocus: Boolean, + ) { + val file = value ?: return + icon = icon(file) + append(file.name) + val parent = parent(file.path) + if (parent.isNotBlank()) append(" $parent", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + } + + sealed interface Resolution { + data object Opened : Resolution + data class OpenDirect(val file: WorkspaceFileDto) : Resolution + data class Choose(val files: List) : Resolution + data object Missing : Resolution + } + + data class Target(val path: String, val line: Int? = null, val column: Int? = null) + + companion object { + private const val FILE_SEARCH_LIMIT = 50 + private val LINE = Regex(":(\\d+)(?:-\\d+)?(?::(\\d+))?$") + private val SCHEME = Regex("^([A-Za-z][A-Za-z0-9+.-]*):") + + fun parse(href: String): Target { + val match = LINE.find(href) ?: return Target(href) + return Target( + href.substring(0, match.range.first), + match.groupValues[1].toIntOrNull(), + match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull(), + ) + } + + fun isFileHref(href: String): Boolean { + val scheme = SCHEME.find(href)?.groupValues?.getOrNull(1) ?: return true + if (scheme.length == 1) return true + return scheme.equals("file", ignoreCase = true) + } + + fun decide(openOk: Boolean, candidates: List): Resolution { + if (openOk) return Resolution.Opened + if (candidates.isEmpty()) return Resolution.Missing + if (candidates.size == 1) return Resolution.OpenDirect(candidates.single()) + return Resolution.Choose(candidates) + } + + private fun icon(file: WorkspaceFileDto): Icon = when { + file.directory -> AllIcons.Nodes.Folder + else -> FileTypeManager.getInstance().getFileTypeByFileName(file.name).icon ?: AllIcons.FileTypes.Text + } + + private fun name(path: String): String { + val clean = path.trimEnd('/', '\\') + val idx = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\')) + if (idx < 0) return clean + return clean.substring(idx + 1) + } + + private fun parent(path: String): String { + val idx = path.lastIndexOf('/') + if (idx <= 0) return "" + return path.substring(0, idx) + } + + private fun decode(value: String): String = runCatching { + URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8) + }.getOrDefault(value) + + private fun List.ranked(path: String): List { + val target = path.trimStart('/', '\\') + return sortedByDescending { it.path == target || it.path.endsWith("/$target") } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 7ae7dc9cc1a..a1a51cf8026 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -25,6 +25,7 @@ import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.prompt.SlashAction import ai.kilocode.client.session.ui.prompt.mentionParts as promptMentionParts import ai.kilocode.client.session.ui.account.SessionAccountOverlay +import ai.kilocode.client.session.ui.popup.HeaderPopupController import ai.kilocode.client.session.ui.SessionDropOverlay import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.SessionMessageListPanel @@ -149,6 +150,7 @@ class SessionUi( private lateinit var root: SessionRootPanel + private lateinit var fileLinks: SessionFileLinks private lateinit var account: SessionAccountOverlay private lateinit var drop: SessionDropOverlay private lateinit var overlay: SessionHoverCopyOverlay @@ -182,6 +184,7 @@ class SessionUi( private var modalFocus: (() -> JComponent)? = null private var style = SessionEditorStyle.current() private val selection = SessionSelection() + private val popup = HeaderPopupController(timers) private val provider = object : TextCopyProvider() { override fun getActionUpdateThread() = ActionUpdateThread.EDT @@ -195,6 +198,7 @@ class SessionUi( private var disposed = false init { + Disposer.register(this, popup) buildUi() Disposer.register(this, selection) scroll.show(body(controller.model.state)) @@ -264,6 +268,7 @@ class SessionUi( private fun buildUi() { root = SessionRootPanel() + fileLinks = SessionFileLinks(workspace.directory, workspaces, cs, root, ::openUrl) SessionContextMenu.install(root, this) migrationOverlay = MigrationOverlayPanel().apply { @@ -331,17 +336,22 @@ class SessionUi( question, permission, login, - ::openFile, + fileLinks::open, ::openUrl, selection, ::openAttachment, repo = workspace.directory, resize = { anchor, fn -> scroll.preserve(anchor, fn) }, - ) + ).also { + it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } + } header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) - scroll.onScroll = overlay::clear + scroll.onScroll = { + overlay.clear() + popup.hideAll() + } completion = KiloPromptCompletionProvider( workspace = workspace, @@ -469,7 +479,7 @@ class SessionUi( is SessionControllerEvent.ViewChanged.ShowSession -> { empty = null - scroll.show(messageBody) + scroll.show(body(controller.model.state)) } is SessionControllerEvent.AppChanged -> { @@ -553,7 +563,10 @@ class SessionUi( private fun bindStyle() { addHierarchyListener { event -> if ((event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong()) == 0L) return@addHierarchyListener - if (!isShowing) return@addHierarchyListener + if (!isShowing) { + popup.hideAll() + return@addHierarchyListener + } applyStyleIfThemeChanged() } @@ -600,6 +613,7 @@ class SessionUi( private fun resumeOpen() { if (!pending || !opening || !this::scroll.isInitialized) return if (width <= 0 || height <= 0) return + if (body(controller.model.state) !== messageBody) return pending = false scroll.openBottom { opening = false @@ -679,12 +693,6 @@ class SessionUi( ) } - private fun openFile(path: String) { - cs.launch { - workspaces.openPath(workspace.directory, path) - } - } - private fun openUrl(url: String) { BrowserUtil.browse(url) } @@ -721,7 +729,7 @@ class SessionUi( return } LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$path") - openFile(path) + fileLinks.open(path, null) return } LOG.info("kind=attachment-open route=browser session=${controller.id ?: "none"} message=$messageId part=${item.id} url=${attachmentUrl(url)}") @@ -807,6 +815,7 @@ class SessionUi( override fun dispose() { disposed = true hide.stop() + popup.hideAll() modalFocus = null empty = null if (this::root.isInitialized) root.setModalContent(null) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index c3b7f65d547..9d0c9186e5d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState @@ -51,7 +52,7 @@ class SessionMessageListPanel( private val question: QuestionView? = null, private val permission: PermissionView? = null, private val login: LoginRequiredView? = null, - private val openFile: (String) -> Unit, + private val openFile: SessionFileOpener, private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> ai.kilocode.client.session.views.AttachmentView.openDefault(item, openFile, openUrl) }, @@ -74,6 +75,8 @@ class SessionMessageListPanel( private var hiddenTool: ToolCallRef? = null private var hovered: PartView? = null + var onHover: ((PartView, Boolean) -> Unit)? = null + /** Progress footer — always the last child inside the scroll. */ val progress = ProgressPanel(model, parent) @@ -365,15 +368,19 @@ class SessionMessageListPanel( if (prev === view) return hovered = view prev?.setHovered(false) + onHover?.invoke(view, true) return } - if (hovered === view) hovered = null + if (hovered !== view) return + hovered = null + onHover?.invoke(view, false) } private fun clearHover() { val view = hovered ?: return hovered = null view.setHovered(false) + onHover?.invoke(view, false) } override fun applyStyle(style: SessionEditorStyle) { @@ -396,6 +403,7 @@ class SessionMessageListPanel( turnViews.clear() msgToTurn.clear() msgToView.clear() + onHover = null removeAll() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt new file mode 100644 index 00000000000..e4dc8665544 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt @@ -0,0 +1,17 @@ +package ai.kilocode.client.session.ui.popup + +import com.intellij.openapi.Disposable +import java.awt.Color +import javax.swing.JComponent + +class HeaderPopupRequest( + val anchor: JComponent, + val build: () -> HeaderPopupBody, + val shown: () -> Unit = {}, +) + +class HeaderPopupBody( + val component: JComponent, + val disposable: Disposable, + val background: Color, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt new file mode 100644 index 00000000000..5c3290e2d76 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt @@ -0,0 +1,155 @@ +package ai.kilocode.client.session.ui.popup + +import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers +import com.intellij.openapi.Disposable +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.JBPopupListener +import com.intellij.openapi.ui.popup.LightweightWindowEvent +import com.intellij.openapi.util.Disposer +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.hover.HoverListener +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Component +import java.awt.Point + +/** + * Shows a single header popup after a short hover dwell and hides it after a short grace period. + * + * Hover state is tracked as two booleans — [onHeader] for the originating header row and [onPopup] + * for the balloon subtree — so the show/hide decision is independent of the order platform enter and + * exit events arrive in. The popup is kept alive while the mouse is over either surface, which lets + * the user move from the header into the popup without it disappearing. + * + * Popup subtree hover is detected via [HoverListener] (an experimental IntelliJ API) so the nested + * editor counts as "inside the popup". + */ +class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { + private var target: PartView? = null + private var balloon: Balloon? = null + private var body: Disposable? = null + private var guard: Disposable? = null + private var onHeader = false + private var onPopup = false + private val showTimer = timers.timer(SHOW_MS, repeats = false) { display() } + private val hideTimer = timers.timer(HIDE_MS, repeats = false) { hideAll() } + + @RequiresEdt + fun show(view: PartView) { + if (target === view) { + onHeader = true + reevaluate() + return + } + hideAll() + target = view + guard = object : Disposable { + override fun dispose() { + if (guard === this) guard = null + if (target === view) hideAll() + } + }.also { Disposer.register(view, it) } + onHeader = true + showTimer.restart() + } + + @RequiresEdt + fun notifyExit(view: PartView) { + if (target !== view) return + onHeader = false + reevaluate() + } + + @RequiresEdt + fun hideAll() { + showTimer.stop() + hideTimer.stop() + onHeader = false + onPopup = false + val popup = balloon + val item = body + val hook = guard + target = null + balloon = null + body = null + guard = null + hook?.let(Disposer::dispose) + popup?.hide() + item?.let(Disposer::dispose) + } + + @RequiresEdt + override fun dispose() { + hideAll() + } + + @RequiresEdt + private fun popupEntered() { + onPopup = true + reevaluate() + } + + @RequiresEdt + private fun popupExited() { + onPopup = false + reevaluate() + } + + @RequiresEdt + private fun reevaluate() { + if (onHeader || onPopup) { + hideTimer.stop() + return + } + if (balloon == null) hideAll() else hideTimer.restart() + } + + @RequiresEdt + private fun display() { + val view = target ?: return + if (!onHeader && !onPopup) return hideAll() + val req = view.headerPopup() ?: return hideAll() + val built = req.build() + val popup = JBPopupFactory.getInstance() + .createBalloonBuilder(built.component) + .setFillColor(built.background) + .setBorderColor(UiStyle.Balloon.border()) + .setBorderInsets(UiStyle.Balloon.insets()) + .setPointerSize(UiStyle.Balloon.pointer()) + .setCornerRadius(UiStyle.Balloon.arc()) + .setHideOnClickOutside(true) + .setHideOnKeyOutside(true) + .setHideOnFrameResize(true) + .setFadeoutTime(0) + .setAnimationCycle(0) + .createBalloon() + + popup.setAnimationEnabled(false) + popup.addListener(object : JBPopupListener { + override fun onClosed(event: LightweightWindowEvent) { + if (body !== built.disposable) return + hideAll() + } + }) + + object : HoverListener() { + override fun mouseEntered(component: Component, x: Int, y: Int) = popupEntered() + override fun mouseMoved(component: Component, x: Int, y: Int) = Unit + override fun mouseExited(component: Component) = popupExited() + }.addTo(built.component, built.disposable) + + balloon = popup + body = built.disposable + val point = RelativePoint(req.anchor, Point(req.anchor.width, req.anchor.height / 2)) + popup.show(point, Balloon.Position.atRight) + req.shown() + } + + private companion object { + const val SHOW_MS = 500 + const val HIDE_MS = 250 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index f6ad45966f6..4b0b665e165 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -113,6 +113,22 @@ object SessionUiStyle { const val BODY_HORIZONTAL_PADDING = 8 } + /** Markdown colors that mirror Kilo's VS Code webview tokens. */ + object Markdown { + fun string(): Color = JBColor.namedColor( + "Kilo.Session.Markdown.String", + JBColor(0xA31515, 0xCE9178), + ) + } + + object Todo { + fun checkBg(): Color = JBColor.namedColor("Kilo.Session.Todo.Checkbox.Background", Color.WHITE) + + fun checkFg(): Color = JBColor.namedColor("Kilo.Session.Todo.Checkbox.Foreground", Color(0x1F, 0x23, 0x28)) + + fun checkBorder(): Color = UiStyle.Colors.contentBorder() + } + /** Message container roles and user bubble geometry. */ object Message { const val USER_ROLE = "user" @@ -136,6 +152,11 @@ object SessionUiStyle { fun topPadding(): Int = VIEWPORT_TOP_PADDING } + object Popup { + const val MAX_LINES = 15 + const val MAX_WIDTH = 520 + } + /** Permission session-view command preview limits. */ object Permission { const val COMMAND_LINES = 3 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt index 50f07635b1a..95de4237000 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.SessionFileLinks +import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.ui.attachment.AttachmentCard @@ -17,7 +19,7 @@ class AttachmentView( ) : PartView() { constructor( item: FileAttachment, - openFile: (String) -> Unit, + openFile: SessionFileOpener, openUrl: (String) -> Unit, ) : this(item, { openDefault(it, openFile, openUrl) }) @@ -54,12 +56,16 @@ class AttachmentView( private fun same(next: FileAttachment) = item.mime == next.mime && item.url == next.url && item.filename == next.filename companion object { - fun openDefault(item: FileAttachment, openFile: (String) -> Unit, openUrl: (String) -> Unit) { + fun openDefault(item: FileAttachment, openFile: SessionFileOpener, openUrl: (String) -> Unit) { val url = item.url.takeIf { it.isNotBlank() } ?: return val uri = runCatching { URI.create(url) }.getOrNull() ?: return if (uri.scheme == "file") { val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: return - openFile(path) + openFile(path, null) + return + } + if (SessionFileLinks.isFileHref(url)) { + openFile(url, null) return } openUrl(url) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 1335d1298c7..a16579f6fc8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Message @@ -43,7 +44,7 @@ import javax.swing.SwingUtilities */ class MessageView( val msg: Message, - private val openFile: (String) -> Unit, + private val openFile: SessionFileOpener, private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, @@ -55,8 +56,6 @@ class MessageView( JBUI.scale(SessionUiStyle.SessionLayout.GAP), ), Disposable, SessionEditorStyleTarget, SessionView { - constructor(msg: Message, openFile: (String) -> Unit) : this(msg, openFile, SessionEditorStyle.current()) - val role: String get() = msg.info.role override val sessionViewKind: SessionView.Kind @@ -490,7 +489,7 @@ class MessageView( } override fun mouseExited(e: MouseEvent) { - val point = root.mousePosition + val point = runCatching { root.mousePosition }.getOrNull() if (point != null && root.contains(point)) return if (inside(root, e)) return setPromptHovered(false) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index 421b19f551d..d24ffbe887c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState @@ -8,11 +9,16 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdViewFactory +import ai.kilocode.client.session.openSessionLink import com.intellij.openapi.util.Disposer import java.awt.BorderLayout -class PlanExitView(tool: Tool, openFile: (String) -> Unit, selection: SessionSelection? = null) : PartView() { - constructor(tool: Tool, openFile: (String) -> Unit) : this(tool, openFile, null) +class PlanExitView( + tool: Tool, + private val openFile: SessionFileOpener, + private val openUrl: (String) -> Unit = {}, + selection: SessionSelection? = null, +) : PartView() { companion object { fun canRender(tool: Tool): Boolean = tool.name == "plan_exit" && tool.state == ToolExecState.COMPLETED @@ -27,7 +33,7 @@ class PlanExitView(tool: Tool, openFile: (String) -> Unit, selection: SessionSel layout = BorderLayout() isOpaque = false Disposer.register(this, md) - md.addLinkListener { openFile(it.href) } + md.addLinkListener { openSessionLink(it, openFile, openUrl) } add(md.component, BorderLayout.CENTER) applyStyle(SessionEditorStyle.current()) sync() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt index 958a8c6cb1d..588c8d266de 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt @@ -1,22 +1,26 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.SessionFileLinks +import ai.kilocode.client.session.SessionFileOpener +import ai.kilocode.client.session.anchor import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.model.Content +import ai.kilocode.client.ui.md.MdView import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.util.ui.JBUI class PromptView( text: Text, - private val openFile: (String) -> Unit = {}, + private val openFile: SessionFileOpener = { _, _ -> }, private val openAttachment: (FileAttachment) -> Unit = {}, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, mentions: List = emptyList(), -) : TextView(text, transparent = true, openUrl = openUrl, selection = selection) { +) : TextView(text, transparent = true, openFile = openFile, openUrl = openUrl, selection = selection) { private var mentions = mentions private val buffer = StringBuilder(text.content) @@ -48,17 +52,17 @@ class PromptView( sync() } - override fun onLink(href: String) { - val mention = mentions.firstOrNull { it.path == href || path(it.path) == href } + override fun onLink(event: MdView.LinkEvent) { + val mention = mentions.firstOrNull { it.path == event.href || path(it.path) == event.href } if (mention != null) { mention.attachment?.let { openAttachment(it) return } - openFile(mention.path) + openFile(mention.path, event.anchor()) return } - super.onLink(href) + super.onLink(event) } override fun applyStyle(style: SessionEditorStyle) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 796e18bddbb..1b01027e57c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -3,6 +3,8 @@ package ai.kilocode.client.session.views import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionFileOpener +import ai.kilocode.client.session.openSessionLink import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -29,13 +31,14 @@ import javax.swing.SwingUtilities /** Renders reasoning as a secondary collapsible block. */ class ReasoningView( reasoning: Reasoning, + private val openFile: SessionFileOpener = { _, _ -> }, private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, private val parts: ReasoningParts = reasoningParts(selection), ) : SecondarySessionPartView( parts.header, - { parts.scroll(openUrl) }, + { parts.scroll(openFile, openUrl) }, expanded = reasoning.content.isNotBlank() && !reasoning.done, ) { @@ -46,7 +49,7 @@ class ReasoningView( @RequiresEdt get() { val fresh = !parts.bodyCreated() - val view = parts.md(openUrl) + val view = parts.md(openFile, openUrl) if (!fresh) return view registerBody(view) view.set(source) @@ -277,16 +280,16 @@ class ReasoningParts( fun bodyCreated() = body != null - fun md(openUrl: (String) -> Unit): MdView = body(openUrl).md + fun md(openFile: SessionFileOpener, openUrl: (String) -> Unit): MdView = body(openFile, openUrl).md - fun scroll(openUrl: (String) -> Unit): JBScrollPane = body(openUrl).scroll + fun scroll(openFile: SessionFileOpener, openUrl: (String) -> Unit): JBScrollPane = body(openFile, openUrl).scroll - private fun body(openUrl: (String) -> Unit): ReasoningBody { + private fun body(openFile: SessionFileOpener, openUrl: (String) -> Unit): ReasoningBody { val item = body if (item != null) return item val md = MdViewFactory.create(SessionEditorStyle.current(), selection).apply { opaque = false - addLinkListener { openUrl(it.href) } + addLinkListener { openSessionLink(it, openFile, openUrl) } } val panel = TrackPanel().apply { isOpaque = true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index 8eceececd97..529d8dfa943 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -1,5 +1,8 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.SessionFileLinks +import ai.kilocode.client.session.SessionFileOpener +import ai.kilocode.client.session.openSessionLink import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -20,6 +23,7 @@ import javax.swing.JButton open class TextView( text: Text, transparent: Boolean = true, + private val openFile: SessionFileOpener = { _, _ -> }, private val openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, ) : PartView() { @@ -35,7 +39,7 @@ open class TextView( isOpaque = false Disposer.register(this, md) md.opaque = !transparent - md.addLinkListener { onLink(it.href) } + md.addLinkListener { onLink(it) } applyStyle(SessionEditorStyle.current()) add(md.component, BorderLayout.CENTER) add(toolbar, BorderLayout.SOUTH) @@ -82,7 +86,9 @@ open class TextView( internal fun contentOpaque() = md.opaque - protected open fun onLink(href: String) = openUrl(href) + protected open fun onLink(event: MdView.LinkEvent) { + openSessionLink(event, openFile, openUrl) + } override fun applyStyle(style: SessionEditorStyle) { val font = styleFont(style) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 2723c754bde..81756b6bf9a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Message import ai.kilocode.client.session.ui.SessionLayoutPanel @@ -25,7 +26,7 @@ import javax.swing.JComponent */ class TurnView( val id: String, - private val openFile: (String) -> Unit, + private val openFile: SessionFileOpener, private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, @@ -35,8 +36,6 @@ class TurnView( private val hover: ((PartView, Boolean) -> Unit)? = null, ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget { - constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) - private val messages = LinkedHashMap() init { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 871004d2c2e..10886c589c0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.views.base.GenericView import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.question.QuestionResultView @@ -30,23 +31,23 @@ import ai.kilocode.client.session.views.todo.TodoWriteView object ViewFactory { fun create( content: Content, - openFile: (String) -> Unit, + openFile: SessionFileOpener, ): PartView = create(content, openFile, openUrl = {}, selection = null, repo = null) fun create( content: Content, - openFile: (String) -> Unit, + openFile: SessionFileOpener, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, repo: String? = null, openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) }, ): PartView = when (content) { - is Text -> TextView(content, openUrl = openUrl, selection = selection) - is Reasoning -> ReasoningView(content, openUrl = openUrl, selection = selection) + is Text -> TextView(content, openFile = openFile, openUrl = openUrl, selection = selection) + is Reasoning -> ReasoningView(content, openFile = openFile, openUrl = openUrl, selection = selection) is FileAttachment -> AttachmentView(content, openAttachment) is Tool -> when { TodoWriteView.canRender(content) -> TodoWriteView(content) - PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection) + PlanExitView.canRender(content) -> PlanExitView(content, openFile, openUrl, selection) QuestionResultView.canRender(content) -> QuestionResultView(content, selection) ShellToolView.canRender(content) -> ShellToolView(content, selection = selection) GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo) @@ -61,12 +62,12 @@ object ViewFactory { fun createUser( content: Content, - openFile: (String) -> Unit, + openFile: SessionFileOpener, ): PartView = createUser(content, openFile, openUrl = {}, selection = null, repo = null) fun createUser( content: Content, - openFile: (String) -> Unit, + openFile: SessionFileOpener, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, repo: String? = null, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt index b1b0256110f..7cca2a7e87b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt @@ -1,9 +1,11 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import com.intellij.openapi.Disposable +import com.intellij.util.concurrency.annotations.RequiresEdt import javax.swing.JComponent import javax.swing.JPanel @@ -40,6 +42,9 @@ abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { open fun setHovered(value: Boolean) {} + @RequiresEdt + open fun headerPopup(): HeaderPopupRequest? = null + override fun applyStyle(style: SessionEditorStyle) {} override fun dispose() {} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt index b38b613cabf..62da49a8d96 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt @@ -2,13 +2,20 @@ package ai.kilocode.client.session.views.todo import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.TodoDto -import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.xml.util.XmlStringUtil +import java.awt.BasicStroke import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import javax.swing.Icon import javax.swing.BoxLayout import javax.swing.JPanel @@ -60,9 +67,15 @@ class TodoListPanel( internal fun rowText(index: Int) = rows[index].text.text - internal fun rowChecked(index: Int) = rows[index].check.isSelected + internal fun rowChecked(index: Int) = rows[index].icon.done - internal fun rowCheckboxOpaque(index: Int) = rows[index].check.isOpaque + internal fun rowCheckBackground(index: Int) = rows[index].icon.bg + + internal fun rowCheckForeground(index: Int) = rows[index].icon.fg + + internal fun rowCheckBorder(index: Int) = rows[index].icon.border + + internal fun rowCheckAccessibleName(index: Int) = rows[index].check.accessibleContext.accessibleName internal fun rowFont(index: Int) = rows[index].text.font @@ -102,10 +115,11 @@ class TodoListPanel( } private class Row(todo: TodoDto, style: SessionEditorStyle) { - val check = JBCheckBox().apply { + var icon = TodoCheckIcon(false) + private set + val check = JBLabel().apply { isFocusable = false - isEnabled = false - isOpaque = false + icon = this@Row.icon } val text = JBLabel() val panel = JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply { @@ -121,20 +135,70 @@ class TodoListPanel( fun update(todo: TodoDto, style: SessionEditorStyle) { val done = todo.status == "completed" - check.isSelected = done + syncIcon(done) + check.accessibleContext.accessibleName = KiloBundle.message(accessible(done), todo.content) + check.accessibleContext.accessibleDescription = check.accessibleContext.accessibleName text.text = label(todo.content, done) - text.font = if (todo.changed) style.boldFont else style.regularFont + text.font = style.regularFont text.foreground = when { !done -> style.editorForeground - todo.changed -> style.editorForeground else -> UiStyle.Colors.weak() } } + private fun syncIcon(done: Boolean) { + val bg = SessionUiStyle.View.Todo.checkBg() + val fg = SessionUiStyle.View.Todo.checkFg() + val border = SessionUiStyle.View.Todo.checkBorder() + if (icon.done == done && icon.bg == bg && icon.fg == fg && icon.border == border) return + icon = TodoCheckIcon(done, bg, fg, border) + check.icon = icon + } + private fun label(value: String, done: Boolean): String { val text = XmlStringUtil.escapeString(value) if (!done) return "$text" return "$text" } + + private fun accessible(done: Boolean) = if (done) { + "session.part.todo.accessible.completed" + } else { + "session.part.todo.accessible.pending" + } + } + + private class TodoCheckIcon( + val done: Boolean, + val bg: Color = SessionUiStyle.View.Todo.checkBg(), + val fg: Color = SessionUiStyle.View.Todo.checkFg(), + val border: Color = SessionUiStyle.View.Todo.checkBorder(), + ) : Icon { + override fun getIconWidth() = JBUI.scale(16) + + override fun getIconHeight() = JBUI.scale(16) + + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.translate(x, y) + val size = iconWidth - JBUI.scale(2) + val inset = JBUI.scale(1) + val arc = UiStyle.Gap.sm() + g2.color = bg + g2.fillRoundRect(inset, inset, size, size, arc, arc) + g2.color = border + g2.stroke = BasicStroke(JBUI.scale(1).toFloat()) + g2.drawRoundRect(inset, inset, size, size, arc, arc) + if (!done) return + g2.color = fg + g2.stroke = BasicStroke(JBUI.scale(2).toFloat(), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) + g2.drawLine(JBUI.scale(5), JBUI.scale(8), JBUI.scale(7), JBUI.scale(10)) + g2.drawLine(JBUI.scale(7), JBUI.scale(10), JBUI.scale(11), JBUI.scale(6)) + } finally { + g2.dispose() + } + } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index 78ff1af8fae..131bbc560a2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -60,7 +60,11 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : internal fun rowCount() = parts.list.rowCount() internal fun rowText(index: Int) = parts.list.rowText(index) internal fun rowChecked(index: Int) = parts.list.rowChecked(index) - internal fun rowCheckboxOpaque(index: Int) = parts.list.rowCheckboxOpaque(index) + internal fun rowCheckBackground(index: Int) = parts.list.rowCheckBackground(index) + internal fun rowCheckForeground(index: Int) = parts.list.rowCheckForeground(index) + internal fun rowCheckBorder(index: Int) = parts.list.rowCheckBorder(index) + internal fun rowCheckAccessibleName(index: Int) = parts.list.rowCheckAccessibleName(index) + internal fun rowFont(index: Int) = parts.list.rowFont(index) internal fun rowForeground(index: Int) = parts.list.rowForeground(index) internal fun hiddenText() = parts.list.hiddenText() internal fun titleFont() = parts.title.font diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt index d2c2a623ead..2a205091884 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views.tool +import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState @@ -17,10 +18,10 @@ import javax.swing.ScrollPaneConstants /** Renders read calls with secondary, borderless chrome. */ class ReadToolView( tool: Tool, - openFile: (String) -> Unit = {}, + openFile: SessionFileOpener = { _, _ -> }, private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool, openFile), -) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { + ) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { companion object { fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index 2593c941a08..be75209af21 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -3,6 +3,9 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.session.ui.popup.HeaderPopupBody +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle @@ -22,7 +25,10 @@ import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Container import java.awt.Dimension +import javax.swing.JComponent import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -124,6 +130,9 @@ class ShellToolView( @RequiresEdt internal fun subtitleForeground() = parts.sub.foreground + @RequiresEdt + internal fun subtitleMarkup() = parts.sub.text ?: "" + @RequiresEdt internal fun stateFont() = parts.state.font @@ -137,6 +146,15 @@ class ShellToolView( internal fun horizontalPolicy() = holder.shell?.scrolls()?.firstOrNull()?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? { + if (isExpanded()) return null + val cmd = command(item).takeIf { it.isNotBlank() } ?: return null + return HeaderPopupRequest(row, build = { buildPopupBody(cmd) }) { + Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "bash")) + } + } + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style @@ -170,6 +188,31 @@ class ShellToolView( return body.update(item) } + @RequiresEdt + private fun buildPopupBody(cmd: String): HeaderPopupBody { + val md = MdViewFactory.create( + style, + null, + MdCodeBlockFactory.default( + MdCodeBlockOptions( + border = MdCodeBlockBorder.None, + maxLines = SessionUiStyle.View.Popup.MAX_LINES, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ), + ), + ) + md.applyStyle(style) + md.font = style.transcriptFont + md.foreground = style.editorForeground + md.background = style.editorBackground + md.preBg = style.editorBackground + md.codeFont = style.editorFamily + md.component.border = JBUI.Borders.empty() + md.set(popupMd(formatCommand(cmd))) + return HeaderPopupBody(PopupPanel(md.component), md, style.editorBackground) + } + override fun dumpLabel() = "ShellToolView#$contentId(${labelText()})" companion object { @@ -177,6 +220,37 @@ class ShellToolView( } } +private class PopupPanel(child: JComponent) : JPanel(BorderLayout()) { + init { + // Transparent so the balloon fill (editor background) shows uniformly behind the content. + isOpaque = false + add(child, BorderLayout.CENTER) + } + + override fun getPreferredSize(): Dimension { + // The markdown code block reports a preferred width of 0 so transcript layout can stretch it. + // A balloon has no such constraint, so derive the natural content width and cap it instead. + val size = super.getPreferredSize() + val width = contentWidth(this).coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) + return Dimension(maxOf(width, size.width), size.height) + } +} + +private fun contentWidth(root: Container): Int { + var max = 0 + for (child in root.components) { + if (child is JBScrollPane) { + val view = child.viewport.view as? JComponent + val content = view?.preferredSize?.width ?: 0 + val insets = child.insets + val viewport = child.viewportBorder?.getBorderInsets(child) ?: JBUI.emptyInsets() + max = maxOf(max, content + insets.left + insets.right + viewport.left + viewport.right) + } + if (child is Container) max = maxOf(max, contentWidth(child)) + } + return max +} + class ShellHolder( private val tool: Tool, private val selection: SessionSelection?, @@ -293,6 +367,51 @@ private data class ShellContent( private fun outputLang(text: String): String = if (MdTerminal.hasAnsi(text)) "ansi-stdout" else "shell-output" +private fun popupMd(text: String): String = buildString { + val fence = fence(text) + append(fence).append("shell-command\n") + append(text) + if (!text.endsWith('\n')) append('\n') + append(fence) +} + +/** + * Inserts line breaks after shell separators (`&&`, `||`, `|`, `;`) that sit outside quotes, + * so a long single-line command reads as one statement per line in the popup. Quote and escape + * state is tracked so separators inside string literals are left untouched. + */ +private fun formatCommand(cmd: String): String { + val out = StringBuilder(cmd.length + 8) + var quote = ' ' + var i = 0 + while (i < cmd.length) { + val c = cmd[i] + if (quote != ' ') { + out.append(c) + if (c == '\\' && quote == '"' && i + 1 < cmd.length) { + out.append(cmd[i + 1]) + i += 2 + continue + } + if (c == quote) quote = ' ' + i++ + continue + } + val next = cmd.getOrNull(i + 1) + when { + c == '\'' || c == '"' -> { quote = c; out.append(c); i++ } + c == '\\' && next != null -> { out.append(c).append(next); i += 2 } + c == '&' && next == '&' -> { out.append("&&\n"); i += 2 } + c == '|' && next == '|' -> { out.append("||\n"); i += 2 } + c == '|' && next == '&' -> { out.append("|&\n"); i += 2 } + c == '|' -> { out.append("|\n"); i++ } + c == ';' -> { out.append(";\n"); i++ } + else -> { out.append(c); i++ } + } + } + return out.toString() +} + private fun StringBuilder.section(title: String, text: String, lang: String) { if (text.isBlank()) return if (isNotEmpty()) append("\n\n") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 5b0654b56e1..9bc89f3b177 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.selection.SessionSelection @@ -27,6 +28,7 @@ import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.util.io.OSAgnosticPathUtil import com.intellij.ui.EditorTextField +import com.intellij.ui.awt.RelativePoint import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextArea @@ -39,6 +41,7 @@ import java.awt.CardLayout import java.awt.Color import java.awt.Cursor import java.awt.Font +import java.awt.Point import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.Icon @@ -60,7 +63,7 @@ class ToolParts( val state: JBLabel, val center: JPanel, val controls: JComponent, - private val open: ((String) -> Unit)? = null, + private val open: SessionFileOpener? = null, val extra: JBLabel? = null, val targets: List = emptyList(), private val mode: ToolBodyMode = ToolBodyMode.EDITOR, @@ -88,9 +91,9 @@ class ToolParts( fun bodyCreated() = body != null @RequiresEdt - fun openLink() { + fun openLink(anchor: RelativePoint? = null) { val value = href ?: return - open?.invoke(value) + open?.invoke(value, anchor) } @RequiresEdt @@ -332,14 +335,14 @@ private const val LINK_CARD = "link" @RequiresEdt internal fun toolParts( tool: Tool, - openFile: ((String) -> Unit)? = null, + openFile: SessionFileOpener? = null, mode: ToolBodyMode = ToolBodyMode.TEXT, ): ToolParts { lateinit var parts: ToolParts val glyph = JBLabel() - val title = JBLabel() - val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val link = JBLabel().apply { + val title = clip(JBLabel()) + val sub = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } + val link = clip(JBLabel()).apply { isVisible = false isFocusable = false foreground = UiStyle.Colors.fg() @@ -347,17 +350,21 @@ internal fun toolParts( setRequestFocusEnabled(false) addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { - parts.openLink() + parts.openLink(RelativePoint(this@apply, Point(width / 2, 0))) } }) } val slot = JPanel(CardLayout()).apply { isOpaque = false + minimumSize = JBUI.size(0, minimumSize.height) add(sub, SUB_CARD) add(link, LINK_CARD) } - val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { isOpaque = false } + val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { + isOpaque = false + minimumSize = JBUI.size(0, minimumSize.height) + } val controls = Stack.horizontal() val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false @@ -376,21 +383,21 @@ internal fun toolParts( @RequiresEdt internal fun searchParts(count: Int): ToolParts { val glyph = JBLabel() - val title = JBLabel() - val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val title = clip(JBLabel()) + val sub = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } val targets = List(count) { - JBLabel().apply { + clip(JBLabel()).apply { foreground = UiStyle.Colors.fg() - minimumSize = JBUI.size(0, minimumSize.height) } } - val link = JBLabel().apply { isVisible = false } + val link = clip(JBLabel()).apply { isVisible = false } val slot = JPanel(CardLayout()).apply { isOpaque = false + minimumSize = JBUI.size(0, minimumSize.height) add(sub, SUB_CARD) add(link, LINK_CARD) } - val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } } val target = stack.align(HAlign.TRACK, VAlign.CENTER) val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { @@ -440,7 +447,7 @@ internal fun subtitle(tool: Tool) = when (tool.name) { @RequiresEdt internal fun setText(label: JBLabel, text: String): Boolean { - val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) + val value = html(text) if (label.text == value) return false label.text = value return true @@ -448,20 +455,37 @@ internal fun setText(label: JBLabel, text: String): Boolean { @RequiresEdt internal fun setTargetText(label: JBLabel, text: String): Boolean { - if (label.text == text) return false - label.text = text + val value = single(text) + if (label.text == value) return false + label.text = value return true } @RequiresEdt internal fun setLinkText(parts: ToolParts, text: String): Boolean { - val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") - if (parts.label == text && parts.link.text == value) return false - parts.label = text + val label = single(text) + val value = if (label.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(label)}") + if (parts.label == label && parts.link.text == value) return false + parts.label = label parts.link.text = value return true } +private fun clip(label: JBLabel): JBLabel = label.apply { + minimumSize = JBUI.size(0, minimumSize.height) +} + +private fun html(text: String): String { + val value = single(text) + if (value.isBlank()) return "" + return XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(value)}") +} + +private fun single(text: String): String = text.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString(" ") + @RequiresEdt internal fun show(parts: ToolParts, link: Boolean): Boolean { if (parts.link.isVisible == link && parts.sub.isVisible != link) return false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt index 7230ff2eda5..ac73c1400a5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.HighlighterColors @@ -13,6 +14,14 @@ import com.intellij.util.ui.UIUtil import java.awt.Color internal object MdCommon { + private val pre = Regex("]*>.*?", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + private val protect = Regex("]*>.*?|]*>.*?", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + private val code = Regex("]*)?>(.*?)", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + private val tag = Regex("<[^>]+>") + private val ref = Regex("(? + val attrs = match.groups[1]?.value ?: "" + val body = match.groups[2]?.value ?: "" + "$body" + } + + private fun refs(html: String): String { + if (!html.contains('.')) return html + val out = StringBuilder() + var at = 0 + for (match in protect.findAll(html)) { + out.append(tags(html.substring(at, match.range.first))) + out.append(match.value) + at = match.range.last + 1 + } + out.append(tags(html.substring(at))) + return out.toString() + } + + private fun tags(html: String): String { + if (!html.contains('.')) return html + val out = StringBuilder() + var at = 0 + for (match in tag.findAll(html)) { + out.append(paths(html.substring(at, match.range.first))) + out.append(match.value) + at = match.range.last + 1 + } + out.append(paths(html.substring(at))) + return out.toString() + } + + private fun paths(text: String): String { + if (text.length > REF_SEGMENT_LIMIT || !text.contains('.')) return text + return ref.replace(text) { match -> + val path = match.value + if (!pathish(path)) return@replace path + val href = path.replace(" ", "%20") + .replace("(", "%28") + .replace(")", "%29") + "$path" + } + } + + private fun pathish(path: String): Boolean { + if (path.contains('/')) return true + val name = path.substringBefore(':') + if (name.lowercase() in single) return true + val stem = name.substringBeforeLast('.', missingDelimiterValue = name) + return stem.startsWith('.') || stem.contains('-') || stem.contains('_') + } + + private fun attr(value: String): String = value + .replace("&", "&") + .replace("\"", """) + + private fun blend(bg: Color, fg: Color, alpha: Double): Color { + val beta = 1.0 - alpha + return Color( + (bg.red * beta + fg.red * alpha).toInt(), + (bg.green * beta + fg.green * alpha).toInt(), + (bg.blue * beta + fg.blue * alpha).toInt(), + ) + } } internal data class MdStyle( @@ -115,6 +207,7 @@ internal data class MdStyle( val codeFont: String, val quoteBorder: Color, val quoteFg: Color, + val quoteBg: Color, val tableBorder: Color, val headingFg: Color, val strongFg: Color, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 2ff6770974e..1385932ffec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection import com.intellij.openapi.Disposable import java.awt.Color +import java.awt.Component import java.awt.Font import java.awt.Point import javax.swing.JComponent @@ -37,6 +38,7 @@ interface MdView : Disposable { data class LinkEvent( val href: String, val point: Point? = null, + val component: Component? = null, ) fun interface LinkListener { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt index bcb444d5602..15dcf0dd677 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt @@ -33,7 +33,7 @@ data class MdCodeBlockOptions( val editorOnly: Boolean = false, ) -enum class MdCodeBlockBorder { All, Horizontal, Bottom } +enum class MdCodeBlockBorder { All, Horizontal, Bottom, None } data class MdCodeBlockFactory(val opts: MdCodeBlockOptions = MdCodeBlockOptions()) { companion object { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index cfb9c4b0bcf..973d8b9e5ed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -42,11 +42,17 @@ import org.commonmark.renderer.html.HtmlRenderer import java.awt.Color import java.awt.Dimension import java.awt.Font +import java.awt.Point +import java.awt.event.HierarchyEvent +import java.awt.event.MouseEvent import javax.swing.Box import javax.swing.BoxLayout import javax.swing.JComponent import javax.swing.JPanel +import javax.swing.JViewport import javax.swing.ScrollPaneConstants +import javax.swing.SwingUtilities +import javax.swing.event.ChangeListener import javax.swing.event.HyperlinkEvent import javax.swing.text.html.StyleSheet @@ -64,6 +70,7 @@ internal open class MdViewHybrid( private val source = StringBuilder() private var style = style private var rendered = "" + private var htmlCache: HtmlCache? = null private var disposed = false private val blocks = mutableListOf() private var openFence: Fence? = null @@ -274,6 +281,7 @@ internal open class MdViewHybrid( if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return source.clear() rendered = "" + htmlCache = null openFence = null stale = false clearBlocks() @@ -293,12 +301,13 @@ internal open class MdViewHybrid( override fun markdown(): String = source.toString() override fun html(): String { - if (!stale) return rendered - val out = project(source.toString()) - rendered = out.html - openFence = out.open - stale = false - return rendered + if (stale) { + val out = project(source.toString()) + rendered = out.html + openFence = out.open + stale = false + } + return process(rendered, opts()) } override fun overrideSheet(): String = MdCommon.rules(opts()) @@ -313,6 +322,7 @@ internal open class MdViewHybrid( listeners.clear() source.clear() rendered = "" + htmlCache = null openFence = null stale = false clearBlocks() @@ -412,31 +422,84 @@ internal open class MdViewHybrid( val opts = opts() return object : JBHtmlPane( JBHtmlPaneStyleConfiguration { - enableInlineCodeBackground = true + enableInlineCodeBackground = false enableCodeBlocksBackground = true }, JBHtmlPaneConfiguration { customStyleSheetProvider { sheet() } }, ), UiDataProvider { + private var viewport: JViewport? = null + private val scroll = ChangeListener { hover() } + private val hierarchy = java.awt.event.HierarchyListener { event -> + if (event.changeFlags and HierarchyEvent.PARENT_CHANGED.toLong() != 0L) attach() + } + + init { + addHierarchyListener(hierarchy) + Disposer.register(disposable) { + viewport?.removeChangeListener(scroll) + removeHierarchyListener(hierarchy) + } + } + + override fun addNotify() { + super.addNotify() + attach() + } + + override fun removeNotify() { + viewport?.removeChangeListener(scroll) + viewport = null + super.removeNotify() + } + override fun uiDataSnapshot(sink: DataSink) { selection?.provideCopy(sink) { document.getText(0, document.length).trim() } } + + private fun attach() { + val next = SwingUtilities.getAncestorOfClass(JViewport::class.java, this) as? JViewport + if (viewport === next) return + viewport?.removeChangeListener(scroll) + viewport = next + next?.addChangeListener(scroll) + } + + private fun hover() { + val pt = runCatching { mousePosition }.getOrNull() + val event = if (pt == null) { + MouseEvent(this, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, -1, -1, 0, false, MouseEvent.NOBUTTON) + } else { + MouseEvent(this, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, pt.x, pt.y, 0, false, MouseEvent.NOBUTTON) + } + dispatchEvent(event) + } }.apply { isEditable = false isOpaque = opts.opaque background = opts.background - text = "$body" + text = html(body, opts) selection?.register(this, disposable) addHyperlinkListener { e -> if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener val href = e.description ?: return@addHyperlinkListener - val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point - dispatch(MdView.LinkEvent(href, pt)) + val pt = linkPoint(e) ?: (e.inputEvent as? java.awt.event.MouseEvent)?.point + dispatch(MdView.LinkEvent(href, pt, this)) } } } + private fun JBHtmlPane.linkPoint(event: HyperlinkEvent): Point? { + val elem = event.sourceElement ?: return null + return runCatching { + val start = modelToView2D(elem.startOffset)?.bounds ?: return@runCatching null + val end = modelToView2D((elem.endOffset - 1).coerceAtLeast(elem.startOffset))?.bounds ?: start + val bounds = start.union(end) + Point(bounds.x + bounds.width / 2, bounds.y) + }.getOrNull() + } + private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane { val opts = opts() val value = text.trimEnd('\n') @@ -523,6 +586,7 @@ internal open class MdViewHybrid( MdCodeBlockBorder.All -> JBUI.Borders.customLine(opts.codeBorder, width) MdCodeBlockBorder.Horizontal -> JBUI.Borders.customLine(opts.codeBorder, width, 0, width, 0) MdCodeBlockBorder.Bottom -> JBUI.Borders.customLine(opts.codeBorder, 0, 0, width, 0) + MdCodeBlockBorder.None -> JBUI.Borders.empty() } viewportBorder = JBUI.Borders.empty( SessionUiStyle.View.Code.topPadding(), @@ -761,6 +825,17 @@ internal open class MdViewHybrid( ) } + private fun html(body: String, opts: MdStyle): String = "${process(body, opts)}" + + private fun process(body: String, opts: MdStyle): String { + val color = opts.inlineCodeFg.rgb + val cached = htmlCache + if (cached != null && cached.body == body && cached.color == color) return cached.html + val html = MdCommon.inlineCode(body, opts) + htmlCache = HtmlCache(body, color, html) + return html + } + private fun collect(doc: Node): List { val visitor = Visitor() doc.accept(visitor) @@ -906,6 +981,8 @@ internal open class MdViewHybrid( private data class Projection(val html: String, val blocks: List, val open: Fence?) + private data class HtmlCache(val body: String, val color: Int, val html: String) + private data class Line(val text: String, val end: String) private data class Fence(val char: Char, val size: Int, val info: String) @@ -928,7 +1005,7 @@ internal open class MdViewHybrid( override fun update(desc: Desc) { if (this.desc == desc) return this.desc = desc - pane.text = "${(desc as Desc.Html).body}" + pane.text = html((desc as Desc.Html).body, opts()) } override fun style(opts: MdStyle) { @@ -936,7 +1013,7 @@ internal open class MdViewHybrid( pane.background = opts.background pane.reloadCssStylesheets() val item = desc as Desc.Html - pane.text = "${item.body}" + pane.text = html(item.body, opts) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index c1c91b1c91f..2e88cc541ed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -24,6 +24,7 @@ session.copy.hover=Copy session.copy.copied=Copied session.drop.files.title=Drop files here session.drop.files.subtitle=to add them to the prompt +session.file.missing=Couldn''t find ''{0}'' in this repository. session.tab.new=New Session session.tab.untitled=Untitled Session @@ -102,6 +103,8 @@ session.part.tool.shell.output=Output session.part.tool.truncated=Output truncated in preview. Full output remains in session data. session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index eacde58d1d2..889078ec089 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index caa328e2586..c3605ae359e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 841338f6939..a935518badb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index e0487bf86cc..671a1847f9b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index b991a266db7..51391bebb2d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 4d1e5614558..a53853135a3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 851f89d4043..85680e6cc26 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 75d989c6a39..f119fd47d3e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index 9b2e0dbbc9a..22e7b1a1415 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 0b58eb9f61d..092ad5296ba 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -206,6 +206,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index d44bf46123b..7d5c8aad336 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -206,6 +206,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 76a11c7a18b..d80bcc031c1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -206,6 +206,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index 5698007f3a3..0abe3a8049f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -206,6 +206,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index b041588c377..99883eb3eb3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -206,6 +206,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index 891ad2e5ca6..1e3badd5d1a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -206,6 +206,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 8ba5a608a05..3883d540b1d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index c3524f5dc18..b92426338fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 4e41ed4ec2f..20e7057fb48 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -201,6 +201,8 @@ session.status.offline=Connection offline session.part.plan.ready=Plan is ready session.part.todo.title=To-dos +session.part.todo.accessible.completed=Completed to-do: {0} +session.part.todo.accessible.pending=Pending to-do: {0} session.part.todo.hidden.earlier.one={0} earlier to-do hidden session.part.todo.hidden.earlier.many={0} earlier to-dos hidden session.part.todo.hidden.later.one={0} later to-do hidden @@ -329,3 +331,4 @@ migration.button.migrate=Migrate Settings migration.button.migrating=Migrating... migration.button.done=Done migration.button.continue=Continue +session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt index 5300e74ae8b..607fe2493d3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt @@ -47,6 +47,17 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened) } + fun `test openPath passes line and column to backend`() = runBlocking { + rpc.fileMatches = listOf(WorkspaceFileDto("/test/src/Foo.kt", "Foo.kt")) + + val ok = withContext(Dispatchers.Default) { + service.openPath("/test", "src/Foo.kt", line = 12, column = 3) + } + + assertTrue(ok) + assertEquals(listOf(FakeWorkspaceRpcApi.Opened("/test/src/Foo.kt", 12, 3)), rpc.openedFiles) + } + fun `test openPath returns false when no match exists`() = runBlocking { val ok = withContext(Dispatchers.Default) { service.openPath("/test", ".kilo/plans/missing.md") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt new file mode 100644 index 00000000000..13c56f27f06 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt @@ -0,0 +1,102 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.WorkspaceFileDto +import ai.kilocode.rpc.dto.FileSearchResultDto +import ai.kilocode.rpc.isManagedWorktreeStorage +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import javax.swing.JPanel + +class SessionFileLinksTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeWorkspaceRpcApi + private lateinit var service: KiloWorkspaceService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + rpc = FakeWorkspaceRpcApi() + service = KiloWorkspaceService(scope, rpc) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test parse keeps plain path without location`() { + assertEquals(SessionFileLinks.Target("src/Foo.kt"), SessionFileLinks.parse("src/Foo.kt")) + } + + fun `test parse strips line range and column suffixes`() { + assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12), SessionFileLinks.parse("src/Foo.kt:12")) + assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12), SessionFileLinks.parse("src/Foo.kt:12-20")) + assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12, column = 3), SessionFileLinks.parse("src/Foo.kt:12:3")) + } + + fun `test parse preserves encoded path`() { + assertEquals(SessionFileLinks.Target("src/a%20file.kt", line = 8), SessionFileLinks.parse("src/a%20file.kt:8")) + } + + fun `test isFileHref separates file refs from urls`() { + assertTrue(SessionFileLinks.isFileHref("src/Foo.kt")) + assertTrue(SessionFileLinks.isFileHref("file:///tmp/Foo.kt")) + assertTrue(SessionFileLinks.isFileHref("C:\\repo\\Foo.kt")) + assertFalse(SessionFileLinks.isFileHref("https://kilocode.ai/docs")) + assertFalse(SessionFileLinks.isFileHref("mailto:test@example.com")) + assertFalse(SessionFileLinks.isFileHref("ftp://example.com/file.txt")) + } + + fun `test decide returns resolution from open state and candidates`() { + val one = WorkspaceFileDto("src/Foo.kt", "Foo.kt") + val two = WorkspaceFileDto("test/Foo.kt", "Foo.kt") + + assertEquals(SessionFileLinks.Resolution.Opened, SessionFileLinks.decide(true, emptyList())) + assertEquals(SessionFileLinks.Resolution.Missing, SessionFileLinks.decide(false, emptyList())) + assertEquals(SessionFileLinks.Resolution.OpenDirect(one), SessionFileLinks.decide(false, listOf(one))) + assertEquals(SessionFileLinks.Resolution.Choose(listOf(one, two)), SessionFileLinks.decide(false, listOf(one, two))) + } + + fun `test managed worktree storage filter only rejects worktree subtree`() { + assertFalse(isManagedWorktreeStorage("backend/src/Main.java")) + assertFalse(isManagedWorktreeStorage(".kilo/plans/x.md")) + assertTrue(isManagedWorktreeStorage(".kilo/worktrees")) + assertTrue(isManagedWorktreeStorage(".kilo/worktrees/foo/backend/src/Main.java")) + } + + fun `test open falls back to decoded search query and opens match`() = runBlocking { + val file = WorkspaceFileDto("/test/src/a file.kt", "a file.kt") + val done = CompletableDeferred() + val events = mutableListOf>>() + rpc.fileResolver = { path -> if (path == file.path) listOf(file) else emptyList() } + rpc.search = { FileSearchResultDto(files = listOf(file)) } + val links = SessionFileLinks("/test", service, scope, JPanel(), openUrl = {}) { event, props -> + events.add(event to props) + done.complete(Unit) + } + + links.open("src/a%20file.kt:8", null) + withTimeout(OPEN_TIMEOUT_MS) { done.await() } + + assertEquals(listOf("a file.kt"), rpc.searchQueries) + assertEquals(listOf(FakeWorkspaceRpcApi.Opened("/test/src/a file.kt", 8, null)), rpc.openedFiles) + assertEquals("File Link Opened", events.single().first) + assertEquals("search_direct", events.single().second["result"]) + assertEquals("true", events.single().second["hasLine"]) + } + + private companion object { + const val OPEN_TIMEOUT_MS = 5_000L + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 4b3e9c98e10..c2287d31ee5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.Question @@ -18,6 +19,7 @@ import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.MessageToolbar import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.TextView +import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.rpc.dto.MessageDto @@ -52,7 +54,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable private lateinit var panel: SessionMessageListPanel - private val openFile: (String) -> Unit = {} + private val openFile: SessionFileOpener = { _, _ -> } override fun setUp() { super.setUp() @@ -248,6 +250,29 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals(listOf("https://kilocode.ai/docs"), urls) } + fun `test hover hook follows active part transitions`() { + val events = mutableListOf() + val item = SessionMessageListPanel( + model, + parent, + openFile = openFile, + ).also { + it.onHover = { view, on -> events.add("${view.contentId}:$on") } + } + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("p1", "a1", "bash", "call1", input = mapOf("command" to "first"))) + model.updateContent("a1", toolPart("p2", "a1", "bash", "call2", input = mapOf("command" to "second"))) + val first = item.findMessage("a1")!!.part("p1") as PartView + val second = item.findMessage("a1")!!.part("p2") as PartView + + first.setHovered(true) + second.setHovered(true) + first.setHovered(false) + second.setHovered(false) + + assertEquals(listOf("p1:true", "p2:true", "p2:false"), events) + } + fun `test ContentDelta appends text to TextView`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", part("p1", "a1", "text", text = "hello ")) @@ -646,7 +671,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { fun `test completed plan update replaces tool view and keeps open file action`() { val opened = mutableListOf() - val item = SessionMessageListPanel(model, parent, openFile = { opened.add(it) }) + val item = SessionMessageListPanel(model, parent, openFile = { href, _ -> opened.add(href) }) model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", toolPart("tp1", "a1", "plan_exit", "call1", state = "running")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 59ef3d9759f..1705776fac7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -43,7 +43,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionMessageListPanel(model, parent, openFile = {}) + panel = SessionMessageListPanel(model, parent, openFile = { _, _ -> }) } override fun tearDown() { @@ -220,7 +220,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { fun `test user text and attachments share one prompt container`() { val opened = mutableListOf() - val item = SessionMessageListPanel(model, parent, openFile = {}, openAttachment = { _, it -> opened.add(it.url) }) + val item = SessionMessageListPanel(model, parent, openFile = { _, _ -> }, openAttachment = { _, it -> opened.add(it.url) }) model.upsertMessage(msg("u1", "user")) model.updateContent("u1", part("p1", "u1", "text", text = "look at this")) model.updateContent( @@ -464,7 +464,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { fun `test transcript attachment click delegates to attachment opener`() { val opened = mutableListOf>() - val item = SessionMessageListPanel(model, parent, openFile = {}, openAttachment = { msg, it -> opened.add(msg to it.url) }) + val item = SessionMessageListPanel(model, parent, openFile = { _, _ -> }, openAttachment = { msg, it -> opened.add(msg to it.url) }) model.upsertMessage(msg("u1", "user")) model.updateContent( "u1", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt new file mode 100644 index 00000000000..5c61d86d539 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt @@ -0,0 +1,107 @@ +package ai.kilocode.client.session.ui.popup + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.testing.TestUiTimers +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +@Suppress("DEPRECATION") +class HeaderPopupControllerTest : BasePlatformTestCase() { + private lateinit var timers: TestUiTimers + private val controllers = mutableListOf() + private val views = mutableListOf() + + override fun setUp() { + super.setUp() + timers = TestUiTimers() + } + + override fun tearDown() { + try { + controllers.forEach { Disposer.dispose(it) } + views.filterNot { Disposer.isDisposed(it) }.forEach { Disposer.dispose(it) } + } finally { + controllers.clear() + views.clear() + super.tearDown() + } + } + + fun `test guard is disposed between repeated hover cycles`() { + val controller = controller() + val view = view() + + controller.show(view) + val first = guard(controller) + assertNotNull(first) + + controller.notifyExit(view) + + assertNull(guard(controller)) + assertTrue(Disposer.isDisposed(first!!)) + + controller.show(view) + val second = guard(controller) + assertNotNull(second) + assertNotSame(first, second) + + controller.hideAll() + + assertNull(guard(controller)) + assertTrue(Disposer.isDisposed(second!!)) + } + + fun `test disposing hovered view clears pending guard and suppresses popup`() { + val controller = controller() + val view = view() + + controller.show(view) + val hook = guard(controller) + assertNotNull(hook) + + Disposer.dispose(view) + timers.advanceBy(500) + + assertNull(guard(controller)) + assertNull(target(controller)) + assertTrue(Disposer.isDisposed(hook!!)) + assertEquals(0, view.requests) + } + + private fun controller(): HeaderPopupController { + val item = HeaderPopupController(timers) + controllers.add(item) + return item + } + + private fun view(): TestView { + val item = TestView() + views.add(item) + return item + } + + private fun guard(controller: HeaderPopupController): Disposable? = field(controller, "guard") + + private fun target(controller: HeaderPopupController): PartView? = field(controller, "target") + + private inline fun field(controller: HeaderPopupController, name: String): T? { + val field = HeaderPopupController::class.java.getDeclaredField(name) + field.isAccessible = true + return field.get(controller) as? T + } + + private class TestView : PartView() { + override val contentId = "test" + var requests = 0 + private set + + override fun update(content: Content) {} + + override fun headerPopup(): HeaderPopupRequest? { + requests++ + return null + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt index 67c69d9bf8a..a9c8247e071 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -86,6 +86,15 @@ class GlobToolViewTest : BasePlatformTestCase() { assertEquals(style.regularFont, view.targetFont(1)) } + fun `test target labels normalize newlines for one line clipping`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src\nnested", "pattern" to "**/*.kt\n*.kts") + }) + + assertEquals(listOf("/repo/src nested", "pattern=**/*.kt *.kts"), view.targetTexts()) + assertFalse(view.targetComponents().any { it.text.contains("\n") }) + } + fun `test completed glob starts collapsed and expands output`() { val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt\n/repo/src/B.kt" })) @@ -133,7 +142,7 @@ class GlobToolViewTest : BasePlatformTestCase() { } fun `test view factory routes glob to glob tool view`() { - assertTrue(ViewFactory.create(tool(), openFile = {}) is GlobToolView) + assertTrue(ViewFactory.create(tool(), openFile = { _, _ -> }) is GlobToolView) } fun `test should replace when glob renderer changes`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index 9e6845f53c7..d1f83c72ebb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -21,14 +21,14 @@ class PlanExitViewTest : BasePlatformTestCase() { metadata = mapOf("plan" to ".kilo/plans/x.md") } - val view = PlanExitView(tool) {} + val view = PlanExitView(tool, openFile = { _, _ -> }) 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 { @@ -36,7 +36,7 @@ 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`() { @@ -45,14 +45,14 @@ class PlanExitViewTest : BasePlatformTestCase() { metadata = mapOf("plan" to ".kilo/plans/my%20plan.md") } - val view = PlanExitView(tool) { opened.add(it) } + val view = PlanExitView(tool, openFile = { href, _ -> opened.add(href) }) view.simulateLink(".kilo/plans/my%20plan.md") assertEquals(listOf(".kilo/plans/my%20plan.md"), opened) } fun `test applyStyle refreshes nested markdown role colors`() { - val view = PlanExitView(tool(ToolExecState.COMPLETED)) {} + val view = PlanExitView(tool(ToolExecState.COMPLETED), openFile = { _, _ -> }) val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme scheme.setAttributes( CodeInsightColors.HYPERLINK_ATTRIBUTES, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 868ba5a4cdc..4a3675637de 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -180,7 +180,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) } @@ -190,14 +190,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) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt index f04fd062a3e..aef4c8ded13 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -46,12 +46,12 @@ class ReadToolViewTest : BasePlatformTestCase() { """.trimIndent() } - val view = ReadToolView(t, openFile = { opened.add(it) }) + val view = ReadToolView(t, openFile = { href, _ -> opened.add(href) }) assertTrue(view.linkVisible()) assertEquals("SessionUiLayoutTest.kt", view.linkText()) assertEquals(path, view.linkHref()) - assertTrue(view.linkMarkup().contains("SessionUiLayoutTest.kt")) + assertTrue(view.linkMarkup().contains("SessionUiLayoutTest.kt")) assertEquals(UiStyle.Colors.fg().rgb, view.linkForeground().rgb) assertEquals(view.linkFont(), view.bodyFont()) assertTrue(view.labelText().contains("SessionUiLayoutTest.kt")) @@ -99,10 +99,18 @@ class ReadToolViewTest : BasePlatformTestCase() { assertFalse(view.bodyVisible()) } + fun `test read directory subtitle is normalized to one line`() { + val t = tool().also { it.input = mapOf("filePath" to "dir\nchild") } + val view = ReadToolView(t) + + assertTrue(view.labelText().contains("dir child")) + assertFalse(view.labelText().contains("\n")) + } + fun `test view factory routes read kind tools to read tool view`() { - assertTrue(ViewFactory.create(tool(), openFile = {}) is ReadToolView) - assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is SearchToolView) - assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is GlobToolView) + assertTrue(ViewFactory.create(tool(), openFile = { _, _ -> }) is ReadToolView) + assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = { _, _ -> }) is SearchToolView) + assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = { _, _ -> }) is GlobToolView) } fun `test canRender matches read kind tools only`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt index e9b090bee9b..54428263a12 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt @@ -92,6 +92,15 @@ class SearchToolViewTest : BasePlatformTestCase() { assertEquals("pattern=", view.targetComponents().first().text) } + fun `test target labels normalize newlines for one line clipping`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo/src\nnested", "pattern" to "class\nSearchToolView", "include" to "*.kt") + }) + + assertEquals(listOf("/repo/src nested", "pattern=class SearchToolView", "include=*.kt"), view.targetTexts()) + assertFalse(view.targetComponents().any { it.text.contains("\n") }) + } + fun `test target labels use regular font`() { val view = SearchToolView(tool().also { it.input = mapOf("pattern" to "TODO", "include" to "*.kt") @@ -177,7 +186,7 @@ class SearchToolViewTest : BasePlatformTestCase() { } fun `test view factory routes grep to search tool view`() { - assertTrue(ViewFactory.create(tool(), openFile = {}) is SearchToolView) + assertTrue(ViewFactory.create(tool(), openFile = { _, _ -> }) is SearchToolView) } fun `test should replace when search renderer changes`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index 8046cfe3d35..e5d8268676a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -16,9 +16,12 @@ import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil +import java.awt.Container +import javax.swing.JComponent import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") @@ -76,6 +79,22 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(listOf("git status", "clean"), view.codeTexts()) } + fun `test shell header subtitle is normalized to one html line`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "printf 'one\ntwo'", "description" to "Run first line\nthen second line") + it.output = "one\ntwo" + })) + + assertTrue(view.labelText().contains("Run first line then second line")) + assertFalse(view.labelText().contains("\n")) + assertTrue(view.subtitleMarkup().contains("Run first line then second line")) + assertEquals("printf 'one\ntwo'\n\none\ntwo", view.bodyText()) + view.toggle() + + assertTrue(view.markdown().contains("```shell-command\nprintf 'one\ntwo'\n```")) + assertTrue(view.markdown().contains("```shell-output\none\ntwo\n```")) + } + fun `test ansi escapes are preserved in markdown and decoded in output`() { val view = track(ShellToolView(tool().also { it.output = "\u001B[32mgreen\u001B[0m line" })) @@ -349,11 +368,94 @@ class ShellToolViewTest : BasePlatformTestCase() { assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) } + fun `test shell header popup is available for collapsed command`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "pwd", "description" to "Short") + })) + + fitSubtitle(view) + assertNotNull(view.headerPopup()) + + cropSubtitle(view) + assertNotNull(view.headerPopup()) + + view.toggle() + assertNull(view.headerPopup()) + } + + fun `test shell header popup body uses shell command editor and splits semicolons`() { + val base = EditorFactory.getInstance().allEditors.size + val view = track(ShellToolView(tool().also { + it.input = mapOf( + "command" to "echo one; echo two; echo three", + "description" to "A very long command description that should be cropped", + ) + })) + cropSubtitle(view) + val req = view.headerPopup()!! + val body = req.build() + + try { + val editors = popupCodeEditors(body.component) + editors.forEach { it.getEditor(true) } + + assertEquals(1, editors.size) + assertEquals("echo one;\n echo two;\n echo three", editors.single().text) + assertTrue(body.component.preferredSize.width in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) + assertTrue(body.component.preferredSize.height > 0) + } finally { + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test shell header popup breaks chained operators outside quotes`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf( + "command" to "cd /x && grep -n 'a;b' f | head || echo 'no | match'", + "description" to "A very long command description that should be cropped", + ) + })) + cropSubtitle(view) + val body = view.headerPopup()!!.build() + + try { + val editors = popupCodeEditors(body.component) + editors.forEach { it.getEditor(true) } + assertEquals( + "cd /x &&\n grep -n 'a;b' f |\n head ||\n echo 'no | match'", + editors.single().text, + ) + } finally { + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + } + + fun `test shell header popup editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "printf one; printf two", "description" to "A very long command description that should be cropped") + })) + cropSubtitle(view) + + repeat(20) { + val body = view.headerPopup()!!.build() + popupCodeEditors(body.component).forEach { it.getEditor(true) } + Disposer.dispose(body.disposable) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + fun `test view factory routes bash and replaces generic views`() { val bash = tool() val other = Tool("p1", "mystery", toolKind("mystery")).also { it.state = ToolExecState.COMPLETED } - assertTrue(ViewFactory.create(bash, openFile = {}) is ShellToolView) + assertTrue(ViewFactory.create(bash, openFile = { _, _ -> }) is ShellToolView) assertTrue(ViewFactory.shouldReplace(ToolView(bash), bash)) assertTrue(ViewFactory.shouldReplace(ShellToolView(bash), other)) assertFalse(ViewFactory.shouldReplace(ShellToolView(bash), bash)) @@ -386,4 +488,43 @@ class ShellToolViewTest : BasePlatformTestCase() { views.add(view) return view } + + private fun layout(view: ShellToolView, width: Int) { + view.setSize(width, view.preferredSize.height) + layout(view) + } + + private fun cropSubtitle(view: ShellToolView) { + layout(view, 120) + val label = subtitle(view) + label.setSize(1, label.preferredSize.height) + } + + private fun fitSubtitle(view: ShellToolView) { + layout(view, 2000) + val label = subtitle(view) + label.setSize(label.preferredSize.width, label.preferredSize.height) + } + + private fun subtitle(view: ShellToolView): JBLabel = labels(view).first { it.text == view.subtitleMarkup() } + + private fun labels(root: Container): List = root.components.flatMap { child -> + val nested = if (child is Container) labels(child) else emptyList() + if (child is JBLabel) nested + child else nested + } + + private fun layout(root: Container) { + root.doLayout() + root.components.filterIsInstance().forEach(::layout) + } + + private fun popupCodeEditors(root: JComponent): List { + val found = mutableListOf() + fun visit(component: JComponent) { + if (component is com.intellij.ui.EditorTextField) found.add(component) + component.components.filterIsInstance().forEach(::visit) + } + visit(root) + return found + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index 9165c7d3acf..3fcd18f6e98 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -255,6 +255,15 @@ class TextViewTest : BasePlatformTestCase() { assertEquals(listOf("https://kilocode.ai/docs"), urls) } + fun `test file link opens file callback`() { + val files = mutableListOf() + val view = TextView(Text("p1"), openFile = { href, _ -> files.add(href) }) + + view.simulateLink("src/Foo.kt:12") + + assertEquals(listOf("src/Foo.kt:12"), files) + } + fun `test linkifyMentions rewrites tracked token`() { val out = linkifyMentions( "read @src/a.kt", @@ -341,7 +350,7 @@ class TextViewTest : BasePlatformTestCase() { val text = Text("p1").also { it.content.append("read @src/a file.kt") } val view = PromptView( text, - openFile = { files.add(it) }, + openFile = { href, _ -> files.add(href) }, openUrl = { urls.add(it) }, mentions = listOf(PromptMention("@src/a file.kt", "src/a file.kt", 5, 19)), ) @@ -362,7 +371,7 @@ class TextViewTest : BasePlatformTestCase() { val text = Text("p1").also { it.content.append("review @git-changes") } val view = PromptView( text, - openFile = { error("should not open file") }, + openFile = { _, _ -> error("should not open file") }, openAttachment = { opened.add(it) }, mentions = listOf(PromptMention("@git-changes", "git-changes", 7, 19, item)), ) @@ -383,7 +392,7 @@ class TextViewTest : BasePlatformTestCase() { fun `test message view syncs prompt mentions from hidden part`() { val msg = Message(MessageDto("m1", "ses", "user", MessageTimeDto(0.0))) - val view = MessageView(msg, openFile = {}) + val view = MessageView(msg, openFile = { _, _ -> }) val text = Text("p1").also { it.content.append("read @src/a.kt") } msg.parts["p1"] = text view.upsertPart(text) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 0ede3a0aa29..e6724373202 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Message import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.Text @@ -21,7 +22,7 @@ import javax.swing.RepaintManager */ @Suppress("UnstableApiUsage") class TurnViewTest : BasePlatformTestCase() { - private val openFile: (String) -> Unit = {} + private val openFile: SessionFileOpener = { _, _ -> } // ------ TurnView ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt index 578988381d2..c4b56fe2078 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.TodoDto @@ -40,8 +41,12 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertTrue(view.rowChecked(0)) assertFalse(view.rowChecked(1)) assertTrue(view.rowText(0).contains("Done")) - assertFalse(view.rowCheckboxOpaque(0)) - assertFalse(view.rowCheckboxOpaque(1)) + assertEquals(SessionUiStyle.View.Todo.checkBg(), view.rowCheckBackground(0)) + assertEquals(SessionUiStyle.View.Todo.checkBg(), view.rowCheckBackground(1)) + assertEquals(SessionUiStyle.View.Todo.checkFg(), view.rowCheckForeground(0)) + assertEquals(SessionUiStyle.View.Todo.checkBorder(), view.rowCheckBorder(0)) + assertEquals("Completed to-do: Done", view.rowCheckAccessibleName(0)) + assertEquals("Pending to-do: Next", view.rowCheckAccessibleName(1)) } fun `test pending rows keep normal foreground`() { @@ -58,6 +63,21 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertEquals(style.editorForeground, view.rowForeground(1)) } + fun `test changed rows use same regular font as other rows`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf( + TodoDto("Changed", "pending", "high", changed = true), + TodoDto("Regular", "pending", "medium"), + ) + }) + val style = SessionEditorStyle.current() + + view.applyStyle(style) + + assertEquals(style.regularFont, view.rowFont(0)) + assertEquals(style.regularFont, view.rowFont(1)) + } + fun `test todo header title subtitle gap uses standard medium gap`() { val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf(TodoDto("Next", "pending", "medium")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 3de5b5a639a..4c1b60ef21b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -46,6 +46,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { val fileCalls = CopyOnWriteArrayList>() val searchQueries = CopyOnWriteArrayList() val opened = CopyOnWriteArrayList() + val openedFiles = CopyOnWriteArrayList() val localConfigs = CopyOnWriteArrayList() var globalConfigs = 0 var localConfigPathCalls = 0 @@ -91,9 +92,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return gitChanges } - override suspend fun openFile(path: String): Boolean { + override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean { assertNotEdt("openFile") opened.add(path) + openedFiles.add(Opened(path, line, column)) return openResult } @@ -122,4 +124,6 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { globalConfigs += 1 return openResult } + + data class Opened(val path: String, val line: Int?, val column: Int?) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 7a0a46884c5..d029c0087a2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -30,9 +30,14 @@ import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Color import java.awt.Font +import java.awt.Point +import java.awt.event.MouseEvent import javax.swing.Box import javax.swing.JPanel import javax.swing.ScrollPaneConstants +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.HTML +import javax.swing.text.html.HTMLDocument import java.awt.datatransfer.DataFlavor @Suppress("UnstableApiUsage") @@ -221,6 +226,96 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(pane.text.contains("
  • ")) } + fun `test prose inline code is styled without code chrome or editor`() { + view.applyStyle(customStyle()) + + view.set("Use `packages/opencode/src/session/prompt.ts` here") + val pane = htmls().single() + val color = MdCommon.hex(SessionUiStyle.View.Markdown.string()) + + assertTrue(view.html().contains("packages/opencode/src/session/prompt.ts")) + assertTrue(pane.text.contains("")) + assertFalse(pane.text.contains("#cc8866")) + assertFalse(pane.text.contains("background:")) + assertTrue(scrolls().isEmpty()) + assertTrue(editors().isEmpty()) + } + + fun `test prose file refs are styled as file links`() { + view.set("See packages/opencode/src/session/prompt.ts before continuing") + val html = view.html() + + assertTrue(html.contains("packages/opencode/src/session/prompt.ts")) + assertTrue(view.overrideSheet().contains("a.kilo-file-ref, code a.kilo-file-ref")) + assertTrue(view.overrideSheet().contains("text-decoration: underline")) + } + + fun `test prose links use platform hover underline listener`() { + view.set("See [docs](https://example.com)") + + assertTrue(htmls().single().hyperlinkListeners.size > 1) + } + + fun `test scrolling clears hovered prose link`() { + view.set("See [docs](https://example.com)\n\n" + (1..20).joinToString("\n") { "line $it" }) + val pane = htmls().single() + val events = mutableListOf() + pane.addHyperlinkListener { + if (it.description == "https://example.com") events.add(it.eventType) + } + val host = JBScrollPane(view.component) + host.setSize(420, 64) + view.component.setSize(420, view.component.preferredSize.height) + host.doLayout() + view.component.doLayout() + pane.doLayout() + val iter = (pane.document as HTMLDocument).getIterator(HTML.Tag.A) + assertTrue(iter.isValid) + val rect = pane.modelToView2D(iter.startOffset)!!.bounds + + pane.dispatchEvent(MouseEvent(pane, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, rect.x + 1, rect.y + rect.height / 2, 0, false, MouseEvent.NOBUTTON)) + host.viewport.viewPosition = Point(0, 32) + drainEdt() + + assertTrue(events.contains(HyperlinkEvent.EventType.ENTERED)) + assertTrue(events.contains(HyperlinkEvent.EventType.EXITED)) + } + + fun `test file ref links include line suffix and exclude punctuation`() { + view.set("See kilocode/session/prompt.ts:302, native-plan-prompt.txt:37-38.") + val html = view.html() + + assertTrue(html.contains("href=\"kilocode/session/prompt.ts:302\">kilocode/session/prompt.ts:302,")) + assertTrue(html.contains("href=\"native-plan-prompt.txt:37-38\">native-plan-prompt.txt:37-38.")) + } + + fun `test existing links are not nested as file refs`() { + view.set("[prompt](packages/opencode/src/session/prompt.ts)") + val html = view.html() + + assertTrue(html.contains("prompt")) + assertFalse(html.contains("kilo-file-ref")) + } + + fun `test fenced code file refs are not linkified`() { + view.set("```text\npackages/opencode/src/session/prompt.ts\n```\n\nSee packages/opencode/src/session/prompt.ts") + + assertEquals("packages/opencode/src/session/prompt.ts", editors().single().text) + assertFalse(view.html().substringBefore("").contains("kilo-file-ref")) + assertTrue(htmls().single().text.contains("kilo-file-ref")) + } + + fun `test inline code and fenced code keep separate render paths`() { + view.applyStyle(customStyle()) + + view.set("Use `foo()` here\n\n```kotlin\nval x = 1\n```") + val color = MdCommon.hex(SessionUiStyle.View.Markdown.string()) + + assertTrue(htmls().single().text.contains("foo()")) + assertEquals(1, scrolls().size) + assertEquals("val x = 1", editors().single().text) + } + fun `test code block separates surrounding prose runs`() { view.set("intro\n\n```kotlin\nval x = 1\n```\n\noutro") @@ -731,7 +826,7 @@ class MdViewHybridTest : BasePlatformTestCase() { } fun `test applyStyle updates retained html block`() { - view.set("hello") + view.set("hello `code`") val pane = htmls().single() val style = SessionEditorStyle.create(family = "Courier New", size = 21) @@ -741,6 +836,18 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(view.overrideSheet().contains(style.transcriptFont.name)) assertTrue(view.overrideSheet().contains("Courier New")) assertTrue(view.overrideSheet().contains("21pt")) + assertTrue(pane.text.contains("code")) } fun `test applyStyle reapplies same style to retained html block`() { @@ -859,10 +966,22 @@ class MdViewHybridTest : BasePlatformTestCase() { HighlighterColors.TEXT, TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN), ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_COMMENT, + TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.LINE_COMMENT, + TextAttributes(Color(0x44, 0x55, 0x66), null, null, null, Font.PLAIN), + ) scheme.setAttributes( DefaultLanguageHighlighterColors.DOC_CODE_INLINE, TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.STRING, + TextAttributes(Color(0xCC, 0x88, 0x66), null, null, null, Font.PLAIN), + ) scheme.setAttributes( DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt index 3b8c743fdd3..a98f810192c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt @@ -13,7 +13,7 @@ class MdViewLoggingTest : BasePlatformTestCase() { view.set("`x`") assertTrue(view.overrideSheet().contains("broken\\'font")) - assertTrue(view.html().contains("")) + assertTrue(view.html().contains("")) - assertTrue(view.html().contains("foo()")) + val color = MdCommon.hex(MdCommon.defaults(SessionEditorStyle.current()).inlineCodeFg) + val html = view.html() + + assertTrue(html.contains("foo()")) + assertFalse(html.contains("packages/opencode/src/session/prompt.ts")) + assertTrue(sheet.contains("a.kilo-file-ref, code a.kilo-file-ref { color: $color; font-family:")) + assertTrue(sheet.contains("monospace; text-decoration: underline")) + } + + fun `test inline code file refs keep code color and become file links`() { + view.set("See `packages/opencode/src/session/prompt.ts`") + val color = MdCommon.hex(SessionUiStyle.View.Markdown.string()) + val html = view.html() + + assertTrue(html.contains("packages/opencode/src/session/prompt.ts")) + assertFalse(html.contains("background:")) + } + + fun `test file refs keep line suffix and trailing punctuation outside link`() { + view.set("See kilocode/session/prompt.ts:302 and native-plan-prompt.txt:37-38.") + val html = view.html() + + assertTrue(html.contains("href=\"kilocode/session/prompt.ts:302\">kilocode/session/prompt.ts:302")) + assertTrue(html.contains("href=\"native-plan-prompt.txt:37-38\">native-plan-prompt.txt:37-38.")) + } + + fun `test framework names are not file ref links`() { + view.set("Next.js, Node.js, Vue.js, and Chart.js are framework names, not paths.") + val html = view.html() + + assertFalse(html.contains("kilo-file-ref")) + } + + fun `test existing markdown links are not file ref links`() { + view.set("[prompt](packages/opencode/src/session/prompt.ts)") + val html = view.html() + + assertTrue(html.contains("prompt")) + assertFalse(html.contains("kilo-file-ref")) + } + + fun `test fenced code file refs are not file ref links`() { + view.set("```text\npackages/opencode/src/session/prompt.ts\n```") + val html = view.html() + + assertTrue(html.contains("packages/opencode/src/session/prompt.ts")) + assertFalse(html.contains("kilo-file-ref")) + } + fun `test set renders headings`() { view.set("# Title") assertTrue(view.html().contains("

    ")) @@ -213,7 +268,7 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(html.contains("

    ")) assertTrue(html.contains("")) assertTrue(html.contains("")) - assertTrue(html.contains("")) + assertTrue(html.contains("")) assertTrue(html.contains("
    "))
             assertTrue(html.contains("
    ")) @@ -265,14 +320,22 @@ class MdViewTest : BasePlatformTestCase() { fun `test applyStyle derives markdown colors from editor scheme`() { val style = customStyle() + val color = MdCommon.hex(SessionUiStyle.View.Markdown.string()) + val quote = "#445566" view.applyStyle(style) + view.set("use `inline` code") val sheet = view.overrideSheet() + val html = view.html() assertTrue(sheet.contains("a { color: #778899")) - assertTrue(sheet.contains("code { background: #112233; color: #aabbcc")) + assertTrue(html.contains("inline")) + assertFalse(html.contains("background: #112233")) + assertFalse(html.contains("#cc8866")) assertTrue(sheet.contains("pre { background: #445566; color: #ddeeff; border-color: #223344")) - assertTrue(sheet.contains("blockquote { border-left-color: #223344; color: #334455")) + assertTrue(sheet.contains("blockquote { background:")) + assertTrue(sheet.contains("border-left-color: #223344; color: $quote")) + assertTrue(sheet.contains("blockquote p { color: $quote")) assertTrue(sheet.contains("th, td { border-color: #223344")) } @@ -290,10 +353,11 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.overrideSheet().contains("#ff0077")) } - fun `test code bg override appears in override sheet`() { + fun `test code bg override does not add inline code background`() { view.codeBg = Color(0x10, 0x20, 0x30) view.set("`code`") - assertTrue(view.overrideSheet().contains("#102030")) + assertFalse(view.overrideSheet().contains("#102030")) + assertFalse(view.html().contains("#102030")) } fun `test pre bg and fg overrides appear in override sheet`() { @@ -309,7 +373,23 @@ class MdViewTest : BasePlatformTestCase() { fun `test code font override appears in override sheet`() { view.codeFont = "Fira Code" view.set("`x`") - assertTrue(view.overrideSheet().contains("Fira Code")) + val sheet = view.overrideSheet() + + assertTrue(sheet.contains("tt, code, samp, pre, pre code { font-family: 'Fira Code', monospace")) + assertTrue(sheet.contains("a.kilo-file-ref, code a.kilo-file-ref { color:")) + assertTrue(sheet.contains("font-family: 'Fira Code', monospace; text-decoration: underline")) + } + + fun `test prose keeps transcript font while inline code uses editor font`() { + val style = SessionEditorStyle.create(family = "Courier New", size = 21) + + view.applyStyle(style) + view.set("hello `code` packages/opencode/src/session/prompt.ts") + val sheet = view.overrideSheet() + + assertTrue(sheet.contains("body { color:")) + assertTrue(sheet.contains("font-family: '${style.transcriptFont.name}', sans-serif")) + assertTrue(sheet.contains("tt, code, samp, pre, pre code { font-family: 'Courier New', monospace")) } fun `test blockquote color overrides appear in override sheet`() { @@ -498,10 +578,18 @@ class MdViewTest : BasePlatformTestCase() { DefaultLanguageHighlighterColors.DOC_COMMENT, TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN), ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.LINE_COMMENT, + TextAttributes(Color(0x44, 0x55, 0x66), null, null, null, Font.PLAIN), + ) scheme.setAttributes( DefaultLanguageHighlighterColors.DOC_CODE_INLINE, TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.STRING, + TextAttributes(Color(0xCC, 0x88, 0x66), null, null, null, Font.PLAIN), + ) scheme.setAttributes( DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index f3ed8fde5d4..0356b6c0e67 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -55,7 +55,7 @@ interface KiloWorkspaceRpcApi : RemoteApi { suspend fun gitChanges(directory: String): String? /** Open an absolute backend file path in the IDE. */ - suspend fun openFile(path: String): Boolean + suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean /** Resolve the editable local config target. */ suspend fun localConfigTarget(directory: String): ConfigTargetDto diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/WorkspacePath.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/WorkspacePath.kt new file mode 100644 index 00000000000..7d659e2f938 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/WorkspacePath.kt @@ -0,0 +1,6 @@ +package ai.kilocode.rpc + +fun isManagedWorktreeStorage(path: String): Boolean { + val rel = path.replace('\\', '/').trimStart('/') + return rel == ".kilo/worktrees" || rel.startsWith(".kilo/worktrees/") +}