diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index 360b66c..957a0df 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -1,45 +1,8 @@ #include "chatblockmodel.h" -#include -#include +#include #include -#include -#include -#include -#include -#include -#include -// --- Syntax Highlighting (copied from Kate plugin ChatRenderer) --- - -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; -}; // --- Static helpers --- @@ -56,23 +19,14 @@ static ChatBlock::Type blockTypeFromString(const QString &typeStr) return ChatBlock::Default; } -// Fence opening pattern reused in multiple states -static const QRegularExpression s_fenceOpenRe(R"(^```(\w*)[ \t]*$)"); - // --- Block Parsing --- const QRegularExpression ChatBlockModel::s_headerRe( - R"(^\[\[\[(user|assistant|reasoning|call|tool|error|info|retry)(?::([^\]]*))?\]\]\]$)"); + R"(^\[\[\[([^\]]+)\]\]\]$)"); const QRegularExpression ChatBlockModel::s_endRe( R"(^\[\[\[end\]\]\]$)"); -const QRegularExpression ChatBlockModel::s_interruptOpenRe( - R"(^\s*$)"); - -const QRegularExpression ChatBlockModel::s_interruptCloseRe( - R"(^\s*$)"); - ChatBlockModel::ChatBlockModel(QObject *parent) : QAbstractListModel(parent) { @@ -142,10 +96,7 @@ void ChatBlockModel::reset() beginResetModel(); m_blocks.clear(); m_lineBuffer.clear(); - m_state = State::Outside; - m_wasInBlock = false; - m_fenceLang.clear(); - m_fenceBuffer.clear(); + m_state = State::BlockStart; endResetModel(); emit countChanged(); } @@ -161,76 +112,24 @@ void ChatBlockModel::trimFront(int keepCount) emit countChanged(); } -QString ChatBlockModel::escapeHtml(const QString &text) const +QString ChatBlockModel::renderMarkdown(const QString &markdown) const { - return text.toHtmlEscaped(); -} - -QString ChatBlockModel::highlightCode(const QString &code, const QString &language) const -{ - KSyntaxHighlighting::Repository *repo = new KSyntaxHighlighting::Repository(); - KSyntaxHighlighting::Definition def = repo->definitionForName(language); - if (!def.isValid()) - def = repo->definitionForFileName("file." + language); - - bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5; - KSyntaxHighlighting::Theme theme = repo->defaultTheme(dark - ? KSyntaxHighlighting::Repository::DarkTheme - : KSyntaxHighlighting::Repository::LightTheme); - - if (!def.isValid()) { - delete repo; - return QStringLiteral("
") + escapeHtml(code) + QStringLiteral("
"); - } - - 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'; - } - - delete repo; - return QStringLiteral("
") + builder.html + QStringLiteral("
"); -} - -QString ChatBlockModel::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; + QTextDocument document; + document.setMarkdown(markdown); + return document.toHtml(); } void ChatBlockModel::appendContent(const QString &line) { + if (m_blocks.isEmpty()) + return; + ChatBlock &last = m_blocks.last(); if (!last.content.isEmpty()) last.content += '\n'; last.content += line; - // Keep renderedHtml in sync if a fence was rendered earlier - // in this block, so post-fence content isn't invisible. - if (!last.renderedHtml.isEmpty()) { - if (!last.renderedHtml.endsWith('\n')) - last.renderedHtml += '\n'; - last.renderedHtml += line.toHtmlEscaped(); - } + last.renderedHtml = renderMarkdown(last.content); + QModelIndex idx = index(m_blocks.size() - 1); emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole}); } @@ -238,136 +137,28 @@ void ChatBlockModel::appendContent(const QString &line) void ChatBlockModel::processLine(const QString &line) { switch (m_state) { - - // ────── State: InFence ────────────────────────────────────────── - case State::InFence: { - // Closing fence? - if (line.trimmed() == "```") { - QString rendered; - bool isDiff = (m_fenceLang == "diff") || - (m_fenceLang.isEmpty() && - (m_fenceBuffer.startsWith("--- ") || - m_fenceBuffer.startsWith("diff --git"))); - if (isDiff) - rendered = renderDiff(m_fenceBuffer); - else - rendered = highlightCode(m_fenceBuffer, m_fenceLang); - - if (!m_blocks.isEmpty()) { - ChatBlock &last = m_blocks.last(); - // Prepend any content that came before the fence so it - // isn't lost — the view uses renderedHtml exclusively - // when non-empty. - if (!last.content.isEmpty()) - last.renderedHtml = escapeHtml(last.content) + '\n' + rendered; - else - last.renderedHtml = rendered; - QModelIndex idx = index(m_blocks.size() - 1); - emit dataChanged(idx, idx, {RenderedHtmlRole}); - } - - m_fenceLang.clear(); - m_fenceBuffer.clear(); - // Return to the context we were in before the fence opened - m_state = m_wasInBlock ? State::InBlock : State::Outside; - return; + case State::BlockStart: { + // [[[blockname]]] is the pre-transition header trigger. + QRegularExpressionMatch match = s_headerRe.match(line); + if (match.hasMatch()) { + const QString blockName = match.captured(1); + beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size()); + m_blocks.append({blockTypeFromString(blockName.section(':', 0, 0)), + blockName.section(':', 1), QString(), QString()}); + endInsertRows(); + emit countChanged(); + m_state = State::BlockEnd; } - - // Content line — accumulate fence buffer - if (!m_fenceBuffer.isEmpty()) - m_fenceBuffer += '\n'; - m_fenceBuffer += line; return; } - // ────── State: Outside ────────────────────────────────────────── - case State::Outside: { - // Stray end markers are no-ops (partial stream connections) - if (s_endRe.match(line).hasMatch()) return; - if (s_interruptCloseRe.match(line).hasMatch()) return; - - // Opening ``` outside a block - QRegularExpressionMatch fenceMatch = s_fenceOpenRe.match(line); - if (fenceMatch.hasMatch()) { - m_fenceLang = fenceMatch.captured(1); - m_fenceBuffer.clear(); - m_wasInBlock = false; - m_state = State::InFence; - - // Ensure a block exists to attach rendered output to - if (m_blocks.isEmpty()) { - beginInsertRows(QModelIndex(), 0, 0); - m_blocks.append({ChatBlock::Default, QString(), QString(), QString()}); - endInsertRows(); - emit countChanged(); - } - return; - } - - // Opening interrupt - if (s_interruptOpenRe.match(line).hasMatch()) { - beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size()); - m_blocks.append({ChatBlock::Interrupt, QString(), QString(), QString()}); - endInsertRows(); - emit countChanged(); - m_state = State::InBlock; - return; - } - - // [[[type]]] header line - QRegularExpressionMatch m = s_headerRe.match(line); - if (m.hasMatch()) { - ChatBlock::Type type = blockTypeFromString(m.captured(1)); - beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size()); - m_blocks.append({type, m.captured(2), QString(), QString()}); - endInsertRows(); - emit countChanged(); - m_state = State::InBlock; - return; - } - - // Content line outside a block — drop unless mid-stream connect - if (m_blocks.isEmpty()) { - // Mid-stream connect: first content auto-creates a Default block - beginInsertRows(QModelIndex(), 0, 0); - m_blocks.append({ChatBlock::Default, QString(), QString(), QString()}); - endInsertRows(); - emit countChanged(); - m_state = State::InBlock; - appendContent(line); - return; - } - // Stray content between blocks is ignored - return; - } - - // ────── State: InBlock ────────────────────────────────────────── - case State::InBlock: { - // [[[end]]] closes the block + case State::BlockEnd: + // [[[end]]] is the post-transition trigger; all other lines are Markdown. if (s_endRe.match(line).hasMatch()) { - m_state = State::Outside; + m_state = State::BlockStart; return; } - - // closes block (regardless of actual type) - if (s_interruptCloseRe.match(line).hasMatch()) { - m_state = State::Outside; - return; - } - - // Opening ``` inside a block → enter fence, preserve return context - QRegularExpressionMatch fenceMatch = s_fenceOpenRe.match(line); - if (fenceMatch.hasMatch()) { - m_fenceLang = fenceMatch.captured(1); - m_fenceBuffer.clear(); - m_wasInBlock = true; - m_state = State::InFence; - return; - } - - // Content line — append to current block appendContent(line); return; } - } } diff --git a/gui/chatblockmodel.h b/gui/chatblockmodel.h index 5114152..1c4dbfe 100644 --- a/gui/chatblockmodel.h +++ b/gui/chatblockmodel.h @@ -23,7 +23,7 @@ struct ChatBlock { Type type = Default; QString name; QString content; - QString renderedHtml; // non-empty when block contains rendered fenced code/diff + QString renderedHtml; // Markdown-rendered block content }; class ChatBlockModel : public QAbstractListModel @@ -32,11 +32,10 @@ class ChatBlockModel : public QAbstractListModel Q_PROPERTY(int count READ rowCount NOTIFY countChanged) public: - // Combined parser FSM — three states, fence is a sub-state of the block context + // Two-state parser for [[[blockname]]] ... [[[end]]] blocks. enum class State { - Outside, // No block, no fence — scanning for headers - InBlock, // Inside [[[type]]]...[[[end]]], accepting content - InFence, // Inside ```...```, accumulating for render + BlockStart, // Waiting for a block header + BlockEnd // Collecting Markdown content until [[[end]]] }; enum Roles { @@ -63,29 +62,15 @@ private: void processLine(const QString &line); void appendContent(const QString &line); - // Syntax highlighting (shared with Kate plugin pattern) - QString highlightCode(const QString &code, const QString &language) const; - QString renderDiff(const QString &diff) const; - QString escapeHtml(const QString &text) const; + QString renderMarkdown(const QString &markdown) const; - // Combined parser state - State m_state = State::Outside; - - // Return-context flag for InFence: were we inside a block before the fence - // opened? Preserved across fence boundaries. Only meaningful when InFence. - bool m_wasInBlock = false; - - // Fence parameters (only meaningful in InFence state) - QString m_fenceLang; - QString m_fenceBuffer; + State m_state = State::BlockStart; QList m_blocks; QString m_lineBuffer; static const QRegularExpression s_headerRe; static const QRegularExpression s_endRe; - static const QRegularExpression s_interruptOpenRe; - static const QRegularExpression s_interruptCloseRe; }; #endif // CHATBLOCKMODEL_H \ No newline at end of file diff --git a/gui/main.qml b/gui/main.qml index 9806a8d..861e59e 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -869,27 +869,15 @@ property bool blockExpanded: { } } - // Rendered HTML (fenced code/diff) + // Block content is Markdown rendered by ChatBlockModel. TextEdit { Layout.fillWidth: true - visible: blockItem.blockExpanded && blockRenderedHtml && blockRenderedHtml.length > 0 - text: blockRenderedHtml + visible: blockItem.blockExpanded + text: blockRenderedHtml || "" textFormat: Text.RichText readOnly: true wrapMode: TextEdit.Wrap - font.family: theme.fontFamily || "monospace" - font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 - color: theme.textColor - } - - // Plain text fallback - Label { - Layout.fillWidth: true - visible: blockItem.blockExpanded && (!blockRenderedHtml || blockRenderedHtml.length === 0) - text: blockContent || "" - wrapMode: Text.Wrap - textFormat: Text.PlainText - font.family: theme.fontFamily || "monospace" + font.family: theme.fontFamily || Qt.application.font.family font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 color: theme.textColor }