mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-29 17:37:00 +08:00
fix(jetbrains): stop duplicated streaming text in chat code blocks
Replace the fragile covered/uncovered prefix dedup in SessionController with a stateless content-overlap glue: a text/reasoning delta only appends the portion that extends the current model tail. This eliminates the token-doubling (e.g. "importimport", "ReadRead a file") that occurred when multiple PartUpdated snapshots landed before the lagging PartDelta stream caught up. Also adds a MdViewHybrid fast path that grows the trailing code view in place while inside an open fenced code block, skipping the full commonmark re-parse and block diff per token, with html() recomputed lazily when stale.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Improve JetBrains chat streaming performance by retaining existing markdown and code block views while responses stream, and render code blocks without showing raw fence markers during streamed updates.
|
||||
+16
-1
@@ -17,7 +17,9 @@ import ai.kilocode.client.session.model.PermissionRequestState
|
||||
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.model.Reasoning
|
||||
import ai.kilocode.client.session.model.ToolCallRef
|
||||
import ai.kilocode.client.session.model.Text
|
||||
import ai.kilocode.client.plugin.KiloPluginSettings
|
||||
import ai.kilocode.client.session.SessionRef
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
@@ -897,7 +899,8 @@ class SessionController(
|
||||
|
||||
is ChatEventDto.PartDelta -> {
|
||||
if (event.field == "text") {
|
||||
model.appendDelta(event.messageID, event.partID, event.delta)
|
||||
val delta = glue(event.messageID, event.partID, event.delta)
|
||||
if (delta.isNotEmpty()) model.appendDelta(event.messageID, event.partID, delta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,6 +980,18 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun glue(messageId: String, partId: String, delta: String): String {
|
||||
if (delta.isEmpty()) return delta
|
||||
val cur = when (val content = model.content(messageId, partId)) {
|
||||
is Text -> content.content
|
||||
is Reasoning -> content.content
|
||||
else -> return delta
|
||||
}
|
||||
val span = (minOf(cur.length, delta.length) downTo 1)
|
||||
.firstOrNull { n -> cur.regionMatches(cur.length - n, delta, 0, n) } ?: 0
|
||||
return delta.substring(span)
|
||||
}
|
||||
|
||||
private fun handleHidden(event: ChatEventDto): Boolean = when (event) {
|
||||
is ChatEventDto.Error,
|
||||
is ChatEventDto.PermissionAsked,
|
||||
|
||||
+2
-1
@@ -157,6 +157,7 @@ class SessionModel {
|
||||
fun appendDelta(messageId: String, contentId: String, delta: String) {
|
||||
val msg = entries[messageId] ?: return
|
||||
val existing = msg.parts[contentId]
|
||||
val created = existing == null
|
||||
if (existing != null) {
|
||||
val buf = when (existing) {
|
||||
is Text -> existing.content
|
||||
@@ -170,7 +171,7 @@ class SessionModel {
|
||||
msg.parts[contentId] = content
|
||||
fire(SessionModelEvent.ContentAdded(messageId, content))
|
||||
}
|
||||
fire(SessionModelEvent.ContentDelta(messageId, contentId, delta))
|
||||
fire(SessionModelEvent.ContentDelta(messageId, contentId, delta, created))
|
||||
updateHeader()
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -36,7 +36,12 @@ sealed class SessionModelEvent {
|
||||
data class ContentRemoved(val messageId: String, val contentId: String) : SessionModelEvent() {
|
||||
override fun toString() = "ContentRemoved $messageId/$contentId"
|
||||
}
|
||||
data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() {
|
||||
data class ContentDelta(
|
||||
val messageId: String,
|
||||
val contentId: String,
|
||||
val delta: String,
|
||||
val created: Boolean = false,
|
||||
) : SessionModelEvent() {
|
||||
override fun toString() = "ContentDelta $messageId/$contentId"
|
||||
}
|
||||
data class StateChanged(val state: SessionState) : SessionModelEvent() {
|
||||
|
||||
+3
-6
@@ -96,14 +96,11 @@ class SessionMessageListPanel(
|
||||
}
|
||||
|
||||
is SessionModelEvent.ContentDelta -> {
|
||||
// Use the full current content from the model rather than
|
||||
// an incremental append. This avoids the double-write that
|
||||
// occurs when ContentAdded and ContentDelta both fire for
|
||||
// the same first delta (the model auto-creates the content
|
||||
// on first appendDelta and fires both events in sequence).
|
||||
if (event.created) return@addListener
|
||||
val handled = msgToView[event.messageId]?.appendDelta(event.contentId, event.delta) == true
|
||||
if (handled) return@addListener
|
||||
val content = model.content(event.messageId, event.contentId)
|
||||
if (content != null) msgToView[event.messageId]?.upsertPart(content)
|
||||
refresh()
|
||||
}
|
||||
|
||||
is SessionModelEvent.HistoryLoaded -> rebuild()
|
||||
|
||||
+3
-2
@@ -182,10 +182,11 @@ class MessageView(
|
||||
}
|
||||
|
||||
/** Append a streaming delta to the renderer for [contentId]. */
|
||||
fun appendDelta(contentId: String, delta: String) {
|
||||
val part = parts[contentId] ?: return
|
||||
fun appendDelta(contentId: String, delta: String): Boolean {
|
||||
val part = parts[contentId] ?: return false
|
||||
part.appendDelta(delta)
|
||||
refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Look up a renderer by part id. */
|
||||
|
||||
+360
-48
@@ -126,8 +126,10 @@ internal class MdViewHybrid(
|
||||
private val source = StringBuilder()
|
||||
private var style = style
|
||||
private var rendered = ""
|
||||
private var block: Disposable? = null
|
||||
private var disposed = false
|
||||
private val blocks = mutableListOf<View>()
|
||||
private var openFence: Fence? = null
|
||||
private var stale = false
|
||||
|
||||
private val extensions = listOf(
|
||||
AutolinkExtension.create(),
|
||||
@@ -284,6 +286,7 @@ internal class MdViewHybrid(
|
||||
if (disposed) return
|
||||
if (this.selection === selection) return
|
||||
this.selection = selection
|
||||
clearBlocks()
|
||||
syncBlocks()
|
||||
}
|
||||
|
||||
@@ -315,6 +318,16 @@ internal class MdViewHybrid(
|
||||
override fun append(delta: String) {
|
||||
if (disposed) return
|
||||
if (delta.isEmpty()) return
|
||||
val fence = openFence
|
||||
val view = blocks.lastOrNull() as? CodeView
|
||||
if (fence != null && view != null && clean(fence.char, delta)) {
|
||||
source.append(delta)
|
||||
view.grow(delta)
|
||||
stale = true
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
return
|
||||
}
|
||||
source.append(delta)
|
||||
syncBlocks()
|
||||
}
|
||||
@@ -324,6 +337,8 @@ internal class MdViewHybrid(
|
||||
if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return
|
||||
source.clear()
|
||||
rendered = ""
|
||||
openFence = null
|
||||
stale = false
|
||||
clearBlocks()
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
@@ -340,7 +355,14 @@ internal class MdViewHybrid(
|
||||
|
||||
override fun markdown(): String = source.toString()
|
||||
|
||||
override fun html(): String = rendered
|
||||
override fun html(): String {
|
||||
if (!stale) return rendered
|
||||
val out = project(source.toString())
|
||||
rendered = out.html
|
||||
openFence = out.open
|
||||
stale = false
|
||||
return rendered
|
||||
}
|
||||
|
||||
override fun overrideSheet(): String = MdCommon.rules(opts())
|
||||
|
||||
@@ -354,6 +376,8 @@ internal class MdViewHybrid(
|
||||
listeners.clear()
|
||||
source.clear()
|
||||
rendered = ""
|
||||
openFence = null
|
||||
stale = false
|
||||
clearBlocks()
|
||||
}
|
||||
|
||||
@@ -362,37 +386,66 @@ internal class MdViewHybrid(
|
||||
val opts = opts()
|
||||
root.isOpaque = opts.opaque
|
||||
if (opts.opaque) root.background = opts.background
|
||||
syncBlocks()
|
||||
for (view in blocks) view.style(opts)
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
}
|
||||
|
||||
private fun syncBlocks() {
|
||||
if (disposed) return
|
||||
val text = source.toString()
|
||||
val doc = parser.parse(text)
|
||||
val body = renderer.render(doc)
|
||||
rendered = body
|
||||
resetBlocks()
|
||||
val out = project(text)
|
||||
rendered = out.html
|
||||
openFence = out.open
|
||||
stale = false
|
||||
val next = out.blocks
|
||||
if (text.isEmpty()) {
|
||||
openFence = null
|
||||
clearBlocks()
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
return
|
||||
}
|
||||
val visitor = Visitor()
|
||||
doc.accept(visitor)
|
||||
sync(next)
|
||||
root.revalidate()
|
||||
root.repaint()
|
||||
}
|
||||
|
||||
private fun resetBlocks() {
|
||||
block?.let { Disposer.dispose(it) }
|
||||
block = Disposer.newDisposable("Markdown blocks")
|
||||
private fun clearBlocks() {
|
||||
blocks.forEach { Disposer.dispose(it.disposable) }
|
||||
blocks.clear()
|
||||
root.removeAll()
|
||||
}
|
||||
|
||||
private fun clearBlocks() {
|
||||
block?.let { Disposer.dispose(it) }
|
||||
block = null
|
||||
root.removeAll()
|
||||
private fun sync(next: List<Desc>) {
|
||||
var at = 0
|
||||
while (at < blocks.size && at < next.size) {
|
||||
val view = blocks[at]
|
||||
val desc = next[at]
|
||||
if (!view.compatible(desc)) break
|
||||
view.update(desc)
|
||||
at++
|
||||
}
|
||||
removeBlocks(at)
|
||||
for (desc in next.drop(at)) addBlock(view(desc))
|
||||
}
|
||||
|
||||
private fun clean(char: Char, delta: String): Boolean {
|
||||
if (delta.contains(char)) return false
|
||||
val start = source.lastIndexOf("\n") + 1
|
||||
for (idx in start until source.length) {
|
||||
if (source[idx] == char) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun removeBlocks(start: Int) {
|
||||
if (start >= blocks.size) return
|
||||
val idx = if (start == 0) 0 else start * 2 - 1
|
||||
while (root.componentCount > idx) root.remove(root.componentCount - 1)
|
||||
val stale = blocks.drop(start)
|
||||
repeat(blocks.size - start) { blocks.removeAt(blocks.lastIndex) }
|
||||
stale.forEach { Disposer.dispose(it.disposable) }
|
||||
}
|
||||
|
||||
private fun addGap() {
|
||||
@@ -400,15 +453,23 @@ internal class MdViewHybrid(
|
||||
root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP)))
|
||||
}
|
||||
|
||||
private fun addBlock(component: JComponent) {
|
||||
private fun addBlock(view: View) {
|
||||
addGap()
|
||||
component.alignmentX = JComponent.LEFT_ALIGNMENT
|
||||
root.add(component)
|
||||
view.component.alignmentX = JComponent.LEFT_ALIGNMENT
|
||||
blocks.add(view)
|
||||
root.add(view.component)
|
||||
}
|
||||
|
||||
private fun htmlBlock(node: Node): JComponent {
|
||||
private fun view(desc: Desc): View {
|
||||
val disposable = Disposer.newDisposable("Markdown block")
|
||||
return when (desc) {
|
||||
is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable)
|
||||
is Desc.Code -> CodeView(desc, codeBlock(desc.text, desc.file, disposable), disposable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun htmlBlock(body: String, disposable: Disposable): JBHtmlPane {
|
||||
val opts = opts()
|
||||
val body = renderer.render(node)
|
||||
return JBHtmlPane(
|
||||
JBHtmlPaneStyleConfiguration {
|
||||
enableInlineCodeBackground = true
|
||||
@@ -422,7 +483,7 @@ internal class MdViewHybrid(
|
||||
isOpaque = opts.opaque
|
||||
background = opts.background
|
||||
text = "<html><body>$body</body></html>"
|
||||
block?.let { selection?.register(this, it) }
|
||||
selection?.register(this, disposable)
|
||||
addHyperlinkListener { e ->
|
||||
if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener
|
||||
val href = e.description ?: return@addHyperlinkListener
|
||||
@@ -432,24 +493,20 @@ internal class MdViewHybrid(
|
||||
}
|
||||
}
|
||||
|
||||
private fun codeBlock(text: String, lang: String?): JComponent {
|
||||
private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane {
|
||||
val opts = opts()
|
||||
val value = text.trimEnd('\n')
|
||||
val field = runCatching {
|
||||
CodeField(file(lang), opts, text).also { ed ->
|
||||
block?.let { ed.setDisposedWith(it) }
|
||||
block?.let { selection?.register(ed, it) }
|
||||
CodeField(file, opts, text).also { ed ->
|
||||
ed.setDisposedWith(disposable)
|
||||
selection?.register(ed, disposable)
|
||||
}
|
||||
}.getOrElse { err ->
|
||||
LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err)
|
||||
textArea(text, opts)
|
||||
textArea(text, opts, disposable)
|
||||
}
|
||||
val height = codeHeight(field, value)
|
||||
val width = codeWidth(field, value)
|
||||
field.preferredSize = Dimension(width, height)
|
||||
field.minimumSize = Dimension(0, height)
|
||||
field.maximumSize = Dimension(Int.MAX_VALUE, height)
|
||||
return object : JBScrollPane(field) {
|
||||
sizeCodeField(field, value)
|
||||
val pane = object : JBScrollPane(field) {
|
||||
override fun doLayout() {
|
||||
super.doLayout()
|
||||
val view = viewport.view ?: return
|
||||
@@ -457,7 +514,14 @@ internal class MdViewHybrid(
|
||||
if (size.height <= 0 || view.height == size.height) return
|
||||
view.setSize(view.width.coerceAtLeast(size.width), size.height)
|
||||
}
|
||||
}.apply {
|
||||
}
|
||||
styleCodePane(pane, opts)
|
||||
sizeCodePane(pane, field)
|
||||
return pane
|
||||
}
|
||||
|
||||
private fun styleCodePane(pane: JBScrollPane, opts: MdStyle) {
|
||||
pane.apply {
|
||||
border = JBUI.Borders.customLine(opts.tableBorder, SessionUiStyle.View.Code.BORDER_WIDTH)
|
||||
viewportBorder = JBUI.Borders.empty(
|
||||
SessionUiStyle.View.Code.topPadding(),
|
||||
@@ -475,14 +539,26 @@ internal class MdViewHybrid(
|
||||
horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT))
|
||||
horizontalScrollBar.isOpaque = true
|
||||
verticalScrollBar.preferredSize = JBUI.emptySize()
|
||||
val pad = viewportBorder.getBorderInsets(this)
|
||||
val size = height + insets.top + insets.bottom + pad.top + pad.bottom + horizontalScrollBar.preferredSize.height
|
||||
preferredSize = Dimension(0, size)
|
||||
minimumSize = Dimension(0, size)
|
||||
maximumSize = Dimension(Int.MAX_VALUE, size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sizeCodeField(component: JComponent, text: String) {
|
||||
val height = codeHeight(component, text)
|
||||
val width = codeWidth(component, text)
|
||||
component.preferredSize = Dimension(width, height)
|
||||
component.minimumSize = Dimension(0, height)
|
||||
component.maximumSize = Dimension(Int.MAX_VALUE, height)
|
||||
}
|
||||
|
||||
private fun sizeCodePane(pane: JBScrollPane, component: JComponent) {
|
||||
val pad = pane.viewportBorder.getBorderInsets(pane)
|
||||
val height = component.preferredSize.height + pane.insets.top + pane.insets.bottom +
|
||||
pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height
|
||||
pane.preferredSize = Dimension(0, height)
|
||||
pane.minimumSize = Dimension(0, height)
|
||||
pane.maximumSize = Dimension(Int.MAX_VALUE, height)
|
||||
}
|
||||
|
||||
private fun codeWidth(component: JComponent, text: String): Int {
|
||||
val metrics = component.getFontMetrics(component.font)
|
||||
val width = text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0
|
||||
@@ -503,18 +579,22 @@ internal class MdViewHybrid(
|
||||
return line * rows
|
||||
}
|
||||
|
||||
private fun textArea(text: String, opts: MdStyle) = JBTextArea(text.trimEnd('\n')).apply {
|
||||
private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = JBTextArea(text.trimEnd('\n')).apply {
|
||||
isEditable = false
|
||||
lineWrap = false
|
||||
isOpaque = opts.opaque
|
||||
background = opts.preBg
|
||||
foreground = opts.preFg
|
||||
font = Font(opts.codeFont, Font.PLAIN, style.editorSize)
|
||||
styleTextArea(this, opts)
|
||||
border = JBUI.Borders.empty(
|
||||
SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING,
|
||||
SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING,
|
||||
)
|
||||
block?.let { selection?.register(this, it) }
|
||||
selection?.register(this, disposable)
|
||||
}
|
||||
|
||||
private fun styleTextArea(area: JBTextArea, opts: MdStyle) {
|
||||
area.isOpaque = opts.opaque
|
||||
area.background = opts.preBg
|
||||
area.foreground = opts.preFg
|
||||
area.font = Font(opts.codeFont, Font.PLAIN, style.editorSize)
|
||||
}
|
||||
|
||||
private inner class CodeField(file: FileType, opts: MdStyle, value: String) :
|
||||
@@ -586,17 +666,247 @@ internal class MdViewHybrid(
|
||||
)
|
||||
}
|
||||
|
||||
private fun collect(doc: Node): List<Desc> {
|
||||
val visitor = Visitor()
|
||||
doc.accept(visitor)
|
||||
return visitor.blocks
|
||||
}
|
||||
|
||||
private fun project(text: String): Projection {
|
||||
val blocks = mutableListOf<Desc>()
|
||||
val html = StringBuilder()
|
||||
val md = StringBuilder()
|
||||
val lines = lines(text)
|
||||
var trailing: Fence? = null
|
||||
var idx = 0
|
||||
|
||||
fun flush() {
|
||||
if (md.isEmpty()) return
|
||||
val doc = parser.parse(md.toString())
|
||||
html.append(renderer.render(doc))
|
||||
blocks.addAll(collect(doc))
|
||||
md.clear()
|
||||
}
|
||||
|
||||
while (idx < lines.size) {
|
||||
val line = lines[idx]
|
||||
val open = opener(line.text)
|
||||
if (open == null) {
|
||||
val pending = idx == lines.lastIndex && pendingOpener(line.text)
|
||||
if (pending) {
|
||||
flush()
|
||||
blocks.add(Desc.Code("", PlainTextFileType.INSTANCE))
|
||||
html.append(codeHtml(""))
|
||||
} else {
|
||||
md.append(line.text).append(line.end)
|
||||
}
|
||||
idx++
|
||||
continue
|
||||
}
|
||||
|
||||
flush()
|
||||
idx++
|
||||
val code = StringBuilder()
|
||||
var closed = false
|
||||
var trimmed = false
|
||||
while (idx < lines.size) {
|
||||
val item = lines[idx]
|
||||
val close = closer(item.text, open)
|
||||
if (close) {
|
||||
closed = true
|
||||
idx++
|
||||
break
|
||||
}
|
||||
val partial = idx == lines.lastIndex && partialCloser(item.text, open)
|
||||
if (partial) trimmed = true
|
||||
if (!partial) code.append(item.text).append(item.end)
|
||||
idx++
|
||||
}
|
||||
val desc = Desc.Code(code.toString(), file(open.info))
|
||||
blocks.add(desc)
|
||||
html.append(codeHtml(desc.text))
|
||||
trailing = if (!closed && !trimmed) open else null
|
||||
}
|
||||
|
||||
flush()
|
||||
return Projection(html.toString(), blocks, trailing)
|
||||
}
|
||||
|
||||
private fun lines(text: String): List<Line> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
val lines = mutableListOf<Line>()
|
||||
var start = 0
|
||||
while (start < text.length) {
|
||||
val end = text.indexOf('\n', start)
|
||||
if (end == -1) {
|
||||
lines.add(Line(text.substring(start), ""))
|
||||
break
|
||||
}
|
||||
lines.add(Line(text.substring(start, end), "\n"))
|
||||
start = end + 1
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun opener(text: String): Fence? {
|
||||
val trimmed = text.dropWhile { it == ' ' }
|
||||
val indent = text.length - trimmed.length
|
||||
if (indent > 3) return null
|
||||
val char = trimmed.firstOrNull() ?: return null
|
||||
if (char != '`' && char != '~') return null
|
||||
val size = trimmed.takeWhile { it == char }.length
|
||||
if (size < 3) return null
|
||||
val info = trimmed.drop(size).trim()
|
||||
if (char == '`' && info.contains('`')) return null
|
||||
return Fence(char, size, info)
|
||||
}
|
||||
|
||||
private fun closer(text: String, fence: Fence): Boolean {
|
||||
val trimmed = text.dropWhile { it == ' ' }
|
||||
val indent = text.length - trimmed.length
|
||||
if (indent > 3) return false
|
||||
val size = trimmed.takeWhile { it == fence.char }.length
|
||||
if (size < fence.size) return false
|
||||
return trimmed.drop(size).isBlank()
|
||||
}
|
||||
|
||||
private fun pendingOpener(text: String): Boolean {
|
||||
val trimmed = text.dropWhile { it == ' ' }
|
||||
val indent = text.length - trimmed.length
|
||||
if (indent > 3) return false
|
||||
val char = trimmed.firstOrNull() ?: return false
|
||||
if (char != '`' && char != '~') return false
|
||||
val size = trimmed.takeWhile { it == char }.length
|
||||
if (size !in 1..2) return false
|
||||
return trimmed.drop(size).isBlank()
|
||||
}
|
||||
|
||||
private fun partialCloser(text: String, fence: Fence): Boolean {
|
||||
val trimmed = text.dropWhile { it == ' ' }
|
||||
val indent = text.length - trimmed.length
|
||||
if (indent > 3) return false
|
||||
val size = trimmed.takeWhile { it == fence.char }.length
|
||||
if (size !in 1 until fence.size) return false
|
||||
return trimmed.drop(size).isBlank()
|
||||
}
|
||||
|
||||
private fun codeHtml(text: String): String = "<pre><code>${escape(text)}</code></pre>\n"
|
||||
|
||||
private fun escape(text: String): String = text
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
|
||||
private sealed class Desc {
|
||||
data class Html(val body: String) : Desc()
|
||||
data class Code(val text: String, val file: FileType) : Desc()
|
||||
}
|
||||
|
||||
private data class Projection(val html: String, val blocks: List<Desc>, val open: Fence?)
|
||||
|
||||
private data class Line(val text: String, val end: String)
|
||||
|
||||
private data class Fence(val char: Char, val size: Int, val info: String)
|
||||
|
||||
private abstract inner class View(
|
||||
var desc: Desc,
|
||||
val component: JComponent,
|
||||
val disposable: Disposable,
|
||||
) {
|
||||
abstract fun compatible(desc: Desc): Boolean
|
||||
abstract fun update(desc: Desc)
|
||||
abstract fun style(opts: MdStyle)
|
||||
}
|
||||
|
||||
private inner class HtmlView(desc: Desc.Html, private val pane: JBHtmlPane, disposable: Disposable) :
|
||||
View(desc, pane, disposable) {
|
||||
override fun compatible(desc: Desc) = desc is Desc.Html
|
||||
|
||||
override fun update(desc: Desc) {
|
||||
if (this.desc == desc) return
|
||||
this.desc = desc
|
||||
pane.text = "<html><body>${(desc as Desc.Html).body}</body></html>"
|
||||
}
|
||||
|
||||
override fun style(opts: MdStyle) {
|
||||
pane.isOpaque = opts.opaque
|
||||
pane.background = opts.background
|
||||
val item = desc as Desc.Html
|
||||
pane.text = "<html><body>${item.body}</body></html>"
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) :
|
||||
View(desc, pane, disposable) {
|
||||
override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).file == desc.file
|
||||
|
||||
override fun update(desc: Desc) {
|
||||
if (this.desc == desc) return
|
||||
this.desc = desc
|
||||
val value = (desc as Desc.Code).text.trimEnd('\n')
|
||||
val view = pane.viewport.view
|
||||
when (view) {
|
||||
is CodeField -> view.text = value
|
||||
is JBTextArea -> view.text = value
|
||||
}
|
||||
if (view is JComponent) {
|
||||
sizeCodeField(view, value)
|
||||
sizeCodePane(pane, view)
|
||||
}
|
||||
}
|
||||
|
||||
fun grow(delta: String) {
|
||||
val item = desc as Desc.Code
|
||||
val next = item.copy(text = item.text + delta)
|
||||
desc = next
|
||||
val value = next.text.trimEnd('\n')
|
||||
val view = pane.viewport.view
|
||||
when (view) {
|
||||
is CodeField -> view.text = value
|
||||
is JBTextArea -> view.text = value
|
||||
}
|
||||
if (view is JComponent) {
|
||||
sizeCodeField(view, value)
|
||||
sizeCodePane(pane, view)
|
||||
}
|
||||
}
|
||||
|
||||
override fun style(opts: MdStyle) {
|
||||
styleCodePane(pane, opts)
|
||||
val view = pane.viewport.view
|
||||
when (view) {
|
||||
is CodeField -> {
|
||||
view.font = Font(opts.codeFont, Font.PLAIN, style.editorSize)
|
||||
view.background = opts.preBg
|
||||
}
|
||||
is JBTextArea -> styleTextArea(view, opts)
|
||||
}
|
||||
if (view is JComponent) {
|
||||
val text = when (view) {
|
||||
is CodeField -> view.text
|
||||
is JBTextArea -> view.text
|
||||
else -> ""
|
||||
}
|
||||
sizeCodeField(view, text)
|
||||
sizeCodePane(pane, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Visitor : AbstractVisitor() {
|
||||
val blocks = mutableListOf<Desc>()
|
||||
|
||||
override fun visit(document: Document) {
|
||||
visitChildren(document)
|
||||
}
|
||||
|
||||
override fun visit(code: FencedCodeBlock) {
|
||||
addBlock(codeBlock(code.literal, code.info))
|
||||
blocks.add(Desc.Code(code.literal, file(code.info)))
|
||||
}
|
||||
|
||||
override fun visit(code: IndentedCodeBlock) {
|
||||
addBlock(codeBlock(code.literal, null))
|
||||
blocks.add(Desc.Code(code.literal, file(null)))
|
||||
}
|
||||
|
||||
public override fun visitChildren(parent: Node) {
|
||||
@@ -604,7 +914,9 @@ internal class MdViewHybrid(
|
||||
while (child != null) {
|
||||
val next = child.next
|
||||
if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this)
|
||||
if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) addBlock(htmlBlock(child))
|
||||
if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) {
|
||||
blocks.add(Desc.Html(renderer.render(child)))
|
||||
}
|
||||
child = next
|
||||
}
|
||||
}
|
||||
|
||||
+137
-5
@@ -12,6 +12,7 @@ import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
import ai.kilocode.rpc.dto.SessionStatusDto
|
||||
import ai.kilocode.rpc.dto.TodoDto
|
||||
import ai.kilocode.rpc.dto.ToolRefDto
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
||||
class SessionUpdateQueueTest : SessionControllerTestBase() {
|
||||
|
||||
@@ -234,6 +235,137 @@ class SessionUpdateQueueTest : SessionControllerTestBase() {
|
||||
assertEquals(listOf("hello world"), delta.map { it.delta })
|
||||
}
|
||||
|
||||
fun `test text snapshot covered delta is not duplicated`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE)
|
||||
flush()
|
||||
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import")), flush = false)
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "import"), flush = false)
|
||||
settle()
|
||||
flush()
|
||||
|
||||
assertModel(
|
||||
"""
|
||||
assistant#msg1
|
||||
text#prt1:
|
||||
import
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test text snapshot covered prefix is trimmed from merged delta`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE)
|
||||
flush()
|
||||
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import")))
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import java")), flush = false)
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", " java.util.List;"), flush = false)
|
||||
settle()
|
||||
flush()
|
||||
|
||||
assertModel(
|
||||
"""
|
||||
assistant#msg1
|
||||
text#prt1:
|
||||
import java.util.List;
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test repeated snapshots then lagging merged delta is not duplicated`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE)
|
||||
flush()
|
||||
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import")))
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "import java")))
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "import java"), flush = false)
|
||||
settle()
|
||||
flush()
|
||||
|
||||
assertModel(
|
||||
"""
|
||||
assistant#msg1
|
||||
text#prt1:
|
||||
import java
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test multi round snapshot delta interleave stays single copy`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE)
|
||||
flush()
|
||||
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport")))
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "import"))
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport java")))
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", " java"))
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport java.util.List;\n")))
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", ".util.List;\n"))
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "```java\nimport java.util.List;\n\npublic class StreamBasics {\n}\n```")))
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "\npublic class StreamBasics {\n}\n```"))
|
||||
|
||||
assertModel(
|
||||
"""
|
||||
assistant#msg1
|
||||
text#prt1:
|
||||
```java
|
||||
import java.util.List;
|
||||
|
||||
public class StreamBasics {
|
||||
}
|
||||
```
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test per token snapshot plus delta interleave does not double text or code`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE)
|
||||
flush()
|
||||
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
|
||||
// Reproduces the streamed screenshot: for every token the backend sends a full
|
||||
// PartUpdated snapshot AND a matching incremental PartDelta. The old dedup doubled
|
||||
// each token ("ReadRead", "inputinput.txt.txt"); glue must keep a single copy.
|
||||
val tokens = listOf(
|
||||
"**Python**", "\n\n", "Read", " a", " file", " line", " by", " line", ",",
|
||||
" which", " is", " stream-like", " because", " it", " avoids", " loading",
|
||||
" the", " whole", " file", " into", " memory", ":", "\n\n",
|
||||
"```python\n", "with", " open", "(\"input.txt\",", " \"r\")", " as", " file:", "\n",
|
||||
" for", " line", " in", " file:", "\n", " print", "(line.strip())", "\n",
|
||||
"```",
|
||||
)
|
||||
val sb = StringBuilder()
|
||||
for (token in tokens) {
|
||||
sb.append(token)
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = sb.toString())))
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", token))
|
||||
}
|
||||
settle()
|
||||
flush()
|
||||
|
||||
val text = (m.model.message("msg1")!!.parts["prt1"] as ai.kilocode.client.session.model.Text).content.toString()
|
||||
assertEquals(sb.toString(), text)
|
||||
}
|
||||
|
||||
fun `test visible controller flushes after cadence`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
@@ -346,15 +478,15 @@ class SessionUpdateQueueTest : SessionControllerTestBase() {
|
||||
assertEquals(SessionState.Idle, m.model.state)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun `test condensed and raw controller end with same final state on large corpus`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
|
||||
val events = corpus()
|
||||
val condensed = runCorpus(events, true)
|
||||
val raw = runCorpus(events, false)
|
||||
val a = snapshot(condensed)
|
||||
val b = snapshot(raw)
|
||||
val a = snapshot(runCorpus(events, true))
|
||||
rpc.events.resetReplayCache()
|
||||
val b = snapshot(runCorpus(events, false))
|
||||
|
||||
if (a != b) fail("condensed=\n$a\nraw=\n$b")
|
||||
assertEquals(SessionState.Idle, a.state)
|
||||
@@ -362,7 +494,7 @@ class SessionUpdateQueueTest : SessionControllerTestBase() {
|
||||
assertTrue(a.body.contains("assistant#msg2"))
|
||||
assertTrue(a.body.contains("diff: src/A.kt src/B.kt"))
|
||||
assertTrue(a.body.contains("todo: [completed] ship feature"))
|
||||
assertEquals(4, a.compacted)
|
||||
assertEquals(2, a.compacted)
|
||||
}
|
||||
|
||||
fun `test update hooks run on EDT around queued model batch`() {
|
||||
|
||||
+26
@@ -26,6 +26,7 @@ import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import java.awt.Container
|
||||
import javax.swing.JPanel
|
||||
|
||||
/**
|
||||
* Tests for [SessionMessageListPanel] — structural and index integrity.
|
||||
@@ -186,6 +187,31 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
assertEquals("hello world", tv.markdown())
|
||||
}
|
||||
|
||||
fun `test ContentDelta preserves TextView and markdown component`() {
|
||||
model.upsertMessage(msg("a1", "assistant"))
|
||||
model.updateContent("a1", part("p1", "a1", "text", text = "first\n\nsecond"))
|
||||
val mv = panel.findMessage("a1")!!
|
||||
val tv = mv.part("p1") as TextView
|
||||
val comp = tv.md.component
|
||||
val first = (comp as JPanel).components.first()
|
||||
|
||||
model.appendDelta("a1", "p1", " more")
|
||||
|
||||
assertSame(tv, mv.part("p1"))
|
||||
assertSame(comp, tv.md.component)
|
||||
assertSame(first, comp.components.first())
|
||||
assertEquals("first\n\nsecond more", tv.markdown())
|
||||
}
|
||||
|
||||
fun `test created ContentDelta is not double applied`() {
|
||||
model.upsertMessage(msg("a1", "assistant"))
|
||||
|
||||
model.appendDelta("a1", "p1", "hello")
|
||||
|
||||
val tv = panel.findMessage("a1")!!.part("p1") as TextView
|
||||
assertEquals("hello", tv.markdown())
|
||||
}
|
||||
|
||||
fun `test ContentRemoved removes PartView from MessageView`() {
|
||||
model.upsertMessage(msg("a1", "assistant"))
|
||||
model.updateContent("a1", part("p1", "a1", "text", text = "x"))
|
||||
|
||||
+143
@@ -9,6 +9,7 @@ import com.intellij.openapi.fileTypes.UnknownFileType
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.components.JBHtmlPane
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.BorderLayout
|
||||
@@ -128,6 +129,146 @@ class MdViewHybridTest : BasePlatformTestCase() {
|
||||
assertTrue("streamed editor should not be clipped vertically", editor.height >= editor.preferredSize.height)
|
||||
}
|
||||
|
||||
fun `test appending later html block preserves earlier block component`() {
|
||||
view.set("first\n\nsecond")
|
||||
val first = htmls().first()
|
||||
|
||||
view.append(" more")
|
||||
|
||||
assertSame(first, htmls().first())
|
||||
assertTrue(view.html().contains("second more"))
|
||||
}
|
||||
|
||||
fun `test streaming fenced code block preserves editor component`() {
|
||||
view.append("```java\nclass Example {\n")
|
||||
val pane = scrolls().single()
|
||||
val editor = editors().single()
|
||||
val initial = pane.preferredSize.height
|
||||
|
||||
view.append("void method() {}\n}\n```")
|
||||
|
||||
assertSame(pane, scrolls().single())
|
||||
assertSame(editor, editors().single())
|
||||
assertEquals("class Example {\nvoid method() {}\n}", editor.text)
|
||||
assertTrue(scrolls().single().preferredSize.height >= initial)
|
||||
}
|
||||
|
||||
fun `test streaming code body append reuses editor and stays correct`() {
|
||||
view.append("```java\nclass A {\n")
|
||||
val pane = scrolls().single()
|
||||
val editor = editors().single()
|
||||
|
||||
view.append(" void run() {\n")
|
||||
view.append(" }\n")
|
||||
|
||||
assertSame(pane, scrolls().single())
|
||||
assertSame(editor, editors().single())
|
||||
assertEquals("class A {\n void run() {\n }", editor.text)
|
||||
assertEquals("```java\nclass A {\n void run() {\n }\n", view.markdown())
|
||||
assertTrue(view.html().contains("class A"))
|
||||
|
||||
view.append("}\n```")
|
||||
|
||||
assertSame(pane, scrolls().single())
|
||||
assertSame(editor, editors().single())
|
||||
assertEquals("class A {\n void run() {\n }\n}", editor.text)
|
||||
assertEquals("```java\nclass A {\n void run() {\n }\n}\n```", view.markdown())
|
||||
}
|
||||
|
||||
fun `test streaming code body append keeps html in sync`() {
|
||||
view.append("```java\n")
|
||||
view.append("if (a < b) {\n")
|
||||
|
||||
assertTrue(view.html().contains("a < b"))
|
||||
assertEquals("if (a < b) {", editors().single().text)
|
||||
}
|
||||
|
||||
fun `test streaming partial fence opener renders code block without raw marker`() {
|
||||
view.append("`")
|
||||
|
||||
val pane = scrolls().single()
|
||||
val editor = editors().single()
|
||||
|
||||
assertEquals("", editor.text)
|
||||
assertFalse(view.html().contains("`"))
|
||||
|
||||
view.append("`")
|
||||
|
||||
assertSame(pane, scrolls().single())
|
||||
assertSame(editor, editors().single())
|
||||
assertEquals("", editor.text)
|
||||
assertFalse(view.html().contains("``"))
|
||||
}
|
||||
|
||||
fun `test streaming language prefix stays out of code text`() {
|
||||
view.append("```")
|
||||
view.append("p")
|
||||
view.append("ython\nprint(1)\n")
|
||||
|
||||
assertEquals("print(1)", editors().single().text)
|
||||
assertFalse(view.html().contains("```"))
|
||||
assertFalse(view.html().contains("python"))
|
||||
}
|
||||
|
||||
fun `test streaming partial closing fence stays out of code text`() {
|
||||
view.append("```python\nprint(1)\n")
|
||||
val pane = scrolls().single()
|
||||
val editor = editors().single()
|
||||
|
||||
view.append("`")
|
||||
|
||||
assertSame(pane, scrolls().single())
|
||||
assertSame(editor, editors().single())
|
||||
assertEquals("print(1)", editor.text)
|
||||
|
||||
view.append("`")
|
||||
|
||||
assertSame(pane, scrolls().single())
|
||||
assertSame(editor, editors().single())
|
||||
assertEquals("print(1)", editor.text)
|
||||
}
|
||||
|
||||
fun `test completed closing fence preserves code block and renders following markdown`() {
|
||||
view.append("```python\nprint(1)\n``")
|
||||
val pane = scrolls().single()
|
||||
val editor = editors().single()
|
||||
|
||||
view.append("`\n\nafter")
|
||||
|
||||
assertSame(pane, scrolls().single())
|
||||
assertSame(editor, editors().single())
|
||||
assertEquals("print(1)", editor.text)
|
||||
assertEquals(1, scrolls().size)
|
||||
assertEquals(1, htmls().size)
|
||||
assertTrue(view.html().contains("after"))
|
||||
}
|
||||
|
||||
fun `test appending new block preserves existing code block component`() {
|
||||
view.set("```kotlin\nval value = 1\n```")
|
||||
val pane = scrolls().single()
|
||||
val editor = editors().single()
|
||||
|
||||
view.append("\n\nhello")
|
||||
|
||||
assertSame(pane, scrolls().single())
|
||||
assertSame(editor, editors().single())
|
||||
assertEquals(1, scrolls().size)
|
||||
assertEquals(1, htmls().size)
|
||||
}
|
||||
|
||||
fun `test incompatible suffix replacement preserves prefix block`() {
|
||||
view.set("before\n\n```kotlin\nval value = 1\n```")
|
||||
val prefix = htmls().single()
|
||||
val editor = editors().single().getEditor(true)!!
|
||||
|
||||
view.set("before\n\nafter")
|
||||
drainEdt()
|
||||
|
||||
assertSame(prefix, htmls().first())
|
||||
assertTrue(editor.isDisposed)
|
||||
assertTrue(scrolls().isEmpty())
|
||||
}
|
||||
|
||||
fun `test java code block with blank lines fits vertically`() {
|
||||
val code = """
|
||||
import java.util.List;
|
||||
@@ -277,6 +418,8 @@ class MdViewHybridTest : BasePlatformTestCase() {
|
||||
|
||||
private fun scrolls(): List<JBScrollPane> = (view.component as JPanel).components.filterIsInstance<JBScrollPane>()
|
||||
|
||||
private fun htmls(): List<JBHtmlPane> = (view.component as JPanel).components.filterIsInstance<JBHtmlPane>()
|
||||
|
||||
private fun editors(): List<EditorTextField> = scrolls().mapNotNull { it.viewport.view as? EditorTextField }
|
||||
|
||||
private fun type(ext: String): FileType {
|
||||
|
||||
Reference in New Issue
Block a user