diff --git a/.changeset/jetbrains-platform-stop-icon.md b/.changeset/jetbrains-platform-stop-icon.md new file mode 100644 index 0000000000..12d9af092a --- /dev/null +++ b/.changeset/jetbrains-platform-stop-icon.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use the IntelliJ stop icon for the JetBrains prompt stop button. diff --git a/.changeset/jetbrains-progress-elapsed-time.md b/.changeset/jetbrains-progress-elapsed-time.md new file mode 100644 index 0000000000..11a85d57b4 --- /dev/null +++ b/.changeset/jetbrains-progress-elapsed-time.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show elapsed time in the JetBrains progress footer while Kilo is working. diff --git a/.changeset/jetbrains-prompt-action-separator.md b/.changeset/jetbrains-prompt-action-separator.md new file mode 100644 index 0000000000..826715ea32 --- /dev/null +++ b/.changeset/jetbrains-prompt-action-separator.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add a separator before the JetBrains prompt send button. diff --git a/.changeset/jetbrains-prompt-right-padding.md b/.changeset/jetbrains-prompt-right-padding.md new file mode 100644 index 0000000000..1ffd4e59c4 --- /dev/null +++ b/.changeset/jetbrains-prompt-right-padding.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Match the JetBrains prompt send-button right padding to the bottom padding. diff --git a/.changeset/jetbrains-rollback-redo-font.md b/.changeset/jetbrains-rollback-redo-font.md new file mode 100644 index 0000000000..bd25756167 --- /dev/null +++ b/.changeset/jetbrains-rollback-redo-font.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix JetBrains rollback and redo scrolling and align plan custom response font with the prompt input. diff --git a/.changeset/jetbrains-send-scroll-color.md b/.changeset/jetbrains-send-scroll-color.md new file mode 100644 index 0000000000..619d8f7a51 --- /dev/null +++ b/.changeset/jetbrains-send-scroll-color.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Match the JetBrains prompt send icon color to the scroll-to-bottom button across themes. diff --git a/.changeset/neat-mammals-fry.md b/.changeset/neat-mammals-fry.md new file mode 100644 index 0000000000..08000c3287 --- /dev/null +++ b/.changeset/neat-mammals-fry.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-indexing": patch +--- + +Retry remote embedder validation up to twice on failure diff --git a/.changeset/preserve-compaction-errors.md b/.changeset/preserve-compaction-errors.md new file mode 100644 index 0000000000..cefd57adba --- /dev/null +++ b/.changeset/preserve-compaction-errors.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Preserve gateway and provider errors when chunked compaction fails instead of reporting every failure as a context overflow. diff --git a/.changeset/sidebar-tab-close-focus.md b/.changeset/sidebar-tab-close-focus.md new file mode 100644 index 0000000000..38e8d7c760 --- /dev/null +++ b/.changeset/sidebar-tab-close-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep keyboard focus on the active sidebar tab after closing an inactive session tab. diff --git a/packages/kilo-indexing/src/indexing/constants/index.ts b/packages/kilo-indexing/src/indexing/constants/index.ts index ef8a666673..bd36aceaac 100644 --- a/packages/kilo-indexing/src/indexing/constants/index.ts +++ b/packages/kilo-indexing/src/indexing/constants/index.ts @@ -50,7 +50,7 @@ export const INITIAL_MANAGER_RECOVERY_DELAY_MS = 500 /**Embedder Validation */ export const REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS = 15_000 -export const REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES = 0 +export const REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES = 2 export const OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS = 120_000 /**OpenAI Embedder */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 2bd25bf5fe..12d7f90dc5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -57,6 +57,7 @@ import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.dto.ModelLimitDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto +import ai.kilocode.rpc.dto.SessionRevertDto import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog import com.intellij.ide.BrowserUtil @@ -127,6 +128,8 @@ class SessionUi( private var pending = false private var loaded: Boolean? = null private var revertPrompt: String? = null + private var pendingRollback: String? = null + private var pendingRedo: String? = null private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) .takeIf { it > 0 } @@ -362,8 +365,8 @@ class SessionUi( repo = workspace.directory, resize = { anchor, fn -> scroll.preserve(anchor, fn) }, revert = ::revert, - cancelRevert = controller::cancelRevert, - banner = RevertBanner(controller.model, controller::redo, controller::redoAll, controller::cancelRevert, focus), + cancelRevert = ::cancelRevert, + banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus), ).also { it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } } @@ -537,7 +540,7 @@ class SessionUi( is SessionModelEvent.SessionUpdated -> onSessionUpdated() - is SessionModelEvent.RevertChanged -> syncPromptRevert() + is SessionModelEvent.RevertChanged -> onRevertChanged(event.revert) is SessionModelEvent.TurnAdded, is SessionModelEvent.TurnUpdated, @@ -688,10 +691,47 @@ class SessionUi( @RequiresEdt private fun revert(id: String) { - scroll.followBottom(true) + pendingRollback = id + pendingRedo = null controller.revert(id) } + @RequiresEdt + private fun redo() { + pendingRedo = controller.model.revert()?.messageID + pendingRollback = null + controller.redo() + } + + @RequiresEdt + private fun cancelRevert() { + pendingRollback = null + pendingRedo = null + controller.cancelRevert() + } + + @RequiresEdt + private fun onRevertChanged(revert: SessionRevertDto?) { + syncPromptRevert() + val rollback = pendingRollback + if (rollback != null) { + if (revert?.messageID == rollback) { + pendingRollback = null + scroll.followBottom(true) + return + } + pendingRollback = null + } + val redo = pendingRedo + if (redo == null) return + if (!controller.model.isRevertedMessage(redo)) { + pendingRedo = null + scroll.scrollMessageBottom(redo) + return + } + if (revert != null) pendingRedo = null + } + @RequiresEdt private fun syncPromptRevert() { val saved = revertPrompt @@ -811,6 +851,10 @@ class SessionUi( private fun onStateChanged(state: SessionState) { if (disposed) return if (state is SessionState.Reverting) overlay.clear() + if (state is SessionState.Error) { + pendingRollback = null + pendingRedo = null + } prompt.setBusy(state.isBusy()) load.setState(state) scroll.setQuestionPending(questionPending(state)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index dd891fa1cc..04c7bdb56e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -134,6 +134,27 @@ internal class SessionScroll( followBottom(component.viewport.view === messages && tail) } + @RequiresEdt + fun scrollMessageBottom(id: String): Boolean { + val target = messages.findMessage(id) ?: return false + if (!target.isVisible) return false + user = false + pause = false + stable = -1 + auto = true + show(messages) + auto = false + val gen = ++seq + if (SwingUtilities.isEventDispatchThread()) { + messagePass(gen, id, FOLLOW_PASSES) + return true + } + ApplicationManager.getApplication().invokeLater { + messagePass(gen, id, FOLLOW_PASSES) + } + return true + } + @RequiresEdt fun following(): Boolean { return component.viewport.view === messages && tail @@ -290,6 +311,39 @@ internal class SessionScroll( } } + @RequiresEdt + private fun messagePass(id: Int, message: String, remaining: Int) { + if (id != seq) return + val target = messages.findMessage(message) + if (target == null || !target.isVisible) { + stable = -1 + updateJump() + return + } + auto = true + try { + layoutScroll() + val y = messageBottom(target) + component.viewport.viewPosition = Point(0, y) + bar.value = y + tail = near() + updateJump() + } finally { + auto = false + } + syncValue() + if (remaining <= 0) { + stable = -1 + return + } + val next = messageBottom(target) + val left = if (next == stable) remaining - 1 else FOLLOW_PASSES + stable = next + ApplicationManager.getApplication().invokeLater { + messagePass(id, message, left) + } + } + @RequiresEdt private fun layoutScroll() { root.validate() @@ -305,6 +359,13 @@ internal class SessionScroll( bar.value = bottom() } + @RequiresEdt + private fun messageBottom(target: JComponent): Int { + val point = SwingUtilities.convertPoint(target, Point(0, target.height.coerceAtLeast(1)), messages) + val extent = component.viewport.extentSize.height + return (point.y - extent).coerceIn(0, bottom()) + } + @RequiresEdt private fun bottom(): Int { val bar = component.verticalScrollBar diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index bbc44bd12c..3f07625406 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -6,14 +6,17 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel /** * Progress footer rendered at the bottom of the session transcript while the @@ -31,28 +34,39 @@ import com.intellij.util.ui.JBUI class ProgressPanel( model: SessionModel, parent: Disposable, -) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()), SessionEditorStyleTarget { + private val clock: UiTimerSource = UiTimers, +) : BorderLayoutPanel(), SessionEditorStyleTarget { private var style = SessionEditorStyle.current() private var state: SessionState = SessionState.Idle + private var began = 0L private val label = JBLabel().apply { foreground = style.editorForeground } + private val elapsed = JBLabel().apply { + foreground = UiStyle.Colors.weak() + } private val spinner = JBLabel(AnimatedIcon.Default()) + private val tick = clock.timer(1000) { syncElapsed() } init { isOpaque = false isVisible = false border = JBUI.Borders.empty( UiStyle.Gap.sm(), - JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + 0, 0, 0, ) applyStyle(SessionEditorStyle.current()) - next(spinner) - next(label) + addToLeft( + Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()) + .next(spinner) + .next(label), + ) + addToRight(elapsed) + Disposer.register(parent) { tick.stop() } model.addListener(parent) { event -> if (event is SessionModelEvent.StateChanged) onState(event.state) @@ -62,6 +76,9 @@ class ProgressPanel( /** Exposed for test assertions. */ fun labelText(): String = label.text + /** Exposed for test assertions. */ + fun elapsedText(): String = elapsed.text + /** Exposed for test assertions. */ fun labelForeground() = label.foreground @@ -72,26 +89,46 @@ class ProgressPanel( spinner.isVisible = true label.text = state.text label.foreground = style.editorForeground - isVisible = true + showProgress() } is SessionState.Retry -> { spinner.isVisible = true label.text = retryText(state) label.foreground = UiStyle.Colors.warningLabelForeground() - isVisible = true + showProgress() } is SessionState.Offline -> { spinner.isVisible = false label.text = state.message.ifBlank { KiloBundle.message("session.status.offline") } label.foreground = UiStyle.Colors.errorLabelForeground() - isVisible = true + showProgress() } - else -> isVisible = false + else -> hideProgress() } revalidate() repaint() } + private fun showProgress() { + if (!isVisible) { + began = clock.now() + syncElapsed() + } + if (!tick.isRunning()) tick.start() + isVisible = true + } + + private fun hideProgress() { + tick.stop() + isVisible = false + } + + private fun syncElapsed() { + elapsed.text = elapsedText((clock.now() - began).coerceAtLeast(0)) + revalidate() + repaint() + } + private fun retryText(state: SessionState.Retry): String { val base = state.message.ifBlank { KiloBundle.message("session.status.retry") } return if (state.attempt > 0) { @@ -102,8 +139,20 @@ class ProgressPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style label.font = style.regularFont + elapsed.font = style.regularFont + elapsed.foreground = UiStyle.Colors.weak() if (state is SessionState.Busy) label.foreground = style.editorForeground revalidate() repaint() } + + private fun elapsedText(ms: Long): String { + val total = ms / 1000 + val sec = total % 60 + val min = (total / 60) % 60 + val hour = total / 3600 + if (hour > 0) return "${hour}h ${min}m ${sec}s" + if (min > 0) return "${min}m ${sec}s" + return "${sec}s" + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index ed36d318ef..9c1eb60d8e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -76,6 +76,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BasicStroke import java.awt.BorderLayout +import java.awt.Component import java.awt.Cursor import java.awt.Graphics import java.awt.Graphics2D @@ -116,7 +117,7 @@ class PromptPanel( companion object { private val LOG = KiloLog.create(PromptPanel::class.java) private val SEND_ICON: Icon = IconLoader.getIcon("/icons/send.svg", PromptPanel::class.java) - private val STOP_ICON: Icon = IconLoader.getIcon("/icons/stop.svg", PromptPanel::class.java) + private val STOP_ICON: Icon = AllIcons.Actions.Suspend private val SHIELD_ICON: Icon = IconLoader.getIcon("/icons/shield.svg", PromptPanel::class.java) private val SHIELD_FILLED_ICON: Icon = IconLoader.getIcon("/icons/shield-filled.svg", PromptPanel::class.java) private val WAND_ICON: Icon = IconLoader.getIcon("/icons/wand-sparkles.svg", PromptPanel::class.java) @@ -142,7 +143,7 @@ class PromptPanel( JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), ) } private val attachments = mutableListOf() @@ -177,12 +178,9 @@ class PromptPanel( ed.settings.setBlockCursor(false) SpellCheckingEditorCustomizationProvider.getInstance().getDisabledCustomization()?.customize(ed) ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) }) - ed.setVerticalScrollbarVisible(false) ed.setHorizontalScrollbarVisible(false) - ed.scrollPane.verticalScrollBarPolicy = - ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - ed.scrollPane.horizontalScrollBarPolicy = - ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER installCompletionShortcut(ed) completion?.let { MentionNavigator(ed, it).install() } installFileDrop(ed.contentComponent, "editor") @@ -244,6 +242,14 @@ class PromptPanel( accessibleContext.accessibleName = KiloBundle.message("prompt.action.enhance") addActionListener { enhance() } } + private val separator = object : JComponent() { + override fun getPreferredSize() = JBUI.size(1, JBUI.scale(16)) + override fun getMinimumSize() = preferredSize + override fun getMaximumSize() = preferredSize + }.apply { + alignmentY = Component.CENTER_ALIGNMENT + border = JBUI.Borders.customLineLeft(SessionUiStyle.View.Prompt.separator()) + } @Volatile private var busy = false @@ -290,6 +296,8 @@ class PromptPanel( bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) bar.add(enhance) bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) + bar.add(separator) + bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) bar.add(button) shell.add(bar, BorderLayout.SOUTH) add(shell, BorderLayout.CENTER) @@ -378,24 +386,8 @@ class PromptPanel( @RequiresEdt private fun chrome(ed: EditorEx) { if (ed.isDisposed) return - style.applyTranscriptToEditor(ed) + style.applyPromptToEditor(ed) if (ed.isDisposed) return - val bg = style.editorBackground - ed.setBorder(JBUI.Borders.empty()) - ed.scrollPane.border = JBUI.Borders.empty() - ed.scrollPane.viewportBorder = JBUI.Borders.empty( - 0, - JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), - 0, - JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), - ) - ed.backgroundColor = bg - ed.component.background = bg - ed.contentComponent.background = bg - ed.scrollPane.background = bg - ed.scrollPane.viewport.background = bg - ed.scrollPane.revalidate() - ed.scrollPane.repaint() } @RequiresEdt @@ -482,7 +474,7 @@ class PromptPanel( this.style = style background = style.editorScheme.defaultBackground shell.background = style.editorScheme.defaultBackground - editor.font = style.transcriptFont + style.applyTranscriptToField(editor) editor.getEditor(false)?.let(::chrome) editor.background = style.editorBackground syncEditorHeight() @@ -927,6 +919,7 @@ class PromptPanel( val content = editor.preferredSize.height val sessionCap = rootCap(min) val height = minOf(content, sessionCap ?: content).coerceAtLeast(min) + syncEditorScroll(view, content > height) if (before == height && lower == height) { editor.preferredSize = JBDimension(0, height) editor.minimumSize = JBDimension(0, height) @@ -938,6 +931,18 @@ class PromptPanel( repaint() } + @RequiresEdt + private fun syncEditorScroll(ed: EditorEx?, overflow: Boolean) { + // AS_NEEDED keeps the standard auto-hiding editor scrollbar (appears on + // scroll/hover, fades on inactivity); NEVER hides it entirely when the + // content fits so no bar is shown at all. + ed?.scrollPane?.verticalScrollBarPolicy = if (overflow) { + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } else { + ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + @RequiresEdt private fun rootCap(min: Int): Int? { val root = root ?: return null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index 16f40d3449..8096ba8272 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -4,9 +4,12 @@ import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.ui.EditorTextField import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI import java.awt.Color import java.awt.Font +import javax.swing.ScrollPaneConstants import kotlin.math.roundToInt /** @@ -62,6 +65,35 @@ data class SessionEditorStyle( } } + /** Apply standard transcript typography to an editor text field and its embedded editor when available. */ + fun applyTranscriptToField(field: EditorTextField) { + field.font = transcriptFont + field.getEditor(false)?.let(::applyTranscriptToEditor) + } + + /** Apply the visible prompt-input text styling to embedded session editor components. */ + fun applyPromptToEditor(editor: EditorEx) { + if (editor.isDisposed) return + applyTranscriptToEditor(editor) + if (editor.isDisposed) return + editor.setBorder(JBUI.Borders.empty()) + editor.scrollPane.border = JBUI.Borders.empty() + editor.scrollPane.viewportBorder = JBUI.Borders.empty( + 0, + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), + 0, + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), + ) + editor.backgroundColor = editorBackground + editor.component.background = editorBackground + editor.contentComponent.background = editorBackground + editor.scrollPane.background = editorBackground + editor.scrollPane.viewport.background = editorBackground + editor.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + editor.scrollPane.revalidate() + editor.scrollPane.repaint() + } + companion object { /** Builds a style snapshot from the current global editor color scheme. */ fun current(): SessionEditorStyle { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index 6cd19df3aa..c08851d4e2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.editor.SessionEditorTextField import ai.kilocode.client.session.views.SessionViewIcons @@ -11,11 +12,15 @@ import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader @@ -23,15 +28,18 @@ import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBDimension import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel -import javax.swing.ScrollPaneConstants import java.awt.BorderLayout import java.awt.Color import java.awt.Component import java.awt.Dimension import java.awt.GridBagLayout import java.awt.Rectangle +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent import java.awt.event.FocusAdapter import java.awt.event.FocusEvent import java.awt.event.MouseAdapter @@ -41,9 +49,8 @@ import javax.swing.Box import javax.swing.BoxLayout import javax.swing.ButtonGroup import javax.swing.JPanel -import com.intellij.openapi.editor.event.DocumentEvent -import com.intellij.openapi.editor.event.DocumentListener -import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.ScrollPaneConstants +import javax.swing.SwingUtilities /** Question tool form rendered inside the session transcript. */ class QuestionView( @@ -71,6 +78,12 @@ class QuestionView( // The custom editor for the currently shown question; null when not shown. private var customEditor: SessionEditorTextField? = null private var customFocus: FocusAdapter? = null + private val resize = object : ComponentAdapter() { + @RequiresEdt + override fun componentResized(e: ComponentEvent) { + customEditor?.let(::syncEditorHeight) + } + } private val card = BaseQuestionView(selection, focus) @@ -112,6 +125,7 @@ class QuestionView( init { isOpaque = false isVisible = false + addComponentListener(resize) nav.add(back) nav.add(fwd) @@ -123,6 +137,12 @@ class QuestionView( add(card, BorderLayout.CENTER) } + @RequiresEdt + override fun addNotify() { + super.addNotify() + customEditor?.let(::syncEditorHeight) + } + @RequiresEdt fun show(q: Question) { if (q.items.isEmpty()) { @@ -165,9 +185,9 @@ class QuestionView( this.style = style card.applyStyle(style) customEditor?.let { ed -> - ed.font = style.transcriptFont - ed.getEditor(false)?.let(style::applyTranscriptToEditor) - ed.background = style.editorScheme.defaultBackground + style.applyTranscriptToField(ed) + ed.background = style.editorBackground + syncEditorHeight(ed) } val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc } if (!changed) return @@ -496,21 +516,18 @@ class QuestionView( ed.setShowPlaceholderWhenFocused(true) ed.setOneLineMode(false) ed.addSettingsProvider { ex -> - style.applyTranscriptToEditor(ex) - ex.setBorder(JBUI.Borders.empty()) - ex.scrollPane.border = JBUI.Borders.empty() - ex.scrollPane.viewportBorder = JBUI.Borders.empty() - ex.backgroundColor = style.editorScheme.defaultBackground - ex.scrollPane.background = style.editorScheme.defaultBackground - ex.scrollPane.viewport.background = style.editorScheme.defaultBackground + style.applyPromptToEditor(ex) ex.settings.isUseSoftWraps = true ex.settings.isPaintSoftWraps = false ex.settings.isAdditionalPageAtBottom = false + ex.setHorizontalScrollbarVisible(false) + ex.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + syncEditorHeight(ed, ex) } selection?.register(ed)?.let(regs::add) - ed.font = style.transcriptFont - ed.background = style.editorScheme.defaultBackground + style.applyTranscriptToField(ed) + ed.background = style.editorBackground // Pre-fill with saved text. This call also forces lazy document creation so // that addDocumentListener can install on a non-null document immediately. @@ -546,13 +563,41 @@ class QuestionView( @RequiresEdt private fun syncEditorHeight(ed: SessionEditorTextField) { - val editor = ed.getEditor(false) + syncEditorHeight(ed, ed.getEditor(false)) + } + + @RequiresEdt + private fun syncEditorHeight(ed: SessionEditorTextField, editor: EditorEx?) { val estimated = estimatedLines(ed) val lines = maxOf(editor?.offsetToVisualPosition(editor.document.textLength)?.line?.plus(1) ?: estimated, estimated) val line = editor?.lineHeight ?: ed.getFontMetrics(ed.font).height - val height = line * lines.coerceAtLeast(1) + JBUI.scale(16) - ed.preferredSize = Dimension(0, height) - ed.minimumSize = Dimension(0, height) + val min = line + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME) + val content = line * lines.coerceAtLeast(1) + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME) + val cap = rootCap(min) + val height = minOf(content, cap ?: content).coerceAtLeast(min) + syncEditorScroll(editor, content > height) + ed.preferredSize = JBDimension(0, height) + ed.minimumSize = JBDimension(0, height) + } + + @RequiresEdt + private fun syncEditorScroll(ed: EditorEx?, overflow: Boolean) { + // AS_NEEDED keeps the standard auto-hiding editor scrollbar (appears on + // scroll/hover, fades on inactivity); NEVER hides it entirely when the + // content fits so no bar is shown at all. + ed?.scrollPane?.verticalScrollBarPolicy = if (overflow) { + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } else { + ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + + @RequiresEdt + private fun rootCap(min: Int): Int? { + val root = SwingUtilities.getAncestorOfClass(SessionRootPanel::class.java, this) as? SessionRootPanel + ?: return null + if (root.height <= 0) return null + return (root.height / 3).coerceAtLeast(min) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg index 41f740ad42..a27a8776ad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg index 8e075e2d9b..1f98c1a2e9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index 488cb141cc..7d0a6ebe4f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -1061,13 +1061,28 @@ class SessionScrollTest : SessionUiTestBase() { assertTrue(jumpButton().isVisible) } - fun `test rollback click follows bottom and hides jump button`() { + fun `test rollback click does not scroll before marker update`() { showMessages() fillTranscript(48) val bar = scrollBar() setValue(bar, bottom(bar) / 2) + val value = bar.value assertTrue(jumpButton().isVisible) + rollback("msg_36").doClick() + settle() + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test rollback scrolls after marker shows banner`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + rollback("msg_36").doClick() settle() drainScroll() @@ -1079,6 +1094,25 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(jumpButton().isVisible) } + fun `test redo scrolls to restored message bottom`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test").copy(revert = SessionRevertDto("msg_36")))) + drainScroll() + setValue(bar, 0) + + button(KiloBundle.message("revert.banner.redo")).doClick() + settle() + drainScroll() + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test").copy(revert = SessionRevertDto("msg_37")))) + drainScroll() + + val expected = messageBottomValue("msg_36") + assertTrue("expected=$expected bottom=${bottom(bar)}", expected < bottom(bar)) + assertTrue("value=${bar.value} expected=$expected", kotlin.math.abs(bar.value - expected) <= 1) + } + // ------ helpers ------ private fun button(text: String): JButton = findAll(ui).first { it.text == text } @@ -1093,6 +1127,14 @@ class SessionScrollTest : SessionUiTestBase() { .first { it.toolTipText == KiloBundle.message("revert.message.rollback") } } + private fun messageBottomValue(id: String): Int { + val pane = scrollComponent() as JBScrollPane + val messages = find(ui) + val message = messages.findMessage(id) ?: error("missing message $id") + val point = SwingUtilities.convertPoint(message, Point(0, message.height.coerceAtLeast(1)), messages) + return (point.y - pane.viewport.extentSize.height).coerceIn(0, bottom(scrollBar())) + } + private inline fun option(label: String): T where T : AbstractButton = findAll(ui).first { it.actionCommand == label } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index b1a7e56f01..2c112f306b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -5,12 +5,15 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.util.UiTimer +import ai.kilocode.client.util.UiTimerSource import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.util.ui.JBUI +import com.intellij.ui.components.JBLabel +import java.awt.Component +import java.awt.Container /** * Verifies [ProgressPanel] show/hide behaviour driven by direct [SessionModel] @@ -47,13 +50,14 @@ class ProgressPanelTest : BasePlatformTestCase() { assertTrue(panel.isVisible) assertEquals("Thinking\u2026", panel.labelText()) + assertEquals("0s", panel.elapsedText()) } - fun `test panel uses transcript row padding`() { + fun `test panel relies on transcript inset for left padding`() { val ins = panel.insets assertEquals(UiStyle.Gap.sm(), ins.top) - assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ins.left) + assertEquals(0, ins.left) assertEquals(0, ins.bottom) assertEquals(0, ins.right) } @@ -103,14 +107,66 @@ class ProgressPanelTest : BasePlatformTestCase() { assertEquals("Rate limited", panel.labelText()) } + fun `test elapsed time ticks while progress is visible`() { + val clock = FakeClock() + replace(clock) + + model.setState(SessionState.Busy("Thinking")) + + assertEquals("0s", panel.elapsedText()) + assertTrue(clock.timer.isRunning()) + + clock.advance(59_000) + assertEquals("59s", panel.elapsedText()) + + clock.advance(23_000) + assertEquals("1m 22s", panel.elapsedText()) + + clock.advance(3_600_000) + assertEquals("1h 1m 22s", panel.elapsedText()) + } + + fun `test elapsed time is right aligned`() { + val clock = FakeClock() + replace(clock) + + model.setState(SessionState.Busy("Thinking")) + panel.setSize(300, panel.preferredSize.height) + panel.doLayout() + + val time = labels(panel).first { it.text == "0s" } + + assertEquals(panel.width - panel.insets.right, time.x + time.width) + } + + fun `test elapsed time continues across visible progress states and stops when hidden`() { + val clock = FakeClock() + replace(clock) + + model.setState(SessionState.Busy("Thinking")) + clock.advance(61_000) + model.setState(SessionState.Retry("Rate limited", attempt = 1, next = 0L)) + + assertEquals("1m 1s", panel.elapsedText()) + + model.setState(SessionState.Idle) + assertFalse(clock.timer.isRunning()) + + clock.advance(1_000) + assertEquals("1m 1s", panel.elapsedText()) + + model.setState(SessionState.Busy("Thinking again")) + assertEquals("0s", panel.elapsedText()) + } + fun `test reverting state is busy`() { assertTrue(SessionState.Reverting("x", SessionState.Reverting.Kind.ROLLBACK).isBusy()) } fun `test state churn retains footer components`() { - val count = panel.componentCount - val icon = panel.components[0] - val text = panel.components[1] + val clock = FakeClock() + replace(clock) + val comps = components(panel) repeat(500) { i -> model.setState(SessionState.Busy("Thinking $i")) @@ -118,9 +174,7 @@ class ProgressPanelTest : BasePlatformTestCase() { model.setState(SessionState.Offline("Computer appears offline", requestId = "req$i")) model.setState(SessionState.Idle) - assertEquals(count, panel.componentCount) - assertSame(icon, panel.components[0]) - assertSame(text, panel.components[1]) + assertEquals(comps, components(panel)) } } @@ -150,6 +204,13 @@ class ProgressPanelTest : BasePlatformTestCase() { // ------ helpers ------ + private fun replace(clock: FakeClock) { + Disposer.dispose(parent) + parent = Disposer.newDisposable("test replacement") + model = SessionModel() + panel = ProgressPanel(model, parent, clock) + } + private fun stub() = Permission( id = "perm1", sessionId = "ses", @@ -159,5 +220,62 @@ class ProgressPanelTest : BasePlatformTestCase() { meta = PermissionMeta(raw = emptyMap()), ) - private fun spinner() = panel.components[0] + private fun spinner() = labels(panel).first { it.icon != null } + + private fun labels(root: Container): List { + val items = mutableListOf() + for (child in root.components) { + if (child is JBLabel) items.add(child) + if (child is Container) items.addAll(labels(child)) + } + return items + } + + private fun components(root: Container): List { + val items = mutableListOf() + for (child in root.components) { + items.add(child) + if (child is Container) items.addAll(components(child)) + } + return items + } + + private class FakeClock : UiTimerSource { + var time = 0L + lateinit var timer: FakeTimer + + override fun now(): Long = time + + override fun timer(ms: Int, repeats: Boolean, action: () -> Unit): UiTimer { + timer = FakeTimer(action) + return timer + } + + fun advance(ms: Long) { + time += ms + timer.fire() + } + } + + private class FakeTimer(private val action: () -> Unit) : UiTimer { + private var running = false + + override fun start() { + running = true + } + + override fun stop() { + running = false + } + + override fun restart() { + running = true + } + + override fun isRunning(): Boolean = running + + fun fire() { + if (running) action() + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index b8020f2c64..1707362a30 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -97,6 +97,7 @@ import java.io.File import java.util.Base64 import javax.imageio.ImageIO import javax.swing.JButton +import javax.swing.JComponent import javax.swing.JPanel import javax.swing.ImageIcon import javax.swing.ScrollPaneConstants @@ -192,6 +193,16 @@ class PromptPanelTest : BasePlatformTestCase() { assertEquals(pad, ins.right) } + fun `test prompt shell right padding matches bottom padding`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val shell = panel.shellForTest() + val ins = shell.border.getBorderInsets(shell) + + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), ins.left) + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), ins.bottom) + assertEquals(ins.bottom, ins.right) + } + fun `test prompt focus outline follows editor focus`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) realize(panel, 260, 400) @@ -332,6 +343,10 @@ class PromptPanelTest : BasePlatformTestCase() { val chrome = (panel.preferredSize.height - editor.preferredSize.height).coerceAtLeast(0) assertTrue(editor.preferredSize.height <= root.height / 3 - chrome + 1) + assertEquals( + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editor.getEditor(false)!!.scrollPane.verticalScrollBarPolicy, + ) } fun `test attachment strip is included in session root cap`() { @@ -350,17 +365,29 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(attachedEditor.preferredSize.height < plainEditor.preferredSize.height) } - fun `test prompt editor hides scrollbars and keeps soft wraps`() { + fun `test prompt editor hides scrollbars until content overflows cap`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) realize(panel, 180, 400) - val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!! + val field = panel.defaultFocusedComponent as EditorTextField + val editor = field.getEditor(false)!! assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) assertTrue(editor.settings.isUseSoftWraps) assertFalse(editor.settings.isPaintSoftWraps) assertFalse(editor.settings.isBlockCursor) + + field.text = (1..40).joinToString("\n") { "line $it" } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editor.scrollPane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + + field.text = "short" + UIUtil.dispatchAllInvocationEvents() + + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) } fun `test prompt editor highlights validated commands and mentions`() { @@ -985,9 +1012,15 @@ class PromptPanelTest : BasePlatformTestCase() { panel.setBusy(true) assertEquals("Stop", panel.buttonForTest().toolTipText) + assertSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) assertTrue(panel.isStopEnabled) } + fun `test send icon matches scroll button theme colors`() { + assertTrue(resource("/icons/send.svg").contains("fill=\"#0066B8\"")) + assertTrue(resource("/icons/send_dark.svg").contains("fill=\"#0A7BD8\"")) + } + fun `test busy disables send button`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.setReady(true) @@ -1032,18 +1065,21 @@ class PromptPanelTest : BasePlatformTestCase() { assertSame(icon, button.icon) } - fun `test auto approve and enhance buttons sit next to send button`() { + fun `test auto approve enhance separator and send buttons sit in order`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val auto = autoApproveButton(panel) val enhance = enhanceButton(panel) val send = panel.buttonForTest() val items = auto.parent.components.toList() + val sep = items[items.indexOf(enhance) + 2] as JComponent assertTrue(SwingUtilities.isDescendingFrom(auto, panel.shellForTest())) assertSame(auto.parent, enhance.parent) assertSame(auto.parent, send.parent) assertEquals(2, items.indexOf(enhance) - items.indexOf(auto)) - assertEquals(2, items.indexOf(send) - items.indexOf(enhance)) + assertEquals(4, items.indexOf(send) - items.indexOf(enhance)) + assertEquals(JBUI.scale(1), sep.preferredSize.width) + assertNotNull(sep.border) } fun `test enhance button follows connection and busy state`() { @@ -1363,6 +1399,11 @@ class PromptPanelTest : BasePlatformTestCase() { } } + private fun resource(path: String): String { + val stream = PromptPanel::class.java.getResourceAsStream(path) ?: error("missing resource $path") + return stream.use { it.readBytes().decodeToString() } + } + private class FileListTransferable(private val files: List) : Transferable { override fun getTransferDataFlavors(): Array = arrayOf(DataFlavor.javaFileListFlavor) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index e2591ece67..1f19245a78 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.base.BaseQuestionView @@ -17,6 +18,7 @@ import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Component @@ -33,6 +35,7 @@ class QuestionViewTest : BasePlatformTestCase() { private val replies = mutableListOf>>>() private val rejects = mutableListOf() + private val roots = mutableListOf() private var scrolls = 0 private lateinit var view: QuestionView @@ -46,6 +49,15 @@ class QuestionViewTest : BasePlatformTestCase() { ) } + override fun tearDown() { + try { + roots.asReversed().forEach { it.removeNotify() } + roots.clear() + } finally { + super.tearDown() + } + } + // ------ empty question ------ fun `test empty question hides view and clears stale request id`() { @@ -287,6 +299,10 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(style.transcriptFont.fontName, editor.colorsScheme.editorFontName) assertEquals(style.transcriptFont.size, editor.colorsScheme.editorFontSize) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + val ins = editor.scrollPane.viewportBorder.getBorderInsets(editor.scrollPane) + val pad = JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET) + assertEquals(pad, ins.left) + assertEquals(pad, ins.right) assertTrue(editor.settings.isUseSoftWraps) assertFalse(editor.settings.isPaintSoftWraps) } finally { @@ -295,6 +311,29 @@ class QuestionViewTest : BasePlatformTestCase() { } } + fun `test custom answer editor style updates use transcript font`() { + view.show(customSingleQuestion("q_custom_style_update")) + + findAll(view).first { it.actionCommand == "" }.doClick() + val field = findAll(view).first() + view.addNotify() + try { + layout(view) + UIUtil.dispatchAllInvocationEvents() + val editor = field.getEditor(true) ?: error("missing editor") + val style = SessionEditorStyle.create(family = "Courier New", size = 26) + + view.applyStyle(style) + + assertEquals(style.transcriptFont, field.font) + assertEquals(style.transcriptFont.fontName, editor.colorsScheme.editorFontName) + assertEquals(style.transcriptFont.size, editor.colorsScheme.editorFontSize) + } finally { + view.hideView() + view.removeNotify() + } + } + // ------ multi-question navigation ------ fun `test multi question shows one question at a time and navigates`() { @@ -708,6 +747,33 @@ class QuestionViewTest : BasePlatformTestCase() { assertTrue("custom editor should grow when soft-wrapped text needs more lines", ed.preferredSize.height > initial) } + fun `test custom editor enables vertical scrollbar only after cap`() { + view.show(customSingleQuestion("q_custom_cap")) + val root = realize(view, 240, 600) + + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + layoutTree(root) + + val ed = findAll(view).first() + val editor = ed.getEditor(false)!! + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) + + ed.text = (1..40).joinToString("\n") { "line $it" } + layoutTree(root) + UIUtil.dispatchAllInvocationEvents() + + assertTrue(ed.preferredSize.height <= root.height / 3) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editor.scrollPane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + + ed.text = "short" + layoutTree(root) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) + } + fun `test blank custom input does not enable submit`() { view.show(customSingleQuestion("q_custom_blank")) @@ -998,6 +1064,17 @@ class QuestionViewTest : BasePlatformTestCase() { layoutTree(root) } + private fun realize(child: Component, width: Int, height: Int): SessionRootPanel { + val root = SessionRootPanel() + root.setSize(width, height) + root.content.add(child, BorderLayout.CENTER) + root.addNotify() + layoutTree(root) + UIUtil.dispatchAllInvocationEvents() + roots.add(root) + return root + } + private fun layoutTree(root: Container) { root.doLayout() for (child in root.components) { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx index f2e9d1baf7..068a5d9d63 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx @@ -6,7 +6,7 @@ import { useLocalTabs } from "../../context/local-tabs" import { useSession } from "../../context/session" import { isPendingTab } from "../../utils/local-tabs" import { useTabScroll } from "../../utils/tab-scroll" -import { focusPrompt, focusTabElement, handleTabKey } from "../../utils/tab-navigation" +import { focusPrompt, focusSelectedTab, focusTabElement, handleTabKey } from "../../utils/tab-navigation" import { setTabWidths } from "../../utils/tab-widths" import { useVSCode } from "../../context/vscode" import { SessionTab } from "./SessionTab" @@ -35,7 +35,7 @@ export const SessionTabStrip: Component = () => { if (event.button !== 1) return event.preventDefault() event.stopPropagation() - close(id) + close(id, false) } const key = (id: string, event: KeyboardEvent) => { const root = event.currentTarget instanceof HTMLElement ? event.currentTarget.closest(".am-tab-list") : null @@ -59,9 +59,11 @@ export const SessionTabStrip: Component = () => { const root = () => document.querySelector("[data-component=session-tabs] .am-tab-list") const freeze = () => setTabWidths(true, document) const release = () => setTabWidths(false, document) - const close = (id: string) => { + const close = (id: string, restore = true) => { freeze() + const active = tabs.active() === id tabs.close(id) + if (!active && restore) focusSelectedTab(document, focusPrompt) requestAnimationFrame(release) } const closeOthers = (id: string) => { diff --git a/packages/opencode/src/kilocode/session/compaction-chunks.ts b/packages/opencode/src/kilocode/session/compaction-chunks.ts index c4e26397ed..16acc5252e 100644 --- a/packages/opencode/src/kilocode/session/compaction-chunks.ts +++ b/packages/opencode/src/kilocode/session/compaction-chunks.ts @@ -33,6 +33,7 @@ export namespace KiloCompactionChunks { type Output = { result: SessionProcessor.Result output: string | undefined + error: MessageV2.Assistant["error"] } type Deps = { @@ -272,7 +273,11 @@ export namespace KiloCompactionChunks { model: mdl, }) const parts = yield* MessageV2.parts(worker.message.id) - return { result, output: text(worker.message, parts) } + return { + result, + output: text(worker.message, parts), + error: worker.message.error ?? worker.compactError?.(), + } }).pipe( Effect.ensuring( input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore), @@ -280,9 +285,37 @@ export namespace KiloCompactionChunks { ) const result = out.result const output = out.output - if (result !== "continue") return { result, output: undefined } - if (!output) return { result: "stop" as const, output: undefined } - return { result, output } + if (result !== "continue") return { result, output: undefined, error: out.error } + if (!output) + return { + result: "stop" as const, + output: undefined, + error: + out.error ?? + new MessageV2.APIError({ + message: "Compaction worker returned an empty response", + isRetryable: true, + }).toObject(), + } + return { result, output, error: undefined } + }) + } + + function fatal(output: Output | undefined) { + return output?.result === "stop" && !!output.error && output.error.name !== "ContextOverflowError" + } + + function fail(input: Input, output: Output | undefined) { + return Effect.gen(function* () { + if (output?.result !== "stop") return false + const error = output.error + if (!error || error.name === "ContextOverflowError") return false + + input.target.error = error + input.target.finish = "error" + input.target.time.completed = Date.now() + yield* input.updateMessage(input.target) + return true }) } @@ -332,7 +365,8 @@ export namespace KiloCompactionChunks { (group) => reduce({ ...input, summaries: group, depth: input.depth + 1 }), { concurrency: 1 }, ) - if (next.some((item) => item.result !== "continue" || !item.output)) return result + const failed = next.find(fatal) ?? next.find((item) => item.result !== "continue" || !item.output) + if (failed) return fatal(failed) ? failed : result return yield* reduce({ ...input, summaries: next.map((item) => item.output!), depth: input.depth + 2 }) }) } @@ -346,13 +380,20 @@ export namespace KiloCompactionChunks { const partial = yield* Effect.forEach(chunks, (chunk) => summarize({ ...input, chunk, total: chunks.length }), { concurrency: Math.min(CONCURRENCY, chunks.length), }) - if (partial.some((item) => item.result !== "continue" || !item.output)) return "compact" as const + const failed = partial.find(fatal) ?? partial.find((item) => item.result !== "continue" || !item.output) + if (failed) { + if (yield* fail(input, failed)) return "stop" as const + return "compact" as const + } const final = chunks.length === 1 && (yield* large({ messages: chunks[0].messages, model: input.model, size })) ? partial[0] : yield* reduce({ ...input, summaries: partial.map((item) => item.output!), depth: 0 }) - if (!final || final.result !== "continue" || !final.output) return "compact" as const + if (!final || final.result !== "continue" || !final.output) { + if (yield* fail(input, final)) return "stop" as const + return "compact" as const + } yield* input.updatePart({ id: PartID.ascending(), diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts index 3b867e4592..d9d84a2908 100644 --- a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -168,7 +168,7 @@ function reply(text: string, capture?: (input: LLM.StreamInput) => void) { } } -function fakeRuntime(outputTokenMax?: number) { +function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error"], empty = false) { const calls: string[] = [] const outputs: number[] = [] const bus = Bus.layer @@ -189,6 +189,12 @@ function fakeRuntime(outputTokenMax?: number) { Effect.gen(function* () { outputs.push(input.model.limit.output) calls.push(JSON.stringify(stream.messages)) + if (error) { + input.assistantMessage.error = error + input.assistantMessage.finish = "error" + yield* sessions.updateMessage(input.assistantMessage) + return "stop" as const + } const text = stream.messages.some((msg) => JSON.stringify(msg).includes("Create a new anchored summary"), ) @@ -196,13 +202,14 @@ function fakeRuntime(outputTokenMax?: number) { : calls.length === 1 ? "chunk one" : "chunk two" - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: input.assistantMessage.id, - sessionID: input.sessionID, - type: "text", - text, - }) + if (!empty) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "text", + text, + }) input.assistantMessage.finish = "stop" return "continue" as const }), @@ -238,6 +245,48 @@ function fakeRuntime(outputTokenMax?: number) { } } +async function failure(error?: MessageV2.Assistant["error"], empty = false) { + await using tmp = await tmpdir() + return provideTestInstance({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "oversized " + "x".repeat(80_000)) + await Effect.runPromise( + KiloSessionCompaction.create({ + session: store, + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }), + ) + + const { rt } = fakeRuntime(undefined, error, empty) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + const all = await svc.messages({ sessionID: session.id }) + const summary = all.find((msg) => msg.info.role === "assistant" && msg.info.summary) + return { result, summary } + } finally { + await rt.dispose() + } + }, + }) +} + function liveRuntime(layer: Layer.Layer, context = 10_000) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus), Layer.provide(EventV2Bridge.defaultLayer)) @@ -317,6 +366,53 @@ describe("KiloCompactionChunks", () => { expect(KiloCompactionChunks.budget({ cfg, model, outputTokenMax })).toBe(5_692) }) + test("preserves gateway errors from chunk workers", async () => { + const error = new MessageV2.APIError({ + message: "The operation was aborted", + statusCode: 504, + isRetryable: true, + responseBody: '{"error_type":"timeout"}', + }).toObject() + + const result = await failure(error) + + expect(result.result).toBe("stop") + expect(result.summary?.info.role).toBe("assistant") + if (result.summary?.info.role !== "assistant") return + expect(result.summary.info.finish).toBe("error") + expect(result.summary.info.error).toEqual(error) + }) + + test("keeps context overflow on the terminal compaction path", async () => { + const result = await failure( + new MessageV2.ContextOverflowError({ + message: "worker context overflow", + }).toObject(), + ) + + expect(result.result).toBe("stop") + expect(result.summary?.info.role).toBe("assistant") + if (result.summary?.info.role !== "assistant") return + expect(result.summary.info.error?.name).toBe("ContextOverflowError") + if (result.summary.info.error?.name !== "ContextOverflowError") return + expect(result.summary.info.error.data.message).toBe( + "Session too large to compact - context exceeds model limit even after stripping media", + ) + }) + + test("reports empty chunk worker responses as API errors", async () => { + const result = await failure(undefined, true) + + expect(result.result).toBe("stop") + expect(result.summary?.info.role).toBe("assistant") + if (result.summary?.info.role !== "assistant") return + expect(result.summary.info.finish).toBe("error") + expect(result.summary.info.error?.name).toBe("APIError") + if (result.summary.info.error?.name !== "APIError") return + expect(result.summary.info.error.data.message).toBe("Compaction worker returned an empty response") + expect(result.summary.info.error.data.isRetryable).toBe(true) + }) + test("falls back to chunk workers after the first compaction overflows", async () => { await using tmp = await tmpdir() await provideTestInstance({ diff --git a/packages/opencode/test/kilocode/session-overflow.test.ts b/packages/opencode/test/kilocode/session-overflow.test.ts index 1c5075c008..4cca536949 100644 --- a/packages/opencode/test/kilocode/session-overflow.test.ts +++ b/packages/opencode/test/kilocode/session-overflow.test.ts @@ -123,6 +123,19 @@ describe("Kilo auto-compaction threshold", () => { expect(isOverflow({ cfg: conf, model: mdl, tokens: { ...tokens(0), total: 150_000 } })).toBe(true) }) + + test("uses the output cap as the reserve for single-window gateway models", () => { + const mdl = model({ context: 262_144, output: 262_144 }) + + expect(usable({ cfg: cfg(), model: mdl })).toBe(230_144) + expect(usable({ cfg: cfg({ reserved: 20_000 }), model: mdl })).toBe(230_144) + }) + + test("keeps usable context for small single-window models with large output limits", () => { + const mdl = model({ context: 40_000, output: 262_144 }) + + expect(usable({ cfg: cfg(), model: mdl })).toBe(8_000) + }) }) describe("Kilo request estimation", () => {