#include "chat_renderer.h" #include #include #include #include #include #include #include #include #include #include #include // --- HTML Highlighter --- class HtmlHighlighter : public KSyntaxHighlighting::AbstractHighlighter { public: QString html; using AbstractHighlighter::AbstractHighlighter; KSyntaxHighlighting::State processLine(const QString &line, const KSyntaxHighlighting::State &state) { m_currentLine = line; return highlightLine(line, state); } protected: void applyFormat(int offset, int length, const KSyntaxHighlighting::Format &format) override { QString text = m_currentLine.mid(offset, length).toHtmlEscaped(); if (format.hasTextColor(theme())) { QColor fg = format.textColor(theme()); html += QStringLiteral("") + text + QStringLiteral(""); } else { html += text; } } private: QString m_currentLine; }; // --- ChatRenderer --- ChatRenderer::ChatRenderer() : m_repo(new KSyntaxHighlighting::Repository()) { } ChatRenderer::~ChatRenderer() { delete m_repo; } void ChatRenderer::detectTheme() const { m_dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5; } QString ChatRenderer::escapeHtml(const QString &text) const { return text.toHtmlEscaped(); } // --- Block Parsing --- QList ChatRenderer::parseBlocks(const QString &text) const { QList blocks; if (text.isEmpty()) return blocks; static const QRegularExpression headerRe( R"(^\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\](.*)$)", QRegularExpression::MultilineOption); auto it = headerRe.globalMatch(text); QList> headers; while (it.hasNext()) { auto match = it.next(); headers.append({match.capturedStart(), match}); } if (headers.isEmpty()) { ChatBlock b; b.type = ChatBlock::Assistant; b.content = text; blocks.append(b); return blocks; } for (int i = 0; i < headers.size(); ++i) { const auto &[pos, match] = headers[i]; QString typeStr = match.captured(1); QString name = match.captured(2); QString inlineContent = match.captured(3); int contentStart = match.capturedEnd(); int contentEnd = (i + 1 < headers.size()) ? headers[i + 1].first : text.size(); QString content; if (typeStr == "info" && !inlineContent.trimmed().isEmpty()) { content = inlineContent.trimmed(); } else { content = text.mid(contentStart, contentEnd - contentStart); if (content.startsWith('\n')) content = content.mid(1); while (content.endsWith("\n\n")) content.chop(1); } ChatBlock b; if (typeStr == "user") b.type = ChatBlock::User; else if (typeStr == "assistant") b.type = ChatBlock::Assistant; else if (typeStr == "reasoning") b.type = ChatBlock::Reasoning; else if (typeStr == "call") b.type = ChatBlock::Call; else if (typeStr == "tool") b.type = ChatBlock::Tool; else if (typeStr == "error") b.type = ChatBlock::Error; else if (typeStr == "info") b.type = ChatBlock::Info; b.name = name; b.content = content; blocks.append(b); } return blocks; } QList ChatRenderer::parseSubBlocks(const QString &content) const { QList result; if (content.isEmpty()) return result; static const QRegularExpression interruptRe( R"(\n?(.*?)\n?)", QRegularExpression::DotMatchesEverythingOption); static const QRegularExpression fenceRe( R"(^```(\w*)\s*\n((?:(?!^```).)*?)\n^```\s*$)", QRegularExpression::MultilineOption | QRegularExpression::DotMatchesEverythingOption); // Find all interrupt tags int lastEnd = 0; auto intIt = interruptRe.globalMatch(content); QList> interrupts; while (intIt.hasNext()) { auto match = intIt.next(); interrupts.append({match.capturedStart(), match}); } // Helper: parse a segment for code fences and prose auto parseSegment = [&](const QString &segment) { if (segment.trimmed().isEmpty()) return; int segLastEnd = 0; auto it = fenceRe.globalMatch(segment); while (it.hasNext()) { auto match = it.next(); if (match.capturedStart() > segLastEnd) { QString prose = segment.mid(segLastEnd, match.capturedStart() - segLastEnd).trimmed(); if (!prose.isEmpty()) { SubBlock sb; sb.type = SubBlock::Prose; sb.content = prose; result.append(sb); } } QString lang = match.captured(1); QString code = match.captured(2); SubBlock sb; if (lang == "diff" || (lang.isEmpty() && (code.startsWith("--- ") || code.startsWith("diff --git")))) { sb.type = SubBlock::Diff; } else { sb.type = SubBlock::Code; } sb.language = lang; sb.content = code; result.append(sb); segLastEnd = match.capturedEnd(); } if (segLastEnd < segment.size()) { QString prose = segment.mid(segLastEnd).trimmed(); if (!prose.isEmpty()) { SubBlock sb; sb.type = SubBlock::Prose; sb.content = prose; result.append(sb); } } }; if (interrupts.isEmpty()) { parseSegment(content); } else { for (int i = 0; i < interrupts.size(); ++i) { const auto &[pos, match] = interrupts[i]; if (pos > lastEnd) { parseSegment(content.mid(lastEnd, pos - lastEnd)); } SubBlock sb; sb.type = SubBlock::Interrupt; sb.content = match.captured(1).trimmed(); result.append(sb); lastEnd = match.capturedEnd(); } if (lastEnd < content.size()) { parseSegment(content.mid(lastEnd)); } } if (result.isEmpty()) { SubBlock sb; sb.type = SubBlock::Prose; sb.content = content; result.append(sb); } return result; } // --- HTML Rendering --- QString ChatRenderer::highlightCode(const QString &code, const QString &language) const { auto def = m_repo->definitionForName(language); if (!def.isValid()) def = m_repo->definitionForFileName("file." + language); if (!def.isValid()) return QStringLiteral("
") + escapeHtml(code) + QStringLiteral("
"); auto theme = m_dark ? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme) : m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme); HtmlHighlighter builder; builder.setDefinition(def); builder.setTheme(theme); KSyntaxHighlighting::State state; const QStringList lines = code.split('\n'); for (int i = 0; i < lines.size(); ++i) { state = builder.processLine(lines[i], state); if (i < lines.size() - 1) builder.html += '\n'; } return QStringLiteral("
") + builder.html + QStringLiteral("
"); } QString ChatRenderer::renderDiff(const QString &diff) const { QString html = QStringLiteral("
");
    const QStringList lines = diff.split('\n');
    for (const QString &line : lines) {
        if (line.startsWith('+')) {
            html += QStringLiteral("") + escapeHtml(line) + QStringLiteral("\n");
        } else if (line.startsWith('-')) {
            html += QStringLiteral("") + escapeHtml(line) + QStringLiteral("\n");
        } else if (line.startsWith("@@")) {
            html += QStringLiteral("") + escapeHtml(line) + QStringLiteral("\n");
        } else {
            html += escapeHtml(line) + '\n';
        }
    }
    html += QStringLiteral("
"); return html; } QString ChatRenderer::renderProse(const QString &prose) const { // Simple markdown-ish rendering: bold, italic, inline code, links QString html = escapeHtml(prose); // Inline code: `code` static const QRegularExpression inlineCode("`([^`]+)`"); html.replace(inlineCode, QStringLiteral("\\1")); // Bold: **text** or __text__ static const QRegularExpression bold(R"(\*\*(.+?)\*\*)"); html.replace(bold, QStringLiteral("\\1")); static const QRegularExpression bold2(R"(__(.+?)__)"); html.replace(bold2, QStringLiteral("\\1")); // Italic: *text* or _text_ static const QRegularExpression italic(R"((?\\1")); // Bullet points: lines starting with - or * static const QRegularExpression bullet(R"(^(\s*)[\-\*]\s)", QRegularExpression::MultilineOption); html.replace(bullet, QStringLiteral("\\1• ")); // Newlines to
html.replace('\n', QStringLiteral("
")); return html; } QString ChatRenderer::subBlockToHtml(const SubBlock &sub, bool preserveWhitespace) const { QString codeBg = m_dark ? QStringLiteral("#1e1e2e") : QStringLiteral("#f5f5f5"); QString codeBorder = m_dark ? QStringLiteral("#444") : QStringLiteral("#ddd"); switch (sub.type) { case SubBlock::Code: { QString langLabel; if (!sub.language.isEmpty()) { langLabel = QStringLiteral( "
") + escapeHtml(sub.language) + QStringLiteral("
"); } return QStringLiteral( "" "
") + langLabel + highlightCode(sub.content, sub.language) + QStringLiteral("
"); } case SubBlock::Diff: { // Store diff and render a clickable link to open in Kate's diff viewer if (m_diffStorage) { int idx = m_diffStorage->size(); m_diffStorage->append(sub.content); // Count lines for display int adds = 0, dels = 0; const QStringList lines = sub.content.split('\n'); for (const QString &line : lines) { if (line.startsWith('+') && !line.startsWith("+++")) adds++; else if (line.startsWith('-') && !line.startsWith("---")) dels++; } QString stats; if (adds > 0) stats += QStringLiteral("+%1 ").arg(adds); if (dels > 0) stats += QStringLiteral("-%1").arg(dels); return QStringLiteral( "" "
" "▶ diff ") + stats + QStringLiteral("
" "↗ Open in Diff Viewer" "
"); } // Fallback: render inline return QStringLiteral( "" "
" "
diff
") + renderDiff(sub.content) + QStringLiteral("
"); } case SubBlock::Prose: if (preserveWhitespace) { return QStringLiteral("
") +
                escapeHtml(sub.content) + QStringLiteral("
"); } return QStringLiteral("
") + renderProse(sub.content) + QStringLiteral("
"); case SubBlock::Interrupt: { QString interruptBg = m_dark ? QStringLiteral("#3d2e00") : QStringLiteral("#fff8e1"); QString interruptBorder = QStringLiteral("#ffc107"); return QStringLiteral( "" "
" "
⚠ user interrupt
" "
") + renderProse(sub.content) + QStringLiteral("
"); } } return {}; } QString ChatRenderer::blockToHtml(const ChatBlock &block) const { detectTheme(); QString userBg = m_dark ? QStringLiteral("#1a3a5c") : QStringLiteral("#e3f2fd"); QString assistantBg = m_dark ? QStringLiteral("#2a2a2a") : QStringLiteral("#fafafa"); QString errorBg = m_dark ? QStringLiteral("#3d1515") : QStringLiteral("#ffebee"); QString metaBg = m_dark ? QStringLiteral("#1a1a2e") : QStringLiteral("#f5f5f5"); QString metaColor = m_dark ? QStringLiteral("#888") : QStringLiteral("#666"); QString borderColor = m_dark ? QStringLiteral("#444") : QStringLiteral("#e0e0e0"); switch (block.type) { case ChatBlock::User: { QList subs = parseSubBlocks(block.content); QString inner; for (const auto &sub : subs) inner += subBlockToHtml(sub); return QStringLiteral( "" "" "" "" "
") + inner + QStringLiteral("
You
"); } case ChatBlock::Assistant: { QList subs = parseSubBlocks(block.content); QString inner; QString lastProse; // track preceding prose for file path extraction for (const auto &sub : subs) { if (sub.type == SubBlock::Prose) { lastProse = sub.content; } inner += subBlockToHtml(sub); // Add "Apply" button for code blocks in assistant messages if (sub.type == SubBlock::Code && m_applyStorage && !sub.content.trimmed().isEmpty()) { // Try to extract file path from preceding prose static const QRegularExpression filePathRe( R"((?:`|\b)(/[^\s:`]+\.\w+)(?:`|\b))"); QString filePath; auto fileMatch = filePathRe.match(lastProse); if (fileMatch.hasMatch()) { filePath = fileMatch.captured(1); } int idx = m_applyStorage->size(); ApplyBlock ab; ab.filePath = filePath; ab.code = sub.content; m_applyStorage->append(ab); QString applyLabel = filePath.isEmpty() ? QStringLiteral("✎ Apply") : QStringLiteral("✎ Apply to ") + escapeHtml(filePath.section('/', -1)); inner += QStringLiteral( ""); } } return QStringLiteral( "" "" "" "
") + inner + QStringLiteral("
Ollie
"); } case ChatBlock::Reasoning: { // Show abbreviated with dimmed styling QString abbreviated = block.content.left(200); if (block.content.size() > 200) abbreviated += QStringLiteral("..."); return QStringLiteral( "" "
" "
▸ reasoning
" "
") + escapeHtml(abbreviated) +
            QStringLiteral("
"); } case ChatBlock::Call: { QString label = QStringLiteral("⚡ call"); if (!block.name.isEmpty()) label += QStringLiteral(": ") + escapeHtml(block.name); // Render full call args with sub-block parsing (preserve whitespace) QList subs = parseSubBlocks(block.content); QString inner; for (const auto &sub : subs) inner += subBlockToHtml(sub, true); return QStringLiteral( "" "
" "
") + label + QStringLiteral("
") + inner + QStringLiteral("
"); } case ChatBlock::Tool: { QString label = QStringLiteral("◆ tool"); if (!block.name.isEmpty()) label += QStringLiteral(": ") + escapeHtml(block.name); // Render full tool output with sub-block parsing (preserve whitespace) QList subs = parseSubBlocks(block.content); QString inner; for (const auto &sub : subs) inner += subBlockToHtml(sub, true); return QStringLiteral( "" "
" "
") + label + QStringLiteral("
") + inner + QStringLiteral("
"); } case ChatBlock::Error: { return QStringLiteral( "" "
" "
") +
            escapeHtml(block.content) +
            QStringLiteral("
"); } case ChatBlock::Info: { return QStringLiteral( "
") + escapeHtml(block.content) + QStringLiteral("
"); } } return {}; } // --- Full Render --- void ChatRenderer::render(QTextEdit *view, const QString &rawText) { detectTheme(); // Clear storage before re-render (blocks get re-indexed) if (m_diffStorage) m_diffStorage->clear(); if (m_applyStorage) m_applyStorage->clear(); QList blocks = parseBlocks(rawText); QString html = QStringLiteral( ""); for (const auto &block : blocks) html += blockToHtml(block); html += QStringLiteral(""); auto *sb = view->verticalScrollBar(); bool atBottom = (sb->maximum() <= 0) || (sb->value() >= sb->maximum() - 4); int savedPos = sb->value(); // Block signals to prevent intermediate scroll events during setHtml sb->blockSignals(true); view->setHtml(html); if (atBottom) { // Move cursor to end and ensure visible — this forces layout and // scrolls reliably, unlike sb->setValue(sb->maximum()) which can // read a stale maximum before relayout completes. QTextCursor cursor = view->textCursor(); cursor.movePosition(QTextCursor::End); view->setTextCursor(cursor); view->ensureCursorVisible(); } else { // Restore exact scroll position — clamp to new maximum sb->setValue(qMin(savedPos, sb->maximum())); } sb->blockSignals(false); m_lastRawText = rawText; m_lastBlockCount = blocks.size(); } bool ChatRenderer::renderIncremental(QTextEdit *view, const QString &rawText) { // If text is just appended, try incremental if (!rawText.startsWith(m_lastRawText) || m_lastRawText.isEmpty()) { render(view, rawText); return false; } // Full re-render (the HTML approach doesn't support true incremental well // since setHtml replaces everything). For performance with typical chat // sizes this is fine — QTextEdit handles it efficiently. render(view, rawText); return true; }