IJIA-7 Agent Workbench: avoid transcript EDT deadlock

Remove high-frequency transcript diagnostics from the ACP thread screen. Those messages computed scroll and layout state, including preferred sizes, while handling Agent Workbench callbacks. If debug logging was enabled, a background callback could still take AWTTreeLock and block EDT markdown updates.

ACP message markdown rendering remains coalesced on a pooled thread and applied back on EDT, so streaming transcript updates avoid synchronous markdown conversion on the UI thread.

GitOrigin-RevId: 56651b09c85ce13bfa8146d8e0a32b5fd04e68b8
This commit is contained in:
Sergey Ignatov
2026-07-09 18:59:27 +00:00
committed by intellij-monorepo-bot
parent faa801e468
commit 0663b2a24e
9 changed files with 692 additions and 309 deletions
@@ -31,6 +31,7 @@ jvm_library(
"//libraries/kotlinx/serialization/core",
"//libraries/kotlinx/serialization/json",
"//libraries/markdown",
"//platform/analysis-api:analysis",
"//platform/util/concurrency",
"//platform/core-api:core",
"//platform/core-ui",
@@ -62,6 +63,7 @@ jvm_library(
"//libraries/kotlinx/serialization/core:core_test_lib",
"//libraries/kotlinx/serialization/json:json_test_lib",
"//libraries/markdown:markdown_test_lib",
"//platform/analysis-api:analysis_test_lib",
"//platform/util/concurrency:concurrency_test_lib",
"//platform/core-api:core_test_lib",
"//platform/core-ui:core-ui_test_lib",
@@ -37,6 +37,7 @@
<orderEntry type="module" module-name="intellij.libraries.kotlinx.serialization.core" />
<orderEntry type="module" module-name="intellij.libraries.kotlinx.serialization.json" />
<orderEntry type="module" module-name="intellij.libraries.markdown" />
<orderEntry type="module" module-name="intellij.platform.analysis" />
<orderEntry type="module" module-name="intellij.platform.concurrency" />
<orderEntry type="module" module-name="intellij.platform.core" />
<orderEntry type="module" module-name="intellij.platform.core.ui" />
@@ -13,6 +13,12 @@ acp.screen.row.context=CONTEXT
acp.screen.row.authorization=AUTHORIZATION
acp.screen.row.status={0} - {1}
acp.screen.approval.status=Approval: {0}
acp.screen.tool.status.withApproval={0} - {1}
acp.screen.tool.approval.requested=Waiting for approval
acp.screen.tool.approval.approved=Approved
acp.screen.tool.approval.rejected=Rejected
acp.screen.tool.approval.expired=Approval expired
acp.screen.tool.approval.reason=Approval reason: {0}
acp.screen.message.streaming=streaming
acp.screen.section.command=Command: {0}
acp.screen.section.path=Path: {0}
@@ -0,0 +1,143 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.engine.ui
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.fileEditor.OpenFileDescriptor
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.LocalFileSystem
import java.awt.Cursor
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.nio.file.Path
import javax.swing.event.HyperlinkEvent
import javax.swing.text.AttributeSet
import javax.swing.text.html.HTML
internal object AgentAcpThreadHyperlinkHandler {
fun handle(project: Project, event: HyperlinkEvent): Boolean {
when (event.eventType) {
HyperlinkEvent.EventType.ENTERED -> {
event.inputEvent?.component?.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
return true
}
HyperlinkEvent.EventType.EXITED -> {
event.inputEvent?.component?.cursor = Cursor.getDefaultCursor()
return true
}
HyperlinkEvent.EventType.ACTIVATED -> Unit
}
val target = extractTarget(event) ?: return false
if (target.isWebUri()) {
BrowserUtil.browse(target)
return true
}
val location = parseLocalFileLocation(target, project.basePath ?: return false) ?: return false
val file = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(location.path) ?: return false
OpenFileDescriptor(project, file, location.line ?: 0, location.column ?: 0).navigate(true)
return true
}
internal fun extractTarget(event: HyperlinkEvent): String? {
val attributes = event.sourceElement?.attributes
val directHref = attributes?.getAttribute(HTML.Attribute.HREF) as? String
val anchorAttributes = attributes?.getAttribute(HTML.Tag.A) as? AttributeSet
val anchorHref = anchorAttributes?.getAttribute(HTML.Attribute.HREF) as? String
return directHref
?: anchorHref
?: event.url?.toExternalForm()
?: event.description
}
internal fun parseLocalFileLocation(target: String, projectBasePath: String): LocalFileLocation? {
val rawTarget = target.trim()
if (rawTarget.isBlank() || rawTarget.isWebUri()) return null
val withoutFragment = rawTarget.substringBefore('#')
val fragmentLocation = parseFragmentLocation(rawTarget.substringAfter('#', missingDelimiterValue = ""))
val pathWithLocation = if (withoutFragment.startsWith("file:")) {
val uri = createUriOrNull(withoutFragment) ?: return null
Path.of(uri).toString()
}
else {
if (hasUnsupportedScheme(withoutFragment)) return null
decodePath(withoutFragment)
}
val parsed = splitPathAndLocation(pathWithLocation)
val path = parsed.path.toPath(projectBasePath) ?: return null
return LocalFileLocation(
path = path,
line = fragmentLocation?.line ?: parsed.line,
column = fragmentLocation?.column ?: parsed.column,
)
}
private fun parseFragmentLocation(fragment: String): ParsedLocation? {
if (fragment.isBlank()) return null
val normalized = fragment.removePrefix("L").removePrefix("line-")
val parts = normalized.split(':')
val line = parts.getOrNull(0)?.toNavigationOffset() ?: return null
val column = parts.getOrNull(1)?.toNavigationOffset()
return ParsedLocation(line, column)
}
private fun splitPathAndLocation(pathWithLocation: String): ParsedPathLocation {
val parts = pathWithLocation.split(':')
if (parts.size < 2) return ParsedPathLocation(pathWithLocation, line = null, column = null)
val lastOffset = parts.last().toNavigationOffset()
val previousOffset = parts.getOrNull(parts.lastIndex - 1)?.toNavigationOffset()
return when {
previousOffset != null -> ParsedPathLocation(parts.dropLast(2).joinToString(":"), previousOffset, lastOffset)
lastOffset != null -> ParsedPathLocation(parts.dropLast(1).joinToString(":"), lastOffset, column = null)
else -> ParsedPathLocation(pathWithLocation, line = null, column = null)
}
}
private fun String.toPath(projectBasePath: String): Path? {
val rawPath = trim().takeIf { it.isNotBlank() } ?: return null
val path = Path.of(rawPath)
return if (path.isAbsolute) path else Path.of(projectBasePath).resolve(path).normalize()
}
private fun String.toNavigationOffset(): Int? =
toIntOrNull()?.takeIf { it > 0 }?.minus(1)
private fun hasUnsupportedScheme(value: String): Boolean {
val schemeSeparator = value.indexOf(':')
if (schemeSeparator <= 1 || value.indexOf('/') in 0 until schemeSeparator) return false
if (value.substring(schemeSeparator + 1).substringBefore(':').toNavigationOffset() != null) return false
return value.substring(0, schemeSeparator).all { it.isLetterOrDigit() || it == '+' || it == '-' || it == '.' }
}
private fun String.isWebUri(): Boolean {
val scheme = substringBefore(':', missingDelimiterValue = "").lowercase()
return scheme == "http" || scheme == "https"
}
private fun createUriOrNull(value: String): URI? =
runCatching { URI.create(value) }.getOrNull()
private fun decodePath(value: String): String =
URLDecoder.decode(value, StandardCharsets.UTF_8)
}
internal data class LocalFileLocation(
val path: Path,
val line: Int?,
val column: Int?,
)
private data class ParsedPathLocation(
val path: String,
val line: Int?,
val column: Int?,
)
private data class ParsedLocation(
val line: Int?,
val column: Int?,
)
@@ -69,6 +69,7 @@ import javax.swing.JTextArea
import javax.swing.JViewport
import javax.swing.ScrollPaneConstants
import javax.swing.plaf.basic.BasicTextUI
import javax.swing.text.DefaultCaret
import javax.swing.text.View
import kotlin.math.ceil
@@ -84,13 +85,11 @@ internal class AgentAcpThreadScreen(
) : JPanel(BorderLayout()), AgentThreadViewPreferredFocusableContent {
private val statusLabel = JBLabel().apply { font = JBFont.label().asBold() }
private val subtitleLabel = JBLabel().apply { foreground = UIUtil.getContextHelpForeground() }
private var renderCounter = 0
private var followTranscriptTail = true
private var transcriptScrollToBottomRequested = false
private var programmaticScrollDepth = 0
private var lastTranscriptScrollValue = 0
private var canUnstickTranscriptTail = true
private var transcriptScrollInvokeScheduled = false
private var manualTranscriptTailPauseUntil = 0L
private var renderedTranscriptEntries: List<ThreadTranscriptEntry>? = null
private var renderedPendingApprovals = -1
@@ -126,9 +125,9 @@ internal class AgentAcpThreadScreen(
init {
border = JBUI.Borders.empty(4)
transcriptPanel.scrollToBottomIfNeeded = {
scheduleTranscriptTailScrollFromLayout()
scrollTranscriptTailFromLayout()
}
installTranscriptScrollDiagnostics()
installTranscriptScrollListeners()
add(buildHeader(), BorderLayout.NORTH)
add(transcriptScrollPane, BorderLayout.CENTER)
add(buildInput(), BorderLayout.SOUTH)
@@ -151,20 +150,13 @@ internal class AgentAcpThreadScreen(
private fun sendCurrentInput() {
if (!inputArea.isEnabled) {
LOG.info("[$threadId] ACP screen ignored send: input disabled")
return
}
val text = inputArea.text.trim()
if (text.isEmpty()) return
val service = EngineProjectService.getInstance(project)
val projectionBeforeSend = service.projection(threadId)
LOG.info(
"[$threadId] ACP screen send requested: textLength=${text.length}, " +
"projection=${projectionBeforeSend.debugSummary()}, scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
val sender = EnginePromptSender.forThread(project, threadId)
if (sender == null) {
val projection = service.projection(threadId)
val projection = EngineProjectService.getInstance(project).projection(threadId)
LOG.warn(
"[$threadId] ACP screen cannot send: no prompt sender, " +
"status=${projection.thread.status}, runtimeKind=${projection.thread.runtimeKind}, " +
@@ -172,15 +164,8 @@ internal class AgentAcpThreadScreen(
)
return
}
LOG.info("[$threadId] ACP screen sending prompt: textLength=${text.length}")
inputArea.text = ""
sender.sendPrompt(project, threadId, text)
val projectionAfterDispatch = service.projection(threadId)
LOG.info(
"[$threadId] ACP screen send dispatched: textLength=${text.length}, " +
"projection=${projectionAfterDispatch.debugSummary()}, " +
"scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
}
override fun addNotify() {
@@ -210,21 +195,10 @@ internal class AgentAcpThreadScreen(
project.messageBus.connect(parent).subscribe(
EngineEvents.TOPIC,
object : EngineEvents {
override fun eventAppended(event: ThreadEventEnvelope) {
if (event.threadId != this@AgentAcpThreadScreen.threadId) return
LOG.info(
"[$threadId] ACP screen event appended: seq=${event.seq}, type=${event.type}, " +
"source=${event.source}, visibility=${event.visibility}, payloadKeys=${event.payload.keys.sorted()}, " +
"scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
}
override fun eventAppended(event: ThreadEventEnvelope) = Unit
override fun projectionUpdated(threadId: ThreadId) {
if (threadId != this@AgentAcpThreadScreen.threadId) return
LOG.info(
"[$threadId] ACP screen projectionUpdated received: " +
"scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
scheduleProjectionRender()
}
},
@@ -241,10 +215,6 @@ internal class AgentAcpThreadScreen(
true
}
}
LOG.info(
"[$threadId] ACP screen projection render schedule: scheduled=$scheduled, " +
"scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
if (!scheduled) return
ApplicationManager.getApplication().invokeLater {
synchronized(renderScheduleLock) {
@@ -252,10 +222,6 @@ internal class AgentAcpThreadScreen(
}
if (!project.isDisposed) {
val projection = EngineProjectService.getInstance(project).projection(threadId)
LOG.info(
"[$threadId] ACP screen projectionUpdated rendering: " +
"projection=${projection.debugSummary()}, scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
render(projection)
// The user is looking at this thread, so any fresh agent output is already seen.
if (isShowing) EngineUnreadTracker.getInstance(project).markRead(threadId)
@@ -265,7 +231,6 @@ internal class AgentAcpThreadScreen(
@Suppress("HardCodedStringLiteral")
private fun render(projection: ThreadProjection) {
val renderId = ++renderCounter
val thread = projection.thread
val title: @NlsSafe String = thread.title.ifBlank { threadId.value }
statusLabel.text = title
@@ -275,75 +240,61 @@ internal class AgentAcpThreadScreen(
humanize(thread.status.name),
)
val stickDecision = transcriptStickDecision(projection)
LOG.info(
"[$threadId] ACP screen render#$renderId start: " +
"transcriptSize=${projection.transcript.size}, pendingApprovals=${projection.pendingApprovals}, " +
"previousComponents=${transcriptPanel.componentCount}, latest=${projection.transcript.lastOrNull().debugSummary()}, " +
"stick=${stickDecision.shouldStickToBottom}, stickReason=${stickDecision.reason}, " +
"followTailBefore=${stickDecision.followTailBefore}, followTailAfter=${stickDecision.followTailAfter}, " +
"beforeScroll=${stickDecision.scrollState}, layout=${transcriptLayoutDebugState()}",
)
val emptyStatus = thread.status.takeIf { projection.transcript.isEmpty() }
val transcriptRenderChange = renderTranscript(projection, emptyStatus, renderId)
if (transcriptRenderChange.changed && stickDecision.shouldStickToBottom) {
scheduleScrollTranscriptToBottom(renderId, stickDecision.reason)
}
else if (transcriptRenderChange.changed) {
LOG.info(
"[$threadId] ACP screen render#$renderId preserving scroll: " +
"reason=${stickDecision.reason}, scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
val visibleTranscriptEntries = projection.visibleTranscriptEntries()
val shouldStickToBottom = shouldStickTranscriptToBottom(visibleTranscriptEntries)
val emptyStatus = thread.status.takeIf { visibleTranscriptEntries.isEmpty() }
val transcriptRenderChange = renderTranscript(projection, visibleTranscriptEntries, emptyStatus)
if (transcriptRenderChange.changed && shouldStickToBottom) {
scheduleScrollTranscriptToBottom()
}
val sender = EnginePromptSender.forThread(project, threadId)
val canSend = thread.runtimeKind == RuntimeKind.Acp &&
thread.status != ThreadStatus.Disconnected &&
sender != null
LOG.info(
"[$threadId] ACP screen render#$renderId input: canSend=$canSend, hasSender=${sender != null}, " +
"status=${thread.status}, runtimeKind=${thread.runtimeKind}, binding=${projection.runtimeBinding}, " +
"transcriptSize=${projection.transcript.size}",
)
inputArea.isEnabled = canSend
inputArea.emptyText.text = EngineBundle.message(
if (canSend) "acp.screen.input.hint" else "acp.screen.input.readonly",
)
}
private fun renderTranscript(projection: ThreadProjection, emptyStatus: ThreadStatus?, renderId: Int): TranscriptRenderChange {
private fun renderTranscript(
projection: ThreadProjection,
visibleEntries: List<ThreadTranscriptEntry>,
emptyStatus: ThreadStatus?,
): TranscriptRenderChange {
val previousEntries = renderedTranscriptEntries
if (previousEntries == projection.transcript &&
if (previousEntries == visibleEntries &&
renderedPendingApprovals == projection.pendingApprovals &&
renderedEmptyStatus == emptyStatus) {
LOG.info(
"[$threadId] ACP screen render#$renderId transcript unchanged: " +
"components=${transcriptPanel.componentCount}, scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
return TranscriptRenderChange(changed = false)
}
val requiresFullRebuild = previousEntries == null ||
renderedPendingApprovals != projection.pendingApprovals ||
renderedEmptyStatus != emptyStatus ||
previousEntries.isEmpty() != projection.transcript.isEmpty() ||
previousEntries.isEmpty() != visibleEntries.isEmpty() ||
projection.pendingApprovals > 0 ||
renderedPendingApprovals > 0
return if (requiresFullRebuild) {
rebuildTranscript(projection, emptyStatus, renderId)
rebuildTranscript(projection, visibleEntries, emptyStatus)
}
else {
updateTranscriptTail(previousEntries, projection, renderId)
updateTranscriptTail(previousEntries, projection, visibleEntries)
}
}
private fun rebuildTranscript(projection: ThreadProjection, emptyStatus: ThreadStatus?, renderId: Int): TranscriptRenderChange {
private fun rebuildTranscript(
projection: ThreadProjection,
visibleEntries: List<ThreadTranscriptEntry>,
emptyStatus: ThreadStatus?,
): TranscriptRenderChange {
transcriptPanel.removeAll()
if (projection.transcript.isEmpty()) {
if (visibleEntries.isEmpty()) {
transcriptPanel.add(emptyState(projection.thread.status))
}
else {
addTranscriptEntries(projection.transcript, startIndex = 0)
addTranscriptEntries(visibleEntries, startIndex = 0)
}
if (projection.pendingApprovals > 0) {
transcriptPanel.add(
@@ -354,54 +305,39 @@ internal class AgentAcpThreadScreen(
},
)
}
rememberRenderedTranscript(projection, emptyStatus)
rememberRenderedTranscript(visibleEntries, projection.pendingApprovals, emptyStatus)
transcriptPanel.revalidate()
transcriptPanel.repaint()
LOG.info(
"[$threadId] ACP screen render#$renderId rows rebuilt: " +
"components=${transcriptPanel.componentCount}, afterRevalidateScroll=${transcriptScrollDebugState()}, " +
"layout=${transcriptLayoutDebugState()}",
)
return TranscriptRenderChange(changed = true)
}
private fun updateTranscriptTail(
previousEntries: List<ThreadTranscriptEntry>,
projection: ThreadProjection,
renderId: Int,
visibleEntries: List<ThreadTranscriptEntry>,
): TranscriptRenderChange {
val changedIndex = firstChangedTranscriptIndex(previousEntries, projection.transcript)
if (changedIndex == previousEntries.lastIndex && changedIndex == projection.transcript.lastIndex) {
val changedIndex = firstChangedTranscriptIndex(previousEntries, visibleEntries)
if (changedIndex == previousEntries.lastIndex && changedIndex == visibleEntries.lastIndex) {
val row = transcriptPanel.getComponentOrNull(transcriptComponentIndex(changedIndex)) as? TranscriptRowPanel
if (row != null && row.updateTranscriptEntry(projection.transcript[changedIndex])) {
rememberRenderedTranscript(projection, emptyStatus = null)
if (row != null && row.updateTranscriptEntry(visibleEntries[changedIndex])) {
rememberRenderedTranscript(visibleEntries, projection.pendingApprovals, emptyStatus = null)
transcriptPanel.revalidate()
transcriptPanel.repaint()
LOG.info(
"[$threadId] ACP screen render#$renderId row updated: index=$changedIndex, " +
"components=${transcriptPanel.componentCount}, afterUpdateScroll=${transcriptScrollDebugState()}, " +
"layout=${transcriptLayoutDebugState()}",
)
return TranscriptRenderChange(changed = true)
}
}
removeTranscriptEntriesFrom(changedIndex)
addTranscriptEntries(projection.transcript, startIndex = changedIndex)
rememberRenderedTranscript(projection, emptyStatus = null)
addTranscriptEntries(visibleEntries, startIndex = changedIndex)
rememberRenderedTranscript(visibleEntries, projection.pendingApprovals, emptyStatus = null)
transcriptPanel.revalidate()
transcriptPanel.repaint()
LOG.info(
"[$threadId] ACP screen render#$renderId tail rebuilt: fromIndex=$changedIndex, " +
"components=${transcriptPanel.componentCount}, afterRevalidateScroll=${transcriptScrollDebugState()}, " +
"layout=${transcriptLayoutDebugState()}",
)
return TranscriptRenderChange(changed = true)
}
private fun rememberRenderedTranscript(projection: ThreadProjection, emptyStatus: ThreadStatus?) {
renderedTranscriptEntries = projection.transcript.toList()
renderedPendingApprovals = projection.pendingApprovals
private fun rememberRenderedTranscript(entries: List<ThreadTranscriptEntry>, pendingApprovals: Int, emptyStatus: ThreadStatus?) {
renderedTranscriptEntries = entries.toList()
renderedPendingApprovals = pendingApprovals
renderedEmptyStatus = emptyStatus
}
@@ -432,47 +368,35 @@ internal class AgentAcpThreadScreen(
private fun JPanel.getComponentOrNull(index: Int): Component? =
if (index in 0 until componentCount) getComponent(index) else null
private fun transcriptStickDecision(projection: ThreadProjection): TranscriptStickDecision {
private fun shouldStickTranscriptToBottom(visibleEntries: List<ThreadTranscriptEntry>): Boolean {
val previousComponents = transcriptPanel.componentCount
val scrollState = transcriptScrollState()
val atBottom = scrollState.isAtBottom
val latestIsUserMessage = projection.transcript.lastOrNull().isUserMessage()
val latestIsUserMessage = visibleEntries.lastOrNull().isUserMessage()
if (latestIsUserMessage) {
manualTranscriptTailPauseUntil = 0L
}
val manualTailPauseActive = isManualTranscriptTailPauseActive()
val followTailBefore = followTranscriptTail
val keepFollowingTailAcrossLayoutDrift = followTailBefore && !manualTailPauseActive && scrollState.isWithinTailDrift
val keepFollowingTailAcrossLayoutDrift = followTranscriptTail && !manualTailPauseActive && scrollState.isWithinTailDrift
val shouldStickToBottom =
previousComponents == 0 || latestIsUserMessage || (atBottom && !manualTailPauseActive) || keepFollowingTailAcrossLayoutDrift
val reason = when {
previousComponents == 0 -> "initial-or-empty-panel"
latestIsUserMessage -> "latest-entry-is-user-message"
atBottom && !manualTailPauseActive -> "viewport-at-bottom"
atBottom -> "manual-tail-pause-at-bottom"
keepFollowingTailAcrossLayoutDrift -> "tail-follow-layout-drift"
else -> "preserve-manual-scroll"
}
followTranscriptTail = shouldStickToBottom
return TranscriptStickDecision(
shouldStickToBottom = shouldStickToBottom,
reason = reason,
scrollState = scrollState.debugString(),
followTailBefore = followTailBefore,
followTailAfter = followTranscriptTail,
)
return shouldStickToBottom
}
private fun ThreadTranscriptEntry?.isUserMessage(): Boolean = this is ThreadMessage && role == MessageRole.User
private fun installTranscriptScrollDiagnostics() {
private fun ThreadProjection.visibleTranscriptEntries(): List<ThreadTranscriptEntry> = transcript.filter { entry ->
entry !is ThreadToolCall || AgentAcpToolCallPresenter.shouldRenderInTranscript(entry)
}
private fun installTranscriptScrollListeners() {
val scrollBar = transcriptScrollPane.verticalScrollBar
scrollBar.addAdjustmentListener { event ->
val followTailBefore = followTranscriptTail
val userAdjustment = event.isUserTranscriptAdjustment()
val canUnstickTranscriptTail = userAdjustment && !event.valueIsAdjusting && !transcriptScrollToBottomRequested
val canUnstick = canUnstickTranscriptTail && programmaticScrollDepth == 0
val lastScrollValueBefore = lastTranscriptScrollValue
val requestedBefore = transcriptScrollToBottomRequested
val scrollState = transcriptScrollState()
if (canUnstick) {
when {
@@ -481,125 +405,54 @@ internal class AgentAcpThreadScreen(
}
}
lastTranscriptScrollValue = scrollState.value
LOG.info(
"[$threadId] ACP screen scrollbar adjusted: type=${event.adjustmentType.debugAdjustmentType()}, " +
"eventValue=${event.value}, valueIsAdjusting=${event.valueIsAdjusting}, " +
"programmatic=${programmaticScrollDepth > 0}, canUnstick=$canUnstick, userAdjustment=$userAdjustment, " +
"lastValueBefore=$lastScrollValueBefore, " +
"followTailBefore=$followTailBefore, followTailAfter=$followTranscriptTail, " +
"requestedBefore=$requestedBefore, requestedAfter=$transcriptScrollToBottomRequested, " +
"manualTailPauseAfter=${isManualTranscriptTailPauseActive()}, " +
"scroll=${scrollState.debugString()}, layout=${transcriptLayoutDebugState()}",
)
}
transcriptScrollPane.addMouseWheelListener { event ->
val followTailBefore = followTranscriptTail
val beforeScroll = transcriptScrollState()
val wheelUp = event.wheelRotation < 0 || event.preciseWheelRotation < 0.0
val requestedBefore = transcriptScrollToBottomRequested
if (wheelUp) {
pauseTranscriptTailForUserScroll()
}
LOG.info(
"[$threadId] ACP screen mouse wheel: rotation=${event.wheelRotation}, " +
"preciseRotation=${event.preciseWheelRotation}, units=${event.unitsToScroll}, " +
"wheelUp=$wheelUp, requestedBefore=$requestedBefore, requestedAfter=$transcriptScrollToBottomRequested, " +
"manualTailPauseAfter=${isManualTranscriptTailPauseActive()}, " +
"followTailBefore=$followTailBefore, followTailAfter=$followTranscriptTail, " +
"beforeScroll=${beforeScroll.debugString()}, layout=${transcriptLayoutDebugState()}",
)
ApplicationManager.getApplication().invokeLater {
if (project.isDisposed) return@invokeLater
val followTailAfterWheelBefore = followTranscriptTail
val afterScroll = transcriptScrollState()
val manualTailPauseActive = isManualTranscriptTailPauseActive()
if (afterScroll.isAtBottom && !manualTailPauseActive) {
followTranscriptTail = true
}
LOG.info(
"[$threadId] ACP screen mouse wheel applied: " +
"manualTailPauseActive=$manualTailPauseActive, " +
"followTailBefore=$followTailAfterWheelBefore, followTailAfter=$followTranscriptTail, " +
"afterScroll=${afterScroll.debugString()}, layout=${transcriptLayoutDebugState()}",
)
}
}
}
private fun scheduleScrollTranscriptToBottom(renderId: Int, reason: String) {
val requestedBefore = transcriptScrollToBottomRequested
private fun scheduleScrollTranscriptToBottom() {
transcriptScrollToBottomRequested = true
val scheduled = scheduleTranscriptTailScroll(renderId, reason)
LOG.info(
"[$threadId] ACP screen render#$renderId schedule scroll-to-bottom: " +
"reason=$reason, requestedBefore=$requestedBefore, requestedAfter=true, scheduled=$scheduled, " +
"scheduledScroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
}
private fun scheduleTranscriptTailScroll(renderId: Int?, reason: String): Boolean {
if (transcriptScrollInvokeScheduled) return false
transcriptScrollInvokeScheduled = true
ApplicationManager.getApplication().invokeLater {
transcriptScrollInvokeScheduled = false
scrollTranscriptToBottomIfNeeded(renderId, reason)
}
return true
}
private fun scheduleTranscriptTailScrollFromLayout() {
private fun scrollTranscriptTailFromLayout() {
if (!followTranscriptTail && !transcriptScrollToBottomRequested) return
val scheduled = scheduleTranscriptTailScroll(renderId = null, reason = "transcript-panel-layout")
if (scheduled) {
LOG.info(
"[$threadId] ACP screen layout schedule scroll-to-bottom: " +
"scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
}
scrollTranscriptToBottomIfNeeded()
}
private fun scrollTranscriptToBottomIfNeeded(renderId: Int?, reason: String) {
private fun scrollTranscriptToBottomIfNeeded() {
if (project.isDisposed) {
LOG.info("[$threadId] ACP screen ${renderDebugLabel(renderId)} skip scroll-to-bottom: trigger=scheduled, project disposed")
return
}
if (!followTranscriptTail && !transcriptScrollToBottomRequested) {
LOG.info(
"[$threadId] ACP screen ${renderDebugLabel(renderId)} skip scroll-to-bottom: " +
"trigger=scheduled, followTail=false, requested=false, reason=$reason, " +
"scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
return
}
if (!isTranscriptViewportWidthStable()) {
transcriptScrollToBottomRequested = true
val scheduled = scheduleTranscriptTailScroll(renderId, reason)
LOG.info(
"[$threadId] ACP screen ${renderDebugLabel(renderId)} defer scroll-to-bottom: " +
"trigger=scheduled, reason=$reason, scheduled=$scheduled, " +
"scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
return
}
scrollTranscriptToBottom(renderId, reason)
scrollTranscriptToBottom()
}
private fun scrollTranscriptToBottom(renderId: Int?, reason: String) {
private fun scrollTranscriptToBottom() {
val scrollBar = transcriptScrollPane.verticalScrollBar
val beforeScroll = transcriptScrollDebugState()
val modelTarget = (scrollBar.maximum - scrollBar.visibleAmount).coerceAtLeast(scrollBar.minimum)
runWithoutTranscriptUnsticking {
scrollBar.value = scrollBar.maximum
}
lastTranscriptScrollValue = scrollBar.value
transcriptScrollToBottomRequested = false
followTranscriptTail = true
LOG.info(
"[$threadId] ACP screen ${renderDebugLabel(renderId)} scroll-to-bottom applied: " +
"trigger=scheduled, reason=$reason, modelTarget=$modelTarget, " +
"lastContentBounds=${transcriptPanel.lastContentComponentBoundsDebugString()}, beforeScroll=$beforeScroll, " +
"afterScroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}",
)
}
private fun runWithoutTranscriptUnsticking(block: () -> Unit) {
@@ -623,8 +476,6 @@ internal class AgentAcpThreadScreen(
private fun isManualTranscriptTailPauseActive(): Boolean = System.currentTimeMillis() < manualTranscriptTailPauseUntil
private fun renderDebugLabel(renderId: Int?): String = renderId?.let { "render#$it" } ?: "layout"
private fun isTranscriptViewportWidthStable(): Boolean {
val viewport = transcriptScrollPane.viewport
val extentWidth = viewport.extentSize.width
@@ -633,10 +484,6 @@ internal class AgentAcpThreadScreen(
return viewWidth >= extentWidth - JBUI.scale(2)
}
private fun transcriptScrollDebugState(): String {
return transcriptScrollState().debugString()
}
private fun transcriptScrollState(): TranscriptScrollState {
val scrollBar = transcriptScrollPane.verticalScrollBar
return TranscriptScrollState(
@@ -655,31 +502,6 @@ internal class AgentAcpThreadScreen(
)
}
private fun transcriptLayoutDebugState(): String =
"panelSize=${transcriptPanel.size.debugString()}, panelPreferred=${transcriptPanel.preferredSize.debugString()}, " +
"viewportExtent=${transcriptScrollPane.viewport.extentSize.debugString()}, " +
"viewportViewSize=${transcriptScrollPane.viewport.viewSize.debugString()}, " +
"viewportViewPosition=${transcriptScrollPane.viewport.viewPosition.debugString()}, " +
"visibleRect=${transcriptPanel.visibleRect.debugString()}, " +
"lastComponentBounds=${transcriptPanel.lastComponentBoundsDebugString()}, " +
"lastContentBounds=${transcriptPanel.lastContentComponentBoundsDebugString()}, showing=$isShowing"
private fun ThreadTranscriptEntry?.debugSummary(): String = when (this) {
null -> "none"
is ThreadMessage -> "message(id=$id, role=$role, complete=$complete, textLength=${text.length}, updatedAt=$updatedAt)"
is ThreadToolCall -> "tool(id=$id, status=$status, complete=$complete, outputLength=${outputText.length}, updatedAt=$updatedAt)"
is ThreadCommand -> "command(id=$id, status=$status, exitCode=$exitCode, complete=$complete, outputLength=${outputText.length}, updatedAt=$updatedAt)"
is ThreadPlan -> "plan(id=$id, complete=$complete, items=${items.size}, updatedAt=$updatedAt)"
is ThreadFileDiff -> "diff(id=$id, status=$status, newTextLength=${newText?.length ?: 0}, updatedAt=$updatedAt)"
is ThreadContextCompaction -> "context(id=$id, summaryLength=${summary?.length ?: 0}, updatedAt=$updatedAt)"
is ThreadActionPrompt -> "actionPrompt(id=$id, buttons=${buttons.size}, updatedAt=$updatedAt)"
}
private fun ThreadProjection.debugSummary(): String =
"status=${thread.status}, runtimeKind=${thread.runtimeKind}, transcriptSize=${transcript.size}, " +
"pendingApprovals=$pendingApprovals, lastSeq=$lastSeq, latest=${transcript.lastOrNull().debugSummary()}, " +
"binding=${runtimeBinding != null}"
private fun emptyState(status: ThreadStatus): JComponent =
JBLabel(EngineBundle.message("acp.screen.empty", humanize(status.name))).apply {
foreground = UIUtil.getContextHelpForeground()
@@ -706,8 +528,7 @@ internal class AgentAcpThreadScreen(
foreground = UIUtil.getContextHelpForeground()
border = JBUI.Borders.emptyBottom(2)
}
val bodyText = previewText(message.text)
val bodyTextPane = rowMarkdownPane(bodyText)
val bodyTextPane = rowMarkdownPane(message.text)
val body = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
@@ -718,9 +539,8 @@ internal class AgentAcpThreadScreen(
row.setTranscriptUpdater { entry ->
val updated = entry as? ThreadMessage ?: return@setTranscriptUpdater false
if (updated.id != message.id || updated.role != message.role) return@setTranscriptUpdater false
val updatedBodyText = previewText(updated.text)
roleLabel.text = messageLabelText(updated)
bodyTextPane.setMarkdownText(updatedBodyText)
bodyTextPane.setMarkdownText(updated.text)
bodyTextPane.invalidate()
row.invalidate()
true
@@ -739,30 +559,53 @@ internal class AgentAcpThreadScreen(
}
private fun toolRow(toolCall: ThreadToolCall): JComponent {
val sections = buildList {
toolCall.command?.takeIf { it.isNotBlank() }?.let { command ->
add(RowBodySection(EngineBundle.message("acp.screen.section.command", command)))
val row = TranscriptRowPanel().apply {
isOpaque = false
alignmentX = LEFT_ALIGNMENT
}
val body = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
}
fun renderToolCall(updatedToolCall: ThreadToolCall) {
body.removeAll()
val presentation = AgentAcpToolCallPresenter.create(
updatedToolCall,
textPreview = { previewText(it) },
outputPreview = { previewText(it, OUTPUT_PREVIEW_MAX_CHARS, OUTPUT_PREVIEW_MAX_LINES) },
)
body.add(toolTitleTextArea(presentation.title))
presentation.status?.let { status ->
body.add(toolStatusLabel(status))
}
toolCall.path?.takeIf { it.isNotBlank() }?.let { path ->
add(RowBodySection(EngineBundle.message("acp.screen.section.path", path)))
}
toolCall.outputText.takeIf { it.isNotBlank() }?.let { output ->
add(RowBodySection(EngineBundle.message("acp.screen.section.output",
previewText(output, OUTPUT_PREVIEW_MAX_CHARS, OUTPUT_PREVIEW_MAX_LINES)), true))
}
toolCall.summary?.takeIf { it.isNotBlank() }?.let { summary ->
add(RowBodySection(EngineBundle.message("acp.screen.section.summary", previewText(summary))))
}
toolCall.approval?.let { approval ->
add(RowBodySection(EngineBundle.message("acp.screen.approval.status", humanize(approval.status.name))))
for ((text, monospace) in presentation.details) {
body.add(Box.createVerticalStrut(2))
body.add(rowTextArea(text, monospace))
}
}
return entryRow(
label = EngineBundle.message("acp.screen.row.tool"),
title = toolCall.title ?: toolCall.command ?: toolCall.kind ?: toolCall.id,
sections = sections,
status = displayStatus(toolCall.status),
)
renderToolCall(toolCall)
row.add(body, BorderLayout.CENTER)
row.setTranscriptUpdater { entry ->
val updated = entry as? ThreadToolCall ?: return@setTranscriptUpdater false
if (updated.id != toolCall.id) return@setTranscriptUpdater false
renderToolCall(updated)
body.revalidate()
body.repaint()
row.invalidate()
true
}
return row
}
private fun toolTitleTextArea(title: String): JTextArea = rowTextArea(title, monospace = false).apply {
font = UIUtil.getLabelFont().deriveFont(Font.BOLD)
}
private fun toolStatusLabel(status: @NlsSafe String): JBLabel = JBLabel(status).apply {
foreground = UIUtil.getContextHelpForeground()
border = JBUI.Borders.emptyTop(1)
alignmentX = LEFT_ALIGNMENT
}
private fun commandRow(command: ThreadCommand): JComponent {
@@ -943,9 +786,10 @@ internal class AgentAcpThreadScreen(
alignmentX = LEFT_ALIGNMENT
}
private fun rowMarkdownPane(text: @NlsSafe String): TranscriptMessageMarkdownPane = TranscriptMessageMarkdownPane().apply {
setMarkdownText(text)
}
private fun rowMarkdownPane(text: @NlsSafe String): TranscriptMessageMarkdownPane =
TranscriptMessageMarkdownPane(project).apply {
setMarkdownText(text)
}
private fun previewText(text: String, maxChars: Int = BODY_PREVIEW_MAX_CHARS, maxLines: Int = BODY_PREVIEW_MAX_LINES): @NlsSafe String {
var truncated = false
@@ -984,36 +828,6 @@ internal class AgentAcpThreadScreen(
}
}
private fun Dimension.debugString(): String = "${width}x${height}"
private fun Point.debugString(): String = "${x},${y}"
private fun Rectangle.debugString(): String = "${x},${y} ${width}x${height}"
private fun JPanel.lastComponentBoundsDebugString(): String {
val lastComponent = components.lastOrNull() ?: return "none"
return lastComponent.bounds.debugString()
}
private fun JPanel.lastContentComponentBoundsDebugString(): String =
lastContentComponent()?.bounds?.debugString() ?: "none"
private fun JPanel.lastContentComponent(): Component? {
for (index in componentCount - 1 downTo 0) {
val component = getComponent(index)
if (component !is Box.Filler) return component
}
return null
}
private data class TranscriptStickDecision(
val shouldStickToBottom: Boolean,
val reason: String,
val scrollState: String,
val followTailBefore: Boolean,
val followTailAfter: Boolean,
)
private data class TranscriptRenderChange(
val changed: Boolean,
)
@@ -1037,13 +851,6 @@ internal class AgentAcpThreadScreen(
val isWithinTailDrift: Boolean
get() = distanceToBottom <= tailDriftThreshold
fun debugString(): String =
"value=$value, visible=$visibleAmount, min=$minimum, max=$maximum, " +
"distanceToBottom=$distanceToBottom, threshold=$stickThreshold, " +
"tailDriftThreshold=$tailDriftThreshold, valueIsAdjusting=$valueIsAdjusting, " +
"unitIncrement=$unitIncrement, blockIncrement=$blockIncrement, " +
"followTail=$followTail, manualTailPauseActive=$manualTailPauseActive"
}
private data class RowBodySection(
@@ -1162,8 +969,13 @@ private class TranscriptTextArea(text: @NlsSafe String) : JTextArea(text) {
}
}
private class TranscriptMessageMarkdownPane : JBHtmlPane(MARKDOWN_STYLE_CONFIGURATION, MARKDOWN_PANE_CONFIGURATION) {
private class TranscriptMessageMarkdownPane(
private val project: Project,
) : JBHtmlPane(MARKDOWN_STYLE_CONFIGURATION, MARKDOWN_PANE_CONFIGURATION) {
private var markdownText: String = ""
private val markdownRenderLock = Any()
private var pendingMarkdownText: String? = null
private var markdownRenderInProgress = false
init {
isOpaque = false
@@ -1171,14 +983,70 @@ private class TranscriptMessageMarkdownPane : JBHtmlPane(MARKDOWN_STYLE_CONFIGUR
border = null
font = UIUtil.getLabelFont()
alignmentX = LEFT_ALIGNMENT
caret = DefaultCaret()
putClientProperty("caretWidth", null)
caretPosition = 0
(caret as? DefaultCaret)?.updatePolicy = DefaultCaret.NEVER_UPDATE
addHyperlinkListener { event ->
AgentAcpThreadHyperlinkHandler.handle(project, event)
}
}
fun setMarkdownText(text: @NlsSafe String) {
if (markdownText == text) return
markdownText = text
this.text = AgentAcpThreadMessageMarkdownRenderer.renderHtmlDocument(text)
isVisible = text.isNotBlank()
invalidate()
if (text.isBlank()) {
synchronized(markdownRenderLock) {
pendingMarkdownText = null
}
applyHtml(text, "")
return
}
val shouldScheduleRender = synchronized(markdownRenderLock) {
pendingMarkdownText = text
if (markdownRenderInProgress) {
false
}
else {
markdownRenderInProgress = true
true
}
}
if (shouldScheduleRender) {
ApplicationManager.getApplication().executeOnPooledThread {
renderPendingMarkdown()
}
}
}
private fun renderPendingMarkdown() {
while (true) {
val textToRender = synchronized(markdownRenderLock) {
val pending = pendingMarkdownText
if (pending == null) {
markdownRenderInProgress = false
return
}
pendingMarkdownText = null
pending
}
val html = AgentAcpThreadMessageMarkdownRenderer.renderHtmlDocument(textToRender)
ApplicationManager.getApplication().invokeLater {
applyHtml(textToRender, html)
}
}
}
private fun applyHtml(textToRender: String, html: String) {
if (!project.isDisposed && markdownText == textToRender) {
this.text = html
isVisible = textToRender.isNotBlank()
invalidate()
revalidate()
repaint()
}
}
override fun getPreferredSize(): Dimension {
@@ -1186,7 +1054,7 @@ private class TranscriptMessageMarkdownPane : JBHtmlPane(MARKDOWN_STYLE_CONFIGUR
if (availableWidth <= 0) return super.getPreferredSize()
val previousSize = size
setSize(availableWidth, Int.MAX_VALUE)
setSize(availableWidth, Short.MAX_VALUE.toInt())
val preferredHeight = super.getPreferredSize().height
size = previousSize
return Dimension(availableWidth, preferredHeight)
@@ -1214,14 +1082,6 @@ private fun AdjustmentEvent.isUserTranscriptAdjustment(): Boolean =
else -> false
}
private fun Int.debugAdjustmentType(): String = when (this) {
AdjustmentEvent.UNIT_INCREMENT -> "UNIT_INCREMENT"
AdjustmentEvent.UNIT_DECREMENT -> "UNIT_DECREMENT"
AdjustmentEvent.BLOCK_INCREMENT -> "BLOCK_INCREMENT"
AdjustmentEvent.BLOCK_DECREMENT -> "BLOCK_DECREMENT"
AdjustmentEvent.TRACK -> "TRACK"
else -> "UNKNOWN($this)"
}
/** Splits a PascalCase enum name into spaced words, e.g. `WaitingForApproval` -> `Waiting For Approval`. */
private fun humanize(name: String): String =
@@ -0,0 +1,141 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.engine.ui
import com.intellij.agent.workbench.engine.core.ThreadApprovalStatus
import com.intellij.agent.workbench.engine.core.ThreadToolCall
import com.intellij.agent.workbench.engine.core.ThreadToolOutput
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
internal object AgentAcpToolCallPresenter {
fun shouldRenderInTranscript(toolCall: ThreadToolCall): Boolean {
if (!toolCall.complete) return true
if (toolCall.approval?.status == ThreadApprovalStatus.Requested) return true
return toolCall.isProblemToolCall()
}
fun create(
toolCall: ThreadToolCall,
textPreview: (String) -> String = { it },
outputPreview: (String) -> String = { it },
): AgentAcpToolCallPresentation {
val title = toolTitle(toolCall)
val details = buildList {
toolCall.command?.takeIf { it.isNotBlank() && it != title }?.let { command ->
add(AgentAcpToolCallDetail(EngineBundle.message("acp.screen.section.command", command)))
}
toolCall.path?.takeIf { it.isNotBlank() }?.let { path ->
add(AgentAcpToolCallDetail(EngineBundle.message("acp.screen.section.path", path)))
}
toolCall.summary?.takeIf { it.isNotBlank() }?.let { summary ->
add(AgentAcpToolCallDetail(EngineBundle.message("acp.screen.section.summary", textPreview(summary))))
}
toolCall.approval?.reason?.takeIf { it.isNotBlank() }?.let { reason ->
add(AgentAcpToolCallDetail(EngineBundle.message("acp.screen.tool.approval.reason", textPreview(reason))))
}
toolCall.outputText.takeIf { toolCall.shouldShowOutputDetail(it) }?.let { output ->
add(AgentAcpToolCallDetail(EngineBundle.message("acp.screen.section.output", outputPreview(output)), monospace = true))
}
}
return AgentAcpToolCallPresentation(
title = title,
status = toolStatus(toolCall),
details = details,
)
}
private fun toolTitle(toolCall: ThreadToolCall): String {
return toolCall.title?.takeIf { it.isNotBlank() }
?: toolCall.command?.takeIf { it.isNotBlank() }
?: toolCall.kind?.takeIf { it.isNotBlank() }?.let(::humanizeIdentifier)
?: toolCall.id
}
private fun toolStatus(toolCall: ThreadToolCall): String? {
val statuses = listOfNotNull(
toolCall.status?.takeIf { it.isNotBlank() }?.let(::humanizeIdentifier),
toolCall.approval?.status?.let(::approvalStatusText),
).distinctBy { it.lowercase() }
return when (statuses.size) {
0 -> null
1 -> statuses.single()
else -> EngineBundle.message("acp.screen.tool.status.withApproval", statuses[0], statuses[1])
}
}
private fun approvalStatusText(status: ThreadApprovalStatus): String = when (status) {
ThreadApprovalStatus.Requested -> EngineBundle.message("acp.screen.tool.approval.requested")
ThreadApprovalStatus.Approved -> EngineBundle.message("acp.screen.tool.approval.approved")
ThreadApprovalStatus.Rejected -> EngineBundle.message("acp.screen.tool.approval.rejected")
ThreadApprovalStatus.Expired -> EngineBundle.message("acp.screen.tool.approval.expired")
}
private fun shouldShowOutput(outputs: List<ThreadToolOutput>, text: String): Boolean {
if (text.isBlank()) return false
if (outputs.all { it.stream == "structured" } && isCompactCounterMetadata(text)) return false
return true
}
private fun ThreadToolCall.shouldShowOutputDetail(text: String): Boolean {
return shouldShowOutput(output, text) && (!complete || isProblemToolCall())
}
internal fun isCompactCounterMetadata(text: String): Boolean {
val trimmed = text.trim()
if (trimmed.length > COMPACT_METADATA_MAX_LENGTH || '\n' in trimmed || '\r' in trimmed) return false
val jsonObject = runCatching { Json.parseToJsonElement(trimmed).jsonObject }.getOrNull() ?: return false
return jsonObject.values.all { value ->
val primitive = value as? JsonPrimitive ?: return@all false
primitive.content.toLongOrNull() != null || primitive.content.toBooleanStrictOrNull() != null
} && jsonObject.keys.all(::isMetadataCounterKey)
}
private fun isMetadataCounterKey(key: String): Boolean =
key.normalizedMetadataKey().let { normalizedKey ->
normalizedKey == "referencecount" ||
normalizedKey == "totalmatches" ||
normalizedKey == "truncated" ||
normalizedKey.endsWith("count") ||
normalizedKey.endsWith("size") ||
normalizedKey.endsWith("matches")
}
private fun String.normalizedMetadataKey(): String = replace("_", "").replace("-", "").lowercase()
private fun String?.isProblemStatus(): Boolean = when (this?.trim()?.lowercase()) {
"failed", "failure", "error", "errored", "cancelled", "canceled", "rejected", "expired", "timeout", "timedout", "timed_out" -> true
else -> false
}
private fun ThreadToolCall.isProblemToolCall(): Boolean =
approval?.status == ThreadApprovalStatus.Rejected ||
approval?.status == ThreadApprovalStatus.Expired ||
status.isProblemStatus() ||
exitCode?.let { it != 0 } == true
private fun humanizeIdentifier(value: String): String {
val spaced = value.trim()
.replace('_', ' ')
.replace('-', ' ')
.replace(Regex("(?<=[a-z0-9])(?=[A-Z])"), " ")
.replace(Regex("\\s+"), " ")
return spaced.split(' ').joinToString(" ") { word ->
word.replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase() else char.toString() }
}
}
}
internal data class AgentAcpToolCallPresentation(
val title: String,
val status: String?,
val details: List<AgentAcpToolCallDetail>,
)
internal data class AgentAcpToolCallDetail(
val text: String,
val monospace: Boolean = false,
)
private const val COMPACT_METADATA_MAX_LENGTH = 256
@@ -0,0 +1,70 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.engine.platform
import com.intellij.agent.workbench.engine.ui.AgentAcpThreadHyperlinkHandler
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.nio.file.Path
import javax.swing.JEditorPane
import javax.swing.event.HyperlinkEvent
import javax.swing.text.AttributeSet
import javax.swing.text.Element
import javax.swing.text.html.HTML
class AgentAcpThreadHyperlinkHandlerTest {
private val projectBasePath = "/repo"
@Test
fun `parses project relative file link with line and column`() {
val location = AgentAcpThreadHyperlinkHandler.parseLocalFileLocation("src/Main.kt:12:3", projectBasePath)
assertThat(location?.path).isEqualTo(Path.of("/repo/src/Main.kt"))
assertThat(location?.line).isEqualTo(11)
assertThat(location?.column).isEqualTo(2)
}
@Test
fun `parses project relative filename with line`() {
val location = AgentAcpThreadHyperlinkHandler.parseLocalFileLocation("README.md:12", projectBasePath)
assertThat(location?.path).isEqualTo(Path.of("/repo/README.md"))
assertThat(location?.line).isEqualTo(11)
assertThat(location?.column).isNull()
}
@Test
fun `parses absolute file link with fragment line`() {
val location = AgentAcpThreadHyperlinkHandler.parseLocalFileLocation("file:///repo/src/Main.kt#L12", projectBasePath)
assertThat(location?.path).isEqualTo(Path.of("/repo/src/Main.kt"))
assertThat(location?.line).isEqualTo(11)
assertThat(location?.column).isNull()
}
@Test
fun `ignores web links`() {
assertThat(AgentAcpThreadHyperlinkHandler.parseLocalFileLocation("https://www.jetbrains.com", projectBasePath)).isNull()
}
@Test
fun `extracts href from source element before event description`() {
val pane = JEditorPane("text/html", """
<html><body><a href="src/Main.kt:12">file</a></body></html>
""".trimIndent())
val linkElement = pane.document.defaultRootElement.findElementWithHref()!!
val event = HyperlinkEvent(this, HyperlinkEvent.EventType.ACTIVATED, null, "fallback", linkElement)
assertThat(AgentAcpThreadHyperlinkHandler.extractTarget(event)).isEqualTo("src/Main.kt:12")
}
private fun Element.findElementWithHref(): Element? {
val anchorAttributes = attributes.getAttribute(HTML.Tag.A) as? AttributeSet
if (attributes.getAttribute(HTML.Attribute.HREF) != null || anchorAttributes?.getAttribute(HTML.Attribute.HREF) != null) {
return this
}
for (index in 0 until elementCount) {
getElement(index).findElementWithHref()?.let { return it }
}
return null
}
}
@@ -50,6 +50,13 @@ class AgentAcpThreadMessageMarkdownRendererTest {
assertThat(html).doesNotContain("<div>block</div>")
}
@Test
fun `renders markdown links`() {
val html = AgentAcpThreadMessageMarkdownRenderer.renderHtmlBody("Open [JetBrains](https://www.jetbrains.com/).")
assertThat(html).contains("<a href=\"https://www.jetbrains.com/\">JetBrains</a>")
}
@Test
fun `blank input renders blank html`() {
assertThat(AgentAcpThreadMessageMarkdownRenderer.renderHtmlBody("")).isEmpty()
@@ -0,0 +1,153 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.engine.platform
import com.intellij.agent.workbench.engine.core.ThreadApprovalStatus
import com.intellij.agent.workbench.engine.core.ThreadToolApproval
import com.intellij.agent.workbench.engine.core.ThreadToolCall
import com.intellij.agent.workbench.engine.core.ThreadToolOutput
import com.intellij.agent.workbench.engine.ui.AgentAcpToolCallPresenter
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class AgentAcpToolCallPresenterTest {
@Test
fun `renders title and approval in compact header state`() {
val presentation = AgentAcpToolCallPresenter.create(
ThreadToolCall(
id = "tool-1",
title = "Web search: Portugal in December",
status = "completed",
output = listOf(ThreadToolOutput(text = "{\"referenceCount\":1}")),
approval = ThreadToolApproval(toolCallId = "tool-1", status = ThreadApprovalStatus.Approved),
),
)
assertThat(presentation.title).isEqualTo("Web search: Portugal in December")
assertThat(presentation.status).isEqualTo("Completed - Approved")
assertThat(presentation.details).isEmpty()
}
@Test
fun `keeps useful tool output visible as monospace detail`() {
val presentation = AgentAcpToolCallPresenter.create(
ThreadToolCall(
id = "tool-1",
title = "Read file",
output = listOf(ThreadToolOutput(stream = "stdout", text = "first line\nsecond line")),
),
)
val detail = presentation.details.single()
assertThat(detail.text).isEqualTo("Output:\nfirst line\nsecond line")
assertThat(detail.monospace).isTrue()
}
@Test
fun `shows approval reason when available`() {
val presentation = AgentAcpToolCallPresenter.create(
ThreadToolCall(
id = "tool-1",
title = "Run command",
approval = ThreadToolApproval(
toolCallId = "tool-1",
status = ThreadApprovalStatus.Rejected,
reason = "User denied execution",
),
),
)
assertThat(presentation.status).isEqualTo("Rejected")
val detail = presentation.details.single()
assertThat(detail.text).isEqualTo("Approval reason: User denied execution")
assertThat(detail.monospace).isFalse()
}
@Test
fun `detects compact counter metadata`() {
assertThat(AgentAcpToolCallPresenter.isCompactCounterMetadata("{\"referenceCount\":1}")).isTrue()
assertThat(AgentAcpToolCallPresenter.isCompactCounterMetadata("{\"totalMatches\":0}")).isTrue()
assertThat(AgentAcpToolCallPresenter.isCompactCounterMetadata("{\"totalMatches\":161,\"truncated\":true}")).isTrue()
assertThat(AgentAcpToolCallPresenter.isCompactCounterMetadata("{\"result\":\"done\"}")).isFalse()
assertThat(AgentAcpToolCallPresenter.isCompactCounterMetadata("{\"referenceCount\":1}\nmore")).isFalse()
}
@Test
fun `hides completed compact metadata tool call from transcript`() {
val toolCall = ThreadToolCall(
id = "tool-1",
title = "Web search",
status = "completed",
output = listOf(ThreadToolOutput(text = "{\"referenceCount\":1}")),
approval = ThreadToolApproval(toolCallId = "tool-1", status = ThreadApprovalStatus.Approved),
complete = true,
)
assertThat(AgentAcpToolCallPresenter.shouldRenderInTranscript(toolCall)).isFalse()
}
@Test
fun `hides completed structured count output tool call from transcript`() {
val toolCall = ThreadToolCall(
id = "tool-1",
title = "grep",
status = "completed",
output = listOf(ThreadToolOutput(text = "{\"totalMatches\":161,\"truncated\":true}")),
complete = true,
)
assertThat(AgentAcpToolCallPresenter.shouldRenderInTranscript(toolCall)).isFalse()
}
@Test
fun `keeps running tool call visible in transcript`() {
val toolCall = ThreadToolCall(
id = "tool-1",
title = "Web search",
status = "running",
complete = false,
)
assertThat(AgentAcpToolCallPresenter.shouldRenderInTranscript(toolCall)).isTrue()
}
@Test
fun `keeps requested approval visible in transcript`() {
val toolCall = ThreadToolCall(
id = "tool-1",
title = "Run command",
status = "waiting",
approval = ThreadToolApproval(toolCallId = "tool-1", status = ThreadApprovalStatus.Requested),
complete = true,
)
assertThat(AgentAcpToolCallPresenter.shouldRenderInTranscript(toolCall)).isTrue()
}
@Test
fun `keeps failed tool call visible in transcript`() {
val toolCall = ThreadToolCall(
id = "tool-1",
title = "Run command",
status = "failed",
output = listOf(ThreadToolOutput(stream = "stderr", text = "permission denied")),
complete = true,
)
assertThat(AgentAcpToolCallPresenter.shouldRenderInTranscript(toolCall)).isTrue()
assertThat(AgentAcpToolCallPresenter.create(toolCall).details.single().text).isEqualTo("Output:\npermission denied")
}
@Test
fun `hides successful completed output from transcript`() {
val toolCall = ThreadToolCall(
id = "tool-1",
title = "Read file",
status = "completed",
output = listOf(ThreadToolOutput(stream = "stdout", text = "important result")),
complete = true,
)
assertThat(AgentAcpToolCallPresenter.shouldRenderInTranscript(toolCall)).isFalse()
assertThat(AgentAcpToolCallPresenter.create(toolCall).details).isEmpty()
}
}