feat(jetbrains): center session readable width

This commit is contained in:
kirillk
2026-08-17 12:49:02 -04:00
parent ede14046da
commit 8db5a8c5c9
10 changed files with 216 additions and 37 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Center JetBrains session content at a 98-column readable width on wide panels.
@@ -44,6 +44,9 @@ import ai.kilocode.client.session.ui.selection.SessionHoverCopyOverlay
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.ui.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.client.session.controller.EVENT_FLUSH_MS
import ai.kilocode.client.session.controller.PromptSelection
import ai.kilocode.client.session.controller.SessionController
@@ -452,7 +455,14 @@ class SessionUi(
sessionContent.add(header, BorderLayout.NORTH)
sessionContent.add(scroll.component, BorderLayout.CENTER)
root.content.add(sessionContent, BorderLayout.CENTER)
root.content.add(prompt, BorderLayout.SOUTH)
root.content.add(
prompt.align(
HAlign.CENTER,
VAlign.FIT,
maxW = { SessionUiStyle.SessionLayout.readableWidth(prompt, style.transcriptFont) },
),
BorderLayout.SOUTH,
)
add(root, BorderLayout.CENTER)
}
@@ -36,6 +36,8 @@ class SessionLayout(
private val cache = IdentityHashMap<Component, Measured>()
var maxWidth: ((Container) -> Int)? = null
override fun addLayoutComponent(name: String, comp: Component) = Unit
override fun removeLayoutComponent(comp: Component) {
cache.remove(comp)
@@ -50,7 +52,7 @@ class SessionLayout(
if (!comp.isVisible) continue
if (!first) h += gap(comp)
first = false
val child = bounds(ins, w, comp)
val child = bounds(parent, ins, w, comp)
h += measure(comp, child.width)
}
// w and h are already scaled px (child preferred heights + scaled gaps/insets) and
@@ -70,7 +72,7 @@ class SessionLayout(
if (!comp.isVisible) continue
if (!first) y += gap(comp)
first = false
val child = bounds(ins, w, comp)
val child = bounds(parent, ins, w, comp)
val h = measure(comp, child.width)
comp.setBounds(child.left, y, child.width, h)
y += h
@@ -106,13 +108,20 @@ class SessionLayout(
return h
}
private fun bounds(ins: Insets, width: Int, comp: Component): Bounds {
val view = view(comp) ?: return Bounds(ins.left, width)
if (view.sessionViewKind != SessionView.Kind.UserPrompt) return Bounds(ins.left, width)
private fun bounds(parent: Container, ins: Insets, width: Int, comp: Component): Bounds {
val lane = lane(parent, ins, width)
val view = view(comp) ?: return lane
if (view.sessionViewKind != SessionView.Kind.UserPrompt) return lane
val shift = JBUI.scale(SessionUiStyle.SessionLayout.USER_PROMPT_INDENT)
val next = width - shift
if (next < JBUI.scale(SessionUiStyle.SessionLayout.USER_PROMPT_INDENT)) return Bounds(ins.left, width)
return Bounds(ins.left + shift, next)
val next = lane.width - shift
if (next < JBUI.scale(SessionUiStyle.SessionLayout.USER_PROMPT_INDENT)) return lane
return Bounds(lane.left + shift, next)
}
private fun lane(parent: Container, ins: Insets, width: Int): Bounds {
val cap = maxWidth?.invoke(parent) ?: width
val w = minOf(width, cap.coerceAtLeast(0))
return Bounds(ins.left + (width - w) / 2, w)
}
private fun insets(parent: Container): Insets {
@@ -97,6 +97,9 @@ class SessionMessageListPanel(
init {
Disposer.register(parent, this)
(layout as? SessionLayout)?.maxWidth = { view ->
SessionUiStyle.SessionLayout.readableWidth(view, style.transcriptFont)
}
applyStyle(style)
model.addListener(parent) { event ->
@@ -55,7 +55,12 @@ class EmptySessionPanel(
private val browse: (String) -> Unit = BrowserUtil::browse,
private val timers: UiTimerSource = UiTimers,
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
val view: Align = align(HAlign.CENTER, VAlign.CENTER)
private var style = SessionEditorStyle.current()
val view: Align = align(
HAlign.CENTER,
VAlign.CENTER,
maxW = { SessionUiStyle.SessionLayout.readableWidth(this, style.transcriptFont) },
)
private val timer = timers.timer(ACTIVITY_MS) { syncActivity() }
internal val recent = RecentsList(recents, controller)
@@ -248,6 +253,7 @@ class EmptySessionPanel(
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
welcomeLabel.font = style.regularFont
recent.applyStyle(style)
revalidate()
@@ -5,6 +5,7 @@ import com.intellij.ui.JBColor
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.Color
import java.awt.Component
import java.awt.Font
import javax.swing.UIManager
@@ -75,6 +76,7 @@ object SessionUiStyle {
/** Geometry for the transcript list and its scroll behavior. */
object SessionLayout {
const val READABLE_COLUMNS = 98
const val GAP = 3
const val USER_PROMPT_GAP = 10
const val TRANSCRIPT_SCROLLBAR_PADDING = 10
@@ -87,6 +89,11 @@ object SessionUiStyle {
const val USER_PROMPT_INDENT = 100
const val SCROLL_INCREMENT = 48
fun readableWidth(component: Component, font: Font): Int {
val width = component.getFontMetrics(font).charWidth('0').coerceAtLeast(1)
return width * READABLE_COLUMNS
}
}
/** Shared tokens for individual transcript views and session views. */
@@ -21,6 +21,9 @@ enum class VAlign { TRACK, FIT, TOP, CENTER, BOTTOM }
* **LEFT / CENTER / RIGHT** (horizontal) and **TOP / CENTER / BOTTOM** (vertical):
* child uses its bounded preferred size (coerced into [min, max]) and is placed at the
* corresponding edge or centered. Shrinks to available space when necessary.
* When a dynamic max function is supplied for an axis, these modes fill available space
* up to that max and use alignment only for the leftover space, matching CSS
* `width: 100%; max-width: ...; margin-inline: auto` behavior.
*
* During layout, the child is first sized on TRACK/FIT axes before preferred size
* is read. This mirrors Swing layouts such as [java.awt.BorderLayout] where the
@@ -42,7 +45,9 @@ class Align(
child: Component,
h: HAlign = HAlign.FIT,
v: VAlign = VAlign.FIT,
) : JPanel(Layout(h, v)) {
maxW: (() -> Int)? = null,
maxH: (() -> Int)? = null,
) : JPanel(Layout(h, v, maxW, maxH)) {
init {
isOpaque = false
@@ -52,6 +57,8 @@ class Align(
private class Layout(
private val h: HAlign,
private val v: VAlign,
private val maxW: (() -> Int)?,
private val maxH: (() -> Int)?,
) : LayoutManager2 {
override fun addLayoutComponent(comp: Component, constraints: Any?) = Unit
@@ -66,17 +73,17 @@ class Align(
val availH = maxOf(0, parent.height - ins.top - ins.bottom)
val min = child.minimumSize
val max = child.maximumSize
if (probes(h) || probes(v)) {
val max = max(child)
if (probes(h) || probes(v) || maxW != null || maxH != null) {
child.setSize(
if (probes(h)) probe(h, availW, min.width, max.width) else child.width,
if (probes(v)) probe(v, availH, min.height, max.height) else child.height,
if (probes(h) || maxW != null) probe(h, availW, min.width, max.width, maxW != null) else child.width,
if (probes(v) || maxH != null) probe(v, availH, min.height, max.height, maxH != null) else child.height,
)
}
val pref = child.preferredSize
val (w, cx) = place(h, availW, min.width, pref.width, max.width)
val (ht, cy) = place(v, availH, min.height, pref.height, max.height)
val (w, cx) = place(h, availW, min.width, pref.width, max.width, maxW != null)
val (ht, cy) = place(v, availH, min.height, pref.height, max.height, maxH != null)
child.setBounds(ins.left + cx, ins.top + cy, w, ht)
}
@@ -95,18 +102,18 @@ class Align(
val child = parent.getComponent(0)
val ins = parent.insets
val min = child.minimumSize
val max = child.maximumSize
val max = max(child)
val availW = maxOf(0, parent.width - ins.left - ins.right)
val availH = maxOf(0, parent.height - ins.top - ins.bottom)
if ((availW > 0 && probes(h)) || (availH > 0 && probes(v))) {
if ((availW > 0 && (probes(h) || maxW != null)) || (availH > 0 && (probes(v) || maxH != null))) {
child.setSize(
if (availW > 0 && probes(h)) probe(h, availW, min.width, max.width) else child.width,
if (availH > 0 && probes(v)) probe(v, availH, min.height, max.height) else child.height,
if (availW > 0 && (probes(h) || maxW != null)) probe(h, availW, min.width, max.width, maxW != null) else child.width,
if (availH > 0 && (probes(v) || maxH != null)) probe(v, availH, min.height, max.height, maxH != null) else child.height,
)
}
val pref = child.preferredSize
val cw = if (h == HAlign.TRACK) 0 else bounded(pref.width, min.width, max.width)
val ch = if (v == VAlign.TRACK) 0 else bounded(pref.height, min.height, max.height)
val cw = if (h == HAlign.TRACK) 0 else size(h, pref.width, min.width, max.width, maxW != null)
val ch = if (v == VAlign.TRACK) 0 else size(v, pref.height, min.height, max.height, maxH != null)
return Dimension(cw + ins.left + ins.right, ch + ins.top + ins.bottom)
}
@@ -114,19 +121,25 @@ class Align(
if (target.componentCount == 0) return Dimension(Int.MAX_VALUE, Int.MAX_VALUE)
val child = target.getComponent(0)
val ins = target.insets
val max = max(child)
val cw = if (h == HAlign.TRACK) {
Int.MAX_VALUE
} else {
maxOf(child.minimumSize.width, child.maximumSize.width) + ins.left + ins.right
maxOf(child.minimumSize.width, max.width) + ins.left + ins.right
}
val ch = if (v == VAlign.TRACK) {
Int.MAX_VALUE
} else {
maxOf(child.minimumSize.height, child.maximumSize.height) + ins.top + ins.bottom
maxOf(child.minimumSize.height, max.height) + ins.top + ins.bottom
}
return Dimension(cw, ch)
}
private fun max(child: Component): Dimension {
val base = child.maximumSize
return Dimension(maxW?.invoke() ?: base.width, maxH?.invoke() ?: base.height)
}
override fun getLayoutAlignmentX(target: Container) = 0.5f
override fun getLayoutAlignmentY(target: Container) = 0.5f
override fun invalidateLayout(target: Container) = Unit
@@ -143,7 +156,7 @@ class Align(
* - FIT: size = clamp(avail, min, max), offset = 0
* - edge/center: size = clamp(boundedPref, 0, avail), offset positions according to alignment
*/
private fun place(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair<Int, Int> {
private fun place(mode: Any, avail: Int, min: Int, pref: Int, max: Int, cap: Boolean): Pair<Int, Int> {
val effMax = maxOf(min, max)
return when (mode) {
HAlign.TRACK, VAlign.TRACK -> avail to 0
@@ -153,15 +166,15 @@ private fun place(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair<In
size to 0
}
HAlign.LEFT, VAlign.TOP -> {
val size = minOf(bounded(pref, min, effMax), avail)
val size = minOf(size(mode, pref, min, effMax, cap), avail)
size to 0
}
HAlign.CENTER, VAlign.CENTER -> {
val size = minOf(bounded(pref, min, effMax), avail)
val size = minOf(size(mode, pref, min, effMax, cap), avail)
size to (avail - size) / 2
}
HAlign.RIGHT, VAlign.BOTTOM -> {
val size = minOf(bounded(pref, min, effMax), avail)
val size = minOf(size(mode, pref, min, effMax, cap), avail)
size to (avail - size)
}
else -> avail to 0
@@ -170,9 +183,18 @@ private fun place(mode: Any, avail: Int, min: Int, pref: Int, max: Int): Pair<In
private fun bounded(value: Int, min: Int, max: Int) = value.coerceIn(min, maxOf(min, max))
private fun size(mode: Any, pref: Int, min: Int, max: Int, cap: Boolean): Int {
if (!cap) return bounded(pref, min, max)
if (mode == HAlign.LEFT || mode == HAlign.CENTER || mode == HAlign.RIGHT ||
mode == VAlign.TOP || mode == VAlign.CENTER || mode == VAlign.BOTTOM
) return maxOf(min, max)
return bounded(pref, min, max)
}
private fun probes(mode: Any) = mode == HAlign.TRACK || mode == HAlign.FIT || mode == VAlign.TRACK || mode == VAlign.FIT
private fun probe(mode: Any, avail: Int, min: Int, max: Int): Int {
private fun probe(mode: Any, avail: Int, min: Int, max: Int, cap: Boolean): Int {
if (cap && mode != HAlign.TRACK && mode != VAlign.TRACK) return minOf(avail, maxOf(min, max))
if (mode == HAlign.FIT || mode == VAlign.FIT) return minOf(avail, maxOf(min, max))
return avail
}
@@ -181,4 +203,4 @@ private fun probe(mode: Any, avail: Int, min: Int, max: Int): Int {
// Factory extension
// ---------------------------------------------------------------------------
fun Component.align(h: HAlign, v: VAlign) = Align(this, h, v)
fun Component.align(h: HAlign, v: VAlign, maxW: (() -> Int)? = null, maxH: (() -> Int)? = null) = Align(this, h, v, maxW, maxH)
@@ -22,6 +22,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.client.ui.layout.Align
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.KiloAppStateDto
@@ -37,6 +38,7 @@ import com.intellij.ui.components.JBScrollPane
import java.awt.Dimension
import javax.swing.JLayeredPane
import javax.swing.JPanel
import javax.swing.SwingUtilities
import kotlinx.coroutines.CompletableDeferred
@Suppress("UnstableApiUsage")
@@ -80,7 +82,8 @@ class SessionUiLayoutTest : SessionUiTestBase() {
val connection = find<ConnectionPanel>(ui)
val prompt = find<PromptPanel>(ui)
assertSame(root.content, prompt.parent)
assertTrue(prompt.parent is Align)
assertSame(root.content, prompt.parent.parent)
assertSame(root.overlay, connection.parent)
assertTrue(root.overlay.components.any { it is SessionAccountOverlay })
assertFalse(root.content.components.contains(connection))
@@ -245,15 +248,17 @@ class SessionUiLayoutTest : SessionUiTestBase() {
showConnection()
layout()
val point = promptPoint(root, prompt)
assertTrue(connection.isVisible)
assertSame(root.overlay, connection.parent)
assertEquals(prompt.x, connection.x)
assertEquals(point.x, connection.x)
assertEquals(prompt.width, connection.width)
assertEquals(prompt.y - SessionUiStyle.View.Outline.width(), connection.y + connection.height)
assertEquals(point.y - SessionUiStyle.View.Outline.width(), connection.y + connection.height)
}
fun `test expanded connection panel remains anchored above prompt`() {
val root = find<SessionRootPanel>(ui)
val connection = find<ConnectionPanel>(ui)
val prompt = find<PromptPanel>(ui)
@@ -266,7 +271,7 @@ class SessionUiLayoutTest : SessionUiTestBase() {
layout()
assertTrue(connection.detailsVisible())
assertEquals(prompt.y - SessionUiStyle.View.Outline.width(), connection.y + connection.height)
assertEquals(promptPoint(root, prompt).y - SessionUiStyle.View.Outline.width(), connection.y + connection.height)
}
fun `test connection panel is unaffected by active question view`() {
@@ -276,6 +281,7 @@ class SessionUiLayoutTest : SessionUiTestBase() {
layout()
val connection = find<ConnectionPanel>(ui)
val prompt = find<PromptPanel>(ui)
val root = find<SessionRootPanel>(ui)
val top = connection.y
controller().model.setState(questionStateChanged())
@@ -284,7 +290,7 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertTrue(find<QuestionView>(ui).isVisible)
assertSame(find<SessionMessageListPanel>(ui), find<QuestionView>(ui).parent)
assertEquals(top, connection.y)
assertEquals(prompt.y - SessionUiStyle.View.Outline.width(), connection.y + connection.height)
assertEquals(promptPoint(root, prompt).y - SessionUiStyle.View.Outline.width(), connection.y + connection.height)
assertSame(find<SessionMessageListPanel>(ui), scrollView())
}
@@ -295,6 +301,7 @@ class SessionUiLayoutTest : SessionUiTestBase() {
layout()
val connection = find<ConnectionPanel>(ui)
val prompt = find<PromptPanel>(ui)
val root = find<SessionRootPanel>(ui)
val top = connection.y
controller().model.setState(permissionStateChanged())
@@ -303,7 +310,7 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertTrue(find<PermissionView>(ui).isVisible)
assertSame(find<SessionMessageListPanel>(ui), find<PermissionView>(ui).parent)
assertEquals(top, connection.y)
assertEquals(prompt.y - SessionUiStyle.View.Outline.width(), connection.y + connection.height)
assertEquals(promptPoint(root, prompt).y - SessionUiStyle.View.Outline.width(), connection.y + connection.height)
assertSame(find<SessionMessageListPanel>(ui), scrollView())
}
@@ -764,6 +771,9 @@ class SessionUiLayoutTest : SessionUiTestBase() {
.single()
.let { it as javax.swing.JComponent }
private fun promptPoint(root: SessionRootPanel, prompt: PromptPanel) =
SwingUtilities.convertPoint(prompt.parent, prompt.x, prompt.y, root.overlay)
private class Row(override val sessionViewKind: SessionView.Kind) : JPanel(), SessionView {
override fun getPreferredSize() = Dimension(100, 10)
}
@@ -185,6 +185,56 @@ class SessionLayoutTest : BasePlatformTestCase() {
assertEquals(20, child.height)
}
fun `test max width centers default session view in wide container`() {
val p = panel(width = 500)
(p.layout as SessionLayout).maxWidth = { 300 }
val child = view(height = 20, kind = SessionView.Kind.Default)
p.add(child)
p.doLayout()
assertEquals(100, child.x)
assertEquals(300, child.width)
assertEquals(20, child.height)
}
fun `test max width fills narrow container`() {
val p = panel(width = 250)
(p.layout as SessionLayout).maxWidth = { 300 }
val child = view(height = 20, kind = SessionView.Kind.Default)
p.add(child)
p.doLayout()
assertEquals(0, child.x)
assertEquals(250, child.width)
assertEquals(20, child.height)
}
fun `test max width centers user prompt indent inside lane`() {
val p = panel(width = 500)
(p.layout as SessionLayout).maxWidth = { 300 }
val child = view(height = 20, kind = SessionView.Kind.UserPrompt)
p.add(child)
p.doLayout()
assertEquals(100 + JBUI.scale(SessionUiStyle.SessionLayout.USER_PROMPT_INDENT), child.x)
assertEquals(300 - JBUI.scale(SessionUiStyle.SessionLayout.USER_PROMPT_INDENT), child.width)
assertEquals(20, child.height)
}
fun `test max width measures child height at capped width`() {
val p = panel(width = 500)
(p.layout as SessionLayout).maxWidth = { 300 }
val child = object : JPanel() {
override fun getPreferredSize(): Dimension = Dimension(0, if (width == 300) 20 else 80)
}
p.add(child)
p.doLayout()
assertEquals(100, child.x)
assertEquals(300, child.width)
assertEquals(20, child.height)
}
// ---- invisible children ------
fun `test invisible child is skipped in layout`() {
@@ -298,6 +298,63 @@ class AlignTest : BasePlatformTestCase() {
assertEquals(30 + ins.top + ins.bottom, ps.height)
}
// ------ function max ------
fun `test CENTER with max function fills cap and centers`() {
val child = child(pref = 40 x 20)
val wrap = Align(child, HAlign.CENTER, VAlign.TOP, maxW = { 120 })
wrap.setBounds(0, 0, 300, 100)
wrap.doLayout()
assertBounds(90, 0, 120, 20, child)
}
fun `test CENTER with max function fills available below cap`() {
val child = child(pref = 40 x 20)
val wrap = Align(child, HAlign.CENTER, VAlign.TOP, maxW = { 120 })
wrap.setBounds(0, 0, 80, 100)
wrap.doLayout()
assertBounds(0, 0, 80, 20, child)
}
fun `test RIGHT with max function pins capped width to right`() {
val child = child(pref = 40 x 20)
val wrap = Align(child, HAlign.RIGHT, VAlign.TOP, maxW = { 120 })
wrap.setBounds(0, 0, 300, 100)
wrap.doLayout()
assertBounds(180, 0, 120, 20, child)
}
fun `test max function is evaluated on each layout`() {
var cap = 120
val child = child(pref = 40 x 20)
val wrap = Align(child, HAlign.CENTER, VAlign.TOP, maxW = { cap })
wrap.setBounds(0, 0, 300, 100)
wrap.doLayout()
assertBounds(90, 0, 120, 20, child)
cap = 60
wrap.doLayout()
assertBounds(120, 0, 60, 20, child)
}
fun `test max function probes capped width before measuring height`() {
val child = object : JBLabel("x") {
override fun getMinimumSize() = Dimension(0, 0)
override fun getPreferredSize() = Dimension(20, if (width == 120) 12 else 60)
override fun getMaximumSize() = Dimension(Int.MAX_VALUE, Int.MAX_VALUE)
}
val wrap = Align(child, HAlign.CENTER, VAlign.TOP, maxW = { 120 })
wrap.setBounds(0, 0, 300, 100)
wrap.doLayout()
assertBounds(90, 0, 120, 12, child)
}
// ------ align() factory ------
fun `test align extension returns Align wrapping child`() {