From ab4d03b407aab3a3104636d1d9b0212818fbfaaa Mon Sep 17 00:00:00 2001 From: Sergey Ignatov Date: Fri, 26 Jun 2026 16:52:07 +0200 Subject: [PATCH] IJPL-248623 IJPL-248555 stabilize Agent Workbench ACP chat rendering Streaming and restored ACP threads rebuilt transcript rows too broadly, so Swing could recompute wrapped text while the viewport was trying to follow the tail. Keep the transcript panel stable by using a scrollable transcript view, coalescing projection renders, preserving manual scroll intent, and updating the streaming tail row in place. Record local user prompts before the ACP connection is ready so the sender sees the message immediately. Normalize text-like raw ACP tool output objects and arrays to readable transcript text instead of JSON wrappers, and keep scroll/render diagnostics in the IDE log for follow-up investigation. GitOrigin-RevId: ec47b45fd674e89042cd83667cae9164a23cb8b4 --- .../messages/EngineBundle.properties | 13 + .../engine/src/ui/AgentAcpThreadScreen.kt | 983 ++++++++++++++++-- 2 files changed, 888 insertions(+), 108 deletions(-) diff --git a/plugins/agent-workbench/engine/resources/messages/EngineBundle.properties b/plugins/agent-workbench/engine/resources/messages/EngineBundle.properties index ab39948af65f..eeb8073b8662 100644 --- a/plugins/agent-workbench/engine/resources/messages/EngineBundle.properties +++ b/plugins/agent-workbench/engine/resources/messages/EngineBundle.properties @@ -4,6 +4,7 @@ acp.screen.empty=No messages yet. Thread status: {0}. acp.screen.pendingApprovals={0} approval(s) waiting for your response acp.screen.input.hint=Type a message and press Enter (Shift+Enter for a new line) acp.screen.input.readonly=This thread is read-only +acp.screen.input.accessible.name=Thread message input acp.screen.row.tool=TOOL acp.screen.row.command=COMMAND acp.screen.row.plan=PLAN @@ -11,3 +12,15 @@ acp.screen.row.diff=DIFF acp.screen.row.context=CONTEXT acp.screen.row.status={0} - {1} acp.screen.approval.status=Approval: {0} +acp.screen.message.streaming=streaming +acp.screen.section.command=Command: {0} +acp.screen.section.path=Path: {0} +acp.screen.section.output=Output:\n{0} +acp.screen.section.summary=Summary: {0} +acp.screen.command.status.exitCode={0}, exit code {1} +acp.screen.command.exitCode=exit code {0} +acp.screen.plan.completed=completed +acp.screen.plan.running=running +acp.screen.plan.item={0} - {1} +acp.screen.diff.empty=No diff content. +acp.screen.output.truncated=... output truncated diff --git a/plugins/agent-workbench/engine/src/ui/AgentAcpThreadScreen.kt b/plugins/agent-workbench/engine/src/ui/AgentAcpThreadScreen.kt index c462a4af6b00..61510a4f6fb7 100644 --- a/plugins/agent-workbench/engine/src/ui/AgentAcpThreadScreen.kt +++ b/plugins/agent-workbench/engine/src/ui/AgentAcpThreadScreen.kt @@ -7,6 +7,7 @@ import com.intellij.agent.workbench.engine.core.ThreadCommand import com.intellij.agent.workbench.engine.core.ThreadContextCompaction import com.intellij.agent.workbench.engine.core.ThreadFileDiff import com.intellij.agent.workbench.engine.core.ThreadId +import com.intellij.agent.workbench.engine.core.MessageRole import com.intellij.agent.workbench.engine.core.ThreadMessage import com.intellij.agent.workbench.engine.core.ThreadPlan import com.intellij.agent.workbench.engine.core.ThreadProjection @@ -26,19 +27,32 @@ import com.intellij.openapi.util.NlsSafe import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBViewport import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout +import java.awt.Component import java.awt.Dimension +import java.awt.Font +import java.awt.Point +import java.awt.Rectangle +import java.awt.event.AdjustmentEvent +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent import java.awt.event.KeyAdapter import java.awt.event.KeyEvent import javax.swing.Box import javax.swing.BoxLayout import javax.swing.JComponent import javax.swing.JPanel +import javax.swing.Scrollable import javax.swing.JTextArea +import javax.swing.JViewport import javax.swing.ScrollPaneConstants +import javax.swing.plaf.basic.BasicTextUI +import javax.swing.text.View +import kotlin.math.ceil /** * Non-terminal, IDE-native screen for a Engine/ACP thread. Renders the live [ThreadProjection] @@ -52,16 +66,33 @@ internal class AgentAcpThreadScreen( ) : JPanel(BorderLayout()) { private val statusLabel = JBLabel().apply { font = JBFont.label().asBold() } private val subtitleLabel = JBLabel().apply { foreground = UIUtil.getContextHelpForeground() } - private val transcriptPanel = JPanel().apply { + 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? = null + private var renderedPendingApprovals = -1 + private var renderedEmptyStatus: ThreadStatus? = null + private val renderScheduleLock = Any() + private var projectionRenderScheduled = false + private val transcriptPanel = ScrollableTranscriptPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) border = JBUI.Borders.empty(8) isOpaque = false } + private val transcriptScrollPane = TranscriptScrollPane(transcriptPanel) { block -> + runWithoutTranscriptUnsticking(block) + }.apply { border = JBUI.Borders.empty() } private val inputArea = JBTextArea(3, 0).apply { lineWrap = true wrapStyleWord = true border = JBUI.Borders.empty(6) emptyText.text = EngineBundle.message("acp.screen.input.hint") + accessibleContext.accessibleName = EngineBundle.message("acp.screen.input.accessible.name") addKeyListener(object : KeyAdapter() { override fun keyPressed(e: KeyEvent) { if (e.keyCode == KeyEvent.VK_ENTER && !e.isShiftDown) { @@ -74,15 +105,12 @@ internal class AgentAcpThreadScreen( init { border = JBUI.Borders.empty(4) + transcriptPanel.scrollToBottomIfNeeded = { + scheduleTranscriptTailScrollFromLayout() + } + installTranscriptScrollDiagnostics() add(buildHeader(), BorderLayout.NORTH) - add( - JBScrollPane( - transcriptPanel, - ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, - ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, - ).apply { border = JBUI.Borders.empty() }, - BorderLayout.CENTER, - ) + add(transcriptScrollPane, BorderLayout.CENTER) add(buildInput(), BorderLayout.SOUTH) render(EngineProjectService.getInstance(project).projection(threadId)) subscribe(parent) @@ -108,9 +136,15 @@ internal class AgentAcpThreadScreen( } 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 = EngineProjectService.getInstance(project).projection(threadId) + val projection = service.projection(threadId) LOG.warn( "[$threadId] ACP screen cannot send: no prompt sender, " + "status=${projection.thread.status}, runtimeKind=${projection.thread.runtimeKind}, " + @@ -121,6 +155,12 @@ internal class AgentAcpThreadScreen( 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() { @@ -150,24 +190,62 @@ internal class AgentAcpThreadScreen( project.messageBus.connect(parent).subscribe( EngineEvents.TOPIC, object : EngineEvents { - override fun eventAppended(event: ThreadEventEnvelope) {} + 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 projectionUpdated(threadId: ThreadId) { if (threadId != this@AgentAcpThreadScreen.threadId) return - ApplicationManager.getApplication().invokeLater { - if (!project.isDisposed) { - render(EngineProjectService.getInstance(project).projection(threadId)) - // The user is looking at this thread, so any fresh agent output is already seen. - if (isShowing) EngineUnreadTracker.getInstance(project).markRead(threadId) - } - } + LOG.info( + "[$threadId] ACP screen projectionUpdated received: " + + "scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}", + ) + scheduleProjectionRender() } }, ) } + private fun scheduleProjectionRender() { + val scheduled = synchronized(renderScheduleLock) { + if (projectionRenderScheduled) { + false + } + else { + projectionRenderScheduled = true + true + } + } + LOG.info( + "[$threadId] ACP screen projection render schedule: scheduled=$scheduled, " + + "scroll=${transcriptScrollDebugState()}, layout=${transcriptLayoutDebugState()}", + ) + if (!scheduled) return + ApplicationManager.getApplication().invokeLater { + synchronized(renderScheduleLock) { + projectionRenderScheduled = false + } + 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) + } + } + } + @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 @@ -177,15 +255,75 @@ internal class AgentAcpThreadScreen( humanize(thread.status.name), ) - transcriptPanel.removeAll() - if (projection.transcript.isEmpty()) { - transcriptPanel.add(emptyState(thread.status)) + 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 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 { + val previousEntries = renderedTranscriptEntries + if (previousEntries == projection.transcript && + 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() || + projection.pendingApprovals > 0 || + renderedPendingApprovals > 0 + return if (requiresFullRebuild) { + rebuildTranscript(projection, emptyStatus, renderId) } else { - for (entry in projection.transcript) { - transcriptPanel.add(transcriptRow(entry)) - transcriptPanel.add(Box.createVerticalStrut(8)) - } + updateTranscriptTail(previousEntries, projection, renderId) + } + } + + private fun rebuildTranscript(projection: ThreadProjection, emptyStatus: ThreadStatus?, renderId: Int): TranscriptRenderChange { + transcriptPanel.removeAll() + if (projection.transcript.isEmpty()) { + transcriptPanel.add(emptyState(projection.thread.status)) + } + else { + addTranscriptEntries(projection.transcript, startIndex = 0) } if (projection.pendingApprovals > 0) { transcriptPanel.add( @@ -196,24 +334,330 @@ internal class AgentAcpThreadScreen( }, ) } + rememberRenderedTranscript(projection, emptyStatus) transcriptPanel.revalidate() transcriptPanel.repaint() - - val sender = EnginePromptSender.forThread(project, threadId) - val canSend = thread.runtimeKind == RuntimeKind.Acp && - thread.status != ThreadStatus.Disconnected && - sender != null LOG.info( - "[$threadId] ACP screen render: canSend=$canSend, hasSender=${sender != null}, " + - "status=${thread.status}, runtimeKind=${thread.runtimeKind}, binding=${projection.runtimeBinding}, " + - "transcriptSize=${projection.transcript.size}", + "[$threadId] ACP screen render#$renderId rows rebuilt: " + + "components=${transcriptPanel.componentCount}, afterRevalidateScroll=${transcriptScrollDebugState()}, " + + "layout=${transcriptLayoutDebugState()}", ) - inputArea.isEnabled = canSend - inputArea.emptyText.text = EngineBundle.message( - if (canSend) "acp.screen.input.hint" else "acp.screen.input.readonly", + return TranscriptRenderChange(changed = true) + } + + private fun updateTranscriptTail( + previousEntries: List, + projection: ThreadProjection, + renderId: Int, + ): TranscriptRenderChange { + val changedIndex = firstChangedTranscriptIndex(previousEntries, projection.transcript) + if (changedIndex == previousEntries.lastIndex && changedIndex == projection.transcript.lastIndex) { + val row = transcriptPanel.getComponentOrNull(transcriptComponentIndex(changedIndex)) as? TranscriptRowPanel + if (row != null && row.updateTranscriptEntry(projection.transcript[changedIndex])) { + rememberRenderedTranscript(projection, 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) + 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 + renderedEmptyStatus = emptyStatus + } + + private fun addTranscriptEntries(entries: List, startIndex: Int) { + for (index in startIndex until entries.size) { + transcriptPanel.add(transcriptRow(entries[index])) + transcriptPanel.add(Box.createVerticalStrut(8)) + } + } + + private fun removeTranscriptEntriesFrom(startIndex: Int) { + val componentIndex = transcriptComponentIndex(startIndex) + while (transcriptPanel.componentCount > componentIndex) { + transcriptPanel.remove(componentIndex) + } + } + + private fun firstChangedTranscriptIndex(previousEntries: List, entries: List): Int { + val sharedSize = minOf(previousEntries.size, entries.size) + for (index in 0 until sharedSize) { + if (previousEntries[index] != entries[index]) return index + } + return sharedSize + } + + private fun transcriptComponentIndex(entryIndex: Int): Int = entryIndex * TRANSCRIPT_COMPONENTS_PER_ENTRY + + private fun JPanel.getComponentOrNull(index: Int): Component? = + if (index in 0 until componentCount) getComponent(index) else null + + private fun transcriptStickDecision(projection: ThreadProjection): TranscriptStickDecision { + val previousComponents = transcriptPanel.componentCount + val scrollState = transcriptScrollState() + val atBottom = scrollState.isAtBottom + val latestIsUserMessage = projection.transcript.lastOrNull().isUserMessage() + if (latestIsUserMessage) { + manualTranscriptTailPauseUntil = 0L + } + val manualTailPauseActive = isManualTranscriptTailPauseActive() + val followTailBefore = followTranscriptTail + val keepFollowingTailAcrossLayoutDrift = followTailBefore && !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, ) } + private fun ThreadTranscriptEntry?.isUserMessage(): Boolean = this is ThreadMessage && role == MessageRole.User + + private fun installTranscriptScrollDiagnostics() { + val scrollBar = transcriptScrollPane.verticalScrollBar + scrollBar.addAdjustmentListener { event -> + val followTailBefore = followTranscriptTail + val userAdjustment = event.isUserTranscriptAdjustment() + val canUnstick = canUnstickTranscriptTail && programmaticScrollDepth == 0 + val lastScrollValueBefore = lastTranscriptScrollValue + val requestedBefore = transcriptScrollToBottomRequested + val scrollState = transcriptScrollState() + if (canUnstick) { + when { + scrollState.value < lastScrollValueBefore -> pauseTranscriptTailForUserScroll() + scrollState.isAtBottom && !isManualTranscriptTailPauseActive() -> followTranscriptTail = true + } + } + 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 + 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() { + 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()}", + ) + } + } + + private fun scrollTranscriptToBottomIfNeeded(renderId: Int?, reason: String) { + 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) + } + + private fun scrollTranscriptToBottom(renderId: Int?, reason: String) { + 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) { + val previousCanUnstick = canUnstickTranscriptTail + programmaticScrollDepth++ + try { + canUnstickTranscriptTail = false + block() + } + finally { + canUnstickTranscriptTail = previousCanUnstick + programmaticScrollDepth-- + } + } + + private fun pauseTranscriptTailForUserScroll() { + followTranscriptTail = false + transcriptScrollToBottomRequested = false + manualTranscriptTailPauseUntil = System.currentTimeMillis() + MANUAL_TAIL_PAUSE_MS + } + + 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 + if (extentWidth <= 0) return true + val viewWidth = viewport.viewSize.width + return viewWidth >= extentWidth - JBUI.scale(2) + } + + private fun transcriptScrollDebugState(): String { + return transcriptScrollState().debugString() + } + + private fun transcriptScrollState(): TranscriptScrollState { + val scrollBar = transcriptScrollPane.verticalScrollBar + return TranscriptScrollState( + value = scrollBar.value, + visibleAmount = scrollBar.visibleAmount, + minimum = scrollBar.minimum, + maximum = scrollBar.maximum, + distanceToBottom = maxOf(0, scrollBar.maximum - (scrollBar.value + scrollBar.visibleAmount)), + stickThreshold = JBUI.scale(STICK_TO_BOTTOM_THRESHOLD_PX), + tailDriftThreshold = JBUI.scale(TAIL_FOLLOW_DRIFT_THRESHOLD_PX), + valueIsAdjusting = scrollBar.model.valueIsAdjusting, + unitIncrement = scrollBar.unitIncrement, + blockIncrement = scrollBar.blockIncrement, + followTail = followTranscriptTail, + manualTailPauseActive = isManualTranscriptTailPauseActive(), + ) + } + + 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)" + } + + 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() @@ -223,101 +667,424 @@ internal class AgentAcpThreadScreen( private fun transcriptRow(entry: ThreadTranscriptEntry): JComponent = when (entry) { is ThreadMessage -> messageRow(entry) is ThreadToolCall -> toolRow(entry) - is ThreadCommand -> entryRow( - label = EngineBundle.message("acp.screen.row.command"), - title = entry.title ?: entry.command ?: entry.id, - body = entry.outputText, - status = entry.status, - ) - is ThreadPlan -> entryRow( - label = EngineBundle.message("acp.screen.row.plan"), - title = entry.title ?: entry.id, - body = entry.items.joinToString(separator = "\n") { item -> item.title }, - status = if (entry.complete) "completed" else null, - ) - is ThreadFileDiff -> entryRow( - label = EngineBundle.message("acp.screen.row.diff"), - title = entry.title ?: entry.path ?: entry.id, - body = entry.newText.orEmpty(), - status = entry.status, - ) - is ThreadContextCompaction -> entryRow( - label = EngineBundle.message("acp.screen.row.context"), - title = entry.title ?: entry.id, - body = entry.summary.orEmpty(), - status = null, - ) + is ThreadCommand -> commandRow(entry) + is ThreadPlan -> planRow(entry) + is ThreadFileDiff -> diffRow(entry) + is ThreadContextCompaction -> contextRow(entry) } private fun messageRow(message: ThreadMessage): JComponent { - return entryRow( - label = humanize(message.role.name).uppercase(), - title = null, - body = message.text, - status = if (message.complete) "completed" else null, - ) - } - - private fun toolRow(toolCall: ThreadToolCall): JComponent { - val body = buildList { - toolCall.command?.takeIf { it.isNotBlank() }?.let(::add) - toolCall.path?.takeIf { it.isNotBlank() }?.let(::add) - toolCall.outputText.takeIf { it.isNotBlank() }?.let(::add) - toolCall.summary?.takeIf { it.isNotBlank() }?.let(::add) - toolCall.approval?.let { approval -> - add(EngineBundle.message("acp.screen.approval.status", humanize(approval.status.name))) - } - }.joinToString(separator = "\n") - return entryRow( - label = EngineBundle.message("acp.screen.row.tool"), - title = toolCall.title ?: toolCall.command ?: toolCall.kind ?: toolCall.id, - body = body, - status = toolCall.status, - ) - } - - @Suppress("HardCodedStringLiteral") - private fun entryRow(label: @NlsSafe String, title: String?, body: String, status: String?): JComponent { - val row = JPanel(BorderLayout()).apply { + val row = TranscriptRowPanel().apply { isOpaque = false alignmentX = LEFT_ALIGNMENT - maximumSize = Dimension(Int.MAX_VALUE, Int.MAX_VALUE) } - val labelText = status?.let { EngineBundle.message("acp.screen.row.status", label, humanize(it)) } ?: label - val roleLabel = JBLabel(labelText).apply { + val roleLabel = JBLabel(messageLabelText(message)).apply { font = JBFont.small().asBold() foreground = UIUtil.getContextHelpForeground() border = JBUI.Borders.emptyBottom(2) } - val textContent: @NlsSafe String = buildString { - if (!title.isNullOrBlank()) append(title) - if (body.isNotBlank()) { - if (isNotEmpty()) append('\n') - append(body) - } - } - val text = JTextArea(textContent).apply { - isEditable = false - isFocusable = false - lineWrap = true - wrapStyleWord = true - isOpaque = false - border = null - font = UIUtil.getLabelFont() + val bodyText = previewText(message.text) + val bodyTextArea = rowTextArea(bodyText, monospace = false).apply { + isVisible = bodyText.isNotBlank() } val body = JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = false add(roleLabel) - add(text) + add(bodyTextArea) + } + row.add(body, BorderLayout.CENTER) + 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) + if (bodyTextArea.text != updatedBodyText) { + bodyTextArea.text = updatedBodyText + } + bodyTextArea.isVisible = updatedBodyText.isNotBlank() + bodyTextArea.invalidate() + row.invalidate() + true + } + return row + } + + private fun messageLabelText(message: ThreadMessage): @NlsSafe String { + val label = humanize(message.role.name).uppercase() + return if (message.complete) { + label + } + else { + EngineBundle.message("acp.screen.row.status", label, EngineBundle.message("acp.screen.message.streaming")) + } + } + + 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))) + } + 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)))) + } + } + return entryRow( + label = EngineBundle.message("acp.screen.row.tool"), + title = toolCall.title ?: toolCall.command ?: toolCall.kind ?: toolCall.id, + sections = sections, + status = displayStatus(toolCall.status), + ) + } + + private fun commandRow(command: ThreadCommand): JComponent { + return entryRow( + label = EngineBundle.message("acp.screen.row.command"), + title = command.title ?: command.command ?: command.id, + body = previewText(command.outputText, OUTPUT_PREVIEW_MAX_CHARS, OUTPUT_PREVIEW_MAX_LINES), + status = commandStatus(command.status, command.exitCode), + bodyMonospace = true, + ) + } + + private fun planRow(plan: ThreadPlan): JComponent { + val body = plan.items.joinToString(separator = "\n") { item -> + item.status?.takeIf { it.isNotBlank() }?.let { status -> + EngineBundle.message("acp.screen.plan.item", humanize(status), item.title) + } ?: item.title + } + return entryRow( + label = EngineBundle.message("acp.screen.row.plan"), + title = plan.title ?: plan.id, + body = body, + status = EngineBundle.message(if (plan.complete) "acp.screen.plan.completed" else "acp.screen.plan.running"), + ) + } + + private fun diffRow(diff: ThreadFileDiff): JComponent { + return entryRow( + label = EngineBundle.message("acp.screen.row.diff"), + title = diff.title ?: diff.path ?: diff.id, + body = diff.newText?.takeIf { it.isNotBlank() }?.let { previewText(it, OUTPUT_PREVIEW_MAX_CHARS, OUTPUT_PREVIEW_MAX_LINES) } + ?: EngineBundle.message("acp.screen.diff.empty"), + status = displayStatus(diff.status), + bodyMonospace = true, + ) + } + + private fun contextRow(context: ThreadContextCompaction): JComponent { + return entryRow( + label = EngineBundle.message("acp.screen.row.context"), + title = context.title ?: context.id, + body = previewText(context.summary.orEmpty()), + status = null, + ) + } + + private fun entryRow( + label: @NlsSafe String, + title: String?, + body: @NlsSafe String, + status: @NlsSafe String?, + bodyMonospace: Boolean = false, + ): JComponent = entryRow( + label = label, + title = title, + sections = body.takeIf { it.isNotBlank() }?.let { listOf(RowBodySection(it, bodyMonospace)) }.orEmpty(), + status = status, + ) + + private fun entryRow(label: @NlsSafe String, title: String?, sections: List, status: @NlsSafe String?): JComponent { + val row = TranscriptRowPanel().apply { + isOpaque = false + alignmentX = LEFT_ALIGNMENT + } + val labelText = status?.let { EngineBundle.message("acp.screen.row.status", label, it) } ?: label + val roleLabel = JBLabel(labelText).apply { + font = JBFont.small().asBold() + foreground = UIUtil.getContextHelpForeground() + border = JBUI.Borders.emptyBottom(2) + } + val body = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(roleLabel) + title?.takeIf { it.isNotBlank() }?.let { add(rowTextArea(it, monospace = false)) } + for ((text, monospace) in sections) { + if (componentCount > 1) add(Box.createVerticalStrut(2)) + add(rowTextArea(text, monospace)) + } } row.add(body, BorderLayout.CENTER) return row } + + private fun rowTextArea(text: @NlsSafe String, monospace: Boolean): JTextArea = TranscriptTextArea(text).apply { + isEditable = false + isFocusable = false + lineWrap = true + wrapStyleWord = !monospace + isOpaque = false + border = null + font = if (monospace) Font(Font.MONOSPACED, Font.PLAIN, UIUtil.getLabelFont().size) else UIUtil.getLabelFont() + alignmentX = LEFT_ALIGNMENT + } + + private fun previewText(text: String, maxChars: Int = BODY_PREVIEW_MAX_CHARS, maxLines: Int = BODY_PREVIEW_MAX_LINES): @NlsSafe String { + var truncated = false + val charLimited = if (text.length > maxChars) { + truncated = true + text.take(maxChars) + } + else { + text + } + val lines = charLimited.lineSequence().take(maxLines + 1).toList() + val lineLimited = if (lines.size > maxLines) { + truncated = true + lines.take(maxLines).joinToString(separator = "\n") + } + else { + charLimited + } + if (!truncated) return lineLimited + return buildString { + append(lineLimited.trimEnd()) + if (isNotEmpty()) append('\n') + append(EngineBundle.message("acp.screen.output.truncated")) + } + } + + private fun displayStatus(status: String?): @NlsSafe String? = status?.takeIf { it.isNotBlank() }?.let(::humanize) + + private fun commandStatus(status: String?, exitCode: Int?): @NlsSafe String? { + val displayStatus = displayStatus(status) + return when { + displayStatus != null && exitCode != null -> EngineBundle.message("acp.screen.command.status.exitCode", displayStatus, exitCode) + displayStatus != null -> displayStatus + exitCode != null -> EngineBundle.message("acp.screen.command.exitCode", exitCode) + else -> null + } + } + + 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, + ) + + private data class TranscriptScrollState( + val value: Int, + val visibleAmount: Int, + val minimum: Int, + val maximum: Int, + val distanceToBottom: Int, + val stickThreshold: Int, + val tailDriftThreshold: Int, + val valueIsAdjusting: Boolean, + val unitIncrement: Int, + val blockIncrement: Int, + val followTail: Boolean, + val manualTailPauseActive: Boolean, + ) { + val isAtBottom: Boolean + get() = distanceToBottom <= stickThreshold + + 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( + val text: @NlsSafe String, + val monospace: Boolean = false, + ) +} + +private class TranscriptScrollPane( + transcriptPanel: ScrollableTranscriptPanel, + private val runWithoutUnsticking: (() -> Unit) -> Unit, +) : JBScrollPane( + transcriptPanel, + VERTICAL_SCROLLBAR_AS_NEEDED, + HORIZONTAL_SCROLLBAR_NEVER, +) { + init { + verticalScrollBar.putClientProperty(IGNORE_SCROLLBAR_IN_INSETS, true) + viewport.addComponentListener(object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent?) { + transcriptPanel.invalidate() + } + }) + } + + override fun createViewport(): JViewport { + return object : JBViewport() { + override fun setViewPosition(p: Point?) { + runWithoutUnsticking { super.setViewPosition(p) } + } + + override fun setViewSize(newSize: Dimension?) { + runWithoutUnsticking { super.setViewSize(newSize) } + } + + override fun setBounds(r: Rectangle) { + runWithoutUnsticking { + isViewSizeSet = false + super.setBounds(r) + } + } + } + } +} + +private class ScrollableTranscriptPanel : JPanel(), Scrollable { + var scrollToBottomIfNeeded: () -> Unit = {} + + override fun getPreferredScrollableViewportSize(): Dimension = preferredSize + + override fun getPreferredSize(): Dimension { + val preferred = super.getPreferredSize() + val viewportWidth = viewportWidth() + if (viewportWidth <= 0) return preferred + return Dimension(viewportWidth, preferred.height) + } + + override fun doLayout() { + super.doLayout() + scrollToBottomIfNeeded() + } + + override fun getScrollableUnitIncrement(visibleRect: Rectangle, orientation: Int, direction: Int): Int = JBUI.scale(16) + + override fun getScrollableBlockIncrement(visibleRect: Rectangle, orientation: Int, direction: Int): Int = + maxOf(JBUI.scale(16), visibleRect.height - JBUI.scale(16)) + + override fun getScrollableTracksViewportWidth(): Boolean = true + + override fun getScrollableTracksViewportHeight(): Boolean = false + + fun contentWidth(): Int { + val viewportWidth = viewportWidth().takeIf { it > 0 } ?: width + val insets = insets + return (viewportWidth - insets.left - insets.right).coerceAtLeast(0) + } + + private fun viewportWidth(): Int = (parent as? JViewport)?.extentSize?.width ?: 0 +} + +private class TranscriptRowPanel : JPanel(BorderLayout()) { + private var transcriptUpdater: ((ThreadTranscriptEntry) -> Boolean)? = null + + fun setTranscriptUpdater(updater: (ThreadTranscriptEntry) -> Boolean) { + transcriptUpdater = updater + } + + fun updateTranscriptEntry(entry: ThreadTranscriptEntry): Boolean = transcriptUpdater?.invoke(entry) == true + + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) +} + +private class TranscriptTextArea(text: @NlsSafe String) : JTextArea(text) { + override fun getPreferredSize(): Dimension { + val availableWidth = transcriptContentWidth() + if (availableWidth <= 0) return super.getPreferredSize() + + val textUi = ui as? BasicTextUI ?: return Dimension(availableWidth, super.getPreferredSize().height) + val insets = insets + val textWidth = (availableWidth - insets.left - insets.right).coerceAtLeast(1) + val rootView = textUi.getRootView(this) + rootView.setSize(textWidth.toFloat(), Float.MAX_VALUE) + val preferredHeight = ceil(rootView.getPreferredSpan(View.Y_AXIS).toDouble()).toInt() + insets.top + insets.bottom + return Dimension(availableWidth, preferredHeight) + } + + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + + private fun transcriptContentWidth(): Int { + var component: Component? = parent + while (component != null) { + if (component is ScrollableTranscriptPanel) return component.contentWidth() + component = component.parent + } + return 0 + } +} + +private fun AdjustmentEvent.isUserTranscriptAdjustment(): Boolean = + valueIsAdjusting || when (adjustmentType) { + AdjustmentEvent.UNIT_INCREMENT, + AdjustmentEvent.UNIT_DECREMENT, + AdjustmentEvent.BLOCK_INCREMENT, + AdjustmentEvent.BLOCK_DECREMENT, + -> true + 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 = name.replace(Regex("(?<=[a-z0-9])(?=[A-Z])"), " ") +private const val BODY_PREVIEW_MAX_CHARS = 16 * 1024 +private const val BODY_PREVIEW_MAX_LINES = 120 +private const val OUTPUT_PREVIEW_MAX_CHARS = 8 * 1024 +private const val OUTPUT_PREVIEW_MAX_LINES = 60 +private const val STICK_TO_BOTTOM_THRESHOLD_PX = 24 +private const val TAIL_FOLLOW_DRIFT_THRESHOLD_PX = 128 +private const val MANUAL_TAIL_PAUSE_MS = 1500L +private const val TRANSCRIPT_COMPONENTS_PER_ENTRY = 2 + private val LOG = logger()