kate: windowed chat rendering for long conversations

Only render the last 40 blocks eagerly. Earlier messages are
lazy-loaded in chunks of 20 as the user scrolls up. This fixes
the progressive slowdown that occurred during long sessions where
the full HTML re-render on every ChatUpdated signal became O(n)
on the entire conversation history.
This commit is contained in:
Levi Neely 2026-07-24 14:45:19 +02:00
parent 5b005c4126
commit fad8baaa1b
4 changed files with 102 additions and 6 deletions

View File

@ -589,11 +589,25 @@ void ChatRenderer::render(QTextEdit *view, const QString &rawText)
if (m_applyStorage)
m_applyStorage->clear();
QList<ChatBlock> blocks = parseBlocks(rawText);
m_allBlocks = parseBlocks(rawText);
// Only render the tail window
m_renderStart = qMax(0, m_allBlocks.size() - m_windowSize);
QString html = QStringLiteral(
"<html><body style=\"font-family:sans-serif;font-size:medium;\">");
for (const auto &block : blocks)
html += blockToHtml(block);
// Indicate truncation if there are older blocks
if (m_renderStart > 0) {
html += QStringLiteral(
"<div id=\"ollie-load-more\" style=\"text-align:center;padding:8px;"
"color:gray;font-size:small;font-style:italic;\">↑ "
"Scroll up to load earlier messages (%1 more) ↑</div>")
.arg(m_renderStart);
}
for (int i = m_renderStart; i < m_allBlocks.size(); ++i)
html += blockToHtml(m_allBlocks[i]);
html += QStringLiteral("</body></html>");
auto *sb = view->verticalScrollBar();
@ -619,7 +633,7 @@ void ChatRenderer::render(QTextEdit *view, const QString &rawText)
sb->blockSignals(false);
m_lastRawText = rawText;
m_lastBlockCount = blocks.size();
m_lastBlockCount = m_allBlocks.size();
}
bool ChatRenderer::renderIncremental(QTextEdit *view, const QString &rawText)
@ -636,3 +650,49 @@ bool ChatRenderer::renderIncremental(QTextEdit *view, const QString &rawText)
render(view, rawText);
return true;
}
int ChatRenderer::prependOlder(QTextEdit *view, int count)
{
if (m_renderStart <= 0)
return 0;
detectTheme();
int loadFrom = qMax(0, m_renderStart - count);
int loaded = m_renderStart - loadFrom;
if (loaded <= 0)
return 0;
m_renderStart = loadFrom;
// Re-render all currently-visible blocks (now including the older ones)
auto *sb = view->verticalScrollBar();
int oldMax = sb->maximum();
int oldVal = sb->value();
sb->blockSignals(true);
QString html = QStringLiteral(
"<html><body style=\"font-family:sans-serif;font-size:medium;\">");
if (m_renderStart > 0) {
html += QStringLiteral(
"<div id=\"ollie-load-more\" style=\"text-align:center;padding:8px;"
"color:gray;font-size:small;font-style:italic;\">↑ "
"Scroll up to load earlier messages (%1 more) ↑</div>")
.arg(m_renderStart);
}
for (int i = m_renderStart; i < m_allBlocks.size(); ++i)
html += blockToHtml(m_allBlocks[i]);
html += QStringLiteral("</body></html>");
view->setHtml(html);
// Restore scroll position offset by the added content height
int newMax = sb->maximum();
int addedHeight = newMax - oldMax;
sb->setValue(oldVal + addedHeight);
sb->blockSignals(false);
return loaded;
}

View File

@ -41,6 +41,10 @@ struct SubBlock {
/**
* ChatRenderer parses ollie chat output into structured blocks
* and renders them as styled HTML in a QTextEdit.
*
* To avoid performance degradation on long conversations, only a
* tail window of blocks is eagerly rendered. Earlier blocks are
* loaded on demand when the user scrolls up.
*/
class ChatRenderer
{
@ -62,13 +66,19 @@ public:
};
void setApplyStorage(QList<ApplyBlock> *storage) { m_applyStorage = storage; }
/// Maximum number of blocks to render eagerly (tail window).
static constexpr int DefaultWindowSize = 40;
void setWindowSize(int n) { m_windowSize = n; }
int windowSize() const { return m_windowSize; }
/// Parse raw chat text into blocks
QList<ChatBlock> parseBlocks(const QString &text) const;
/// Parse a block's content into sub-blocks (prose, code, diff)
QList<SubBlock> parseSubBlocks(const QString &content) const;
/// Render full chat text into the QTextEdit as styled HTML
/// Render the tail window of blocks into the QTextEdit.
/// Parses all blocks but only generates HTML for the last windowSize().
void render(QTextEdit *view, const QString &rawText);
/// Render incrementally: assumes rawText is the full text and only
@ -76,6 +86,19 @@ public:
/// an incremental update, false if it did a full re-render.
bool renderIncremental(QTextEdit *view, const QString &rawText);
/// Prepend older blocks into the view. Returns the number of
/// blocks actually prepended (0 if all blocks are already shown).
int prependOlder(QTextEdit *view, int count);
/// True if there are older blocks not yet rendered.
bool hasOlderBlocks() const { return m_renderStart > 0; }
/// Total number of parsed blocks.
int totalBlocks() const { return m_allBlocks.size(); }
/// Number of blocks currently rendered.
int renderedBlocks() const { return m_allBlocks.size() - m_renderStart; }
private:
QString blockToHtml(const ChatBlock &block) const;
QString subBlockToHtml(const SubBlock &sub, bool preserveWhitespace = false) const;
@ -90,6 +113,11 @@ private:
QString m_lastRawText; // for incremental rendering
int m_lastBlockCount = 0;
// Windowed rendering state
QList<ChatBlock> m_allBlocks; // all parsed blocks
int m_renderStart = 0; // index of first rendered block
int m_windowSize = DefaultWindowSize;
// Colors (derived from palette)
mutable bool m_dark = false;
void detectTheme() const;

View File

@ -387,7 +387,7 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m
m_chatView->installEventFilter(this); // handle resize in eventFilter
repositionBtn();
// Also show/hide on manual scroll
// Also show/hide on manual scroll + lazy-load older messages
connect(m_chatView->verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int value) {
auto *sb = m_chatView->verticalScrollBar();
if (value >= sb->maximum() - 4) {
@ -395,6 +395,13 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m
} else if (!m_chatView->toPlainText().isEmpty()) {
m_scrollBottomBtn->show();
}
// Lazy-load: when scrolled near the top, prepend older blocks
if (value <= 50 && m_renderer->hasOlderBlocks() && !m_loadingOlder) {
m_loadingOlder = true;
m_renderer->prependOlder(m_chatView, 20);
m_loadingOlder = false;
}
});
// Prompt input

View File

@ -146,6 +146,7 @@ private:
void annotateFileReferences(const QString &chatText);
QList<ChatRenderer::ApplyBlock> m_applyBlocks;
bool m_loadingOlder = false; // guard against recursive scroll-triggered loads
// Inline annotations (MovingRanges highlighting lines the agent references)
QList<KTextEditor::MovingRange *> m_annotations;