From 24edd5941ad383c1bf9794e6edf68af677587828 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:36:43 +0200 Subject: [PATCH] gui: syntax-highlight fenced code/diff blocks at stream end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fence state machine (NoFence → InFence) to ChatBlockModel - On closing ```, render via KSyntaxHighlighting or inline diff colorizer - Store rendered HTML in ChatBlock.renderedHtml, emit dataChanged on RenderedHtmlRole - QML shows RichText Label when renderedHtml is populated, falls back to PlainText - Add KF6::SyntaxHighlighting dependency for ollie-gui target --- CMakeLists.txt | 2 + gui/chatblockmodel.cpp | 165 ++++++++++++++++++++++++++++++++++++++++- gui/chatblockmodel.h | 13 ++++ gui/main.qml | 67 +++++++++++------ 4 files changed, 219 insertions(+), 28 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index fdd7455..c720f28 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,6 +132,7 @@ else() endif() # --- GUI App (9P-based, no D-Bus) --- + find_package(KF6SyntaxHighlighting REQUIRED) add_executable(ollie-gui gui/main.cpp gui/ollie9pclient.cpp @@ -147,6 +148,7 @@ else() Qt6::Quick Qt6::QuickControls2 KF6::ConfigCore + KF6::SyntaxHighlighting ) qt_add_resources(ollie-gui "gui" PREFIX "/" diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index b581365..53a0bb5 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -1,6 +1,47 @@ #include "chatblockmodel.h" +#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; +}; + +// --- Block Parsing --- const QRegularExpression ChatBlockModel::s_headerRe( R"(^\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\]$)"); @@ -39,6 +80,8 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const return b.name; case ContentRole: return b.content; + case RenderedHtmlRole: + return b.renderedHtml; } return {}; } @@ -49,6 +92,7 @@ QHash ChatBlockModel::roleNames() const {BlockTypeRole, "blockType"}, {BlockNameRole, "blockName"}, {ContentRole, "blockContent"}, + {RenderedHtmlRole, "blockRenderedHtml"}, }; } @@ -69,6 +113,10 @@ void ChatBlockModel::reset() beginResetModel(); m_blocks.clear(); m_lineBuffer.clear(); + // Flush any unclosed fence buffer as plain prose + m_fenceState = NoFence; + m_fenceLang.clear(); + m_fenceBuffer.clear(); endResetModel(); emit countChanged(); } @@ -84,11 +132,120 @@ void ChatBlockModel::trimFront(int keepCount) emit countChanged(); } +QString ChatBlockModel::escapeHtml(const QString &text) 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).replace('\n', QStringLiteral("
")) + 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 += QStringLiteral("
"); + } + + 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("
"); + } else if (line.startsWith('-')) { + html += QStringLiteral("") + escapeHtml(line) + QStringLiteral("
"); + } else if (line.startsWith("@@")) { + html += QStringLiteral("") + escapeHtml(line) + QStringLiteral("
"); + } else { + html += escapeHtml(line) + QStringLiteral("
"); + } + } + html += QStringLiteral("
"); + return html; +} + void ChatBlockModel::processLine(const QString &line) { + // --- Fence state machine --- + if (m_fenceState == InFence) { + // Check for closing fence: ^```\s*$ + if (line.trimmed() == "```") { + // Close fence — render and finalize block + QString rendered; + if (m_fenceLang == "diff" || (m_fenceLang.isEmpty() && + (m_fenceBuffer.startsWith("--- ") || m_fenceBuffer.startsWith("diff --git")))) { + rendered = renderDiff(m_fenceBuffer); + } else { + rendered = highlightCode(m_fenceBuffer, m_fenceLang); + } + + if (!m_blocks.isEmpty()) { + ChatBlock &last = m_blocks.last(); + last.renderedHtml = rendered; + QModelIndex idx = index(m_blocks.size() - 1); + emit dataChanged(idx, idx, {RenderedHtmlRole}); + } + + m_fenceState = NoFence; + m_fenceLang.clear(); + m_fenceBuffer.clear(); + return; + } + + // Accumulate fenced content + if (!m_fenceBuffer.isEmpty()) + m_fenceBuffer += '\n'; + m_fenceBuffer += line; + return; + } + + // --- NoFence state: look for opening fence or header --- + static const QRegularExpression fenceOpenRe(R"(^```(\w*)[ \t]*$)"); + QRegularExpressionMatch fenceMatch = fenceOpenRe.match(line); + if (fenceMatch.hasMatch()) { + // Open fence — start buffering. Create a placeholder block if needed. + m_fenceState = InFence; + m_fenceLang = fenceMatch.captured(1); + m_fenceBuffer.clear(); + + // Ensure there's a block to attach the rendered result to + if (m_blocks.isEmpty()) { + beginInsertRows(QModelIndex(), 0, 0); + m_blocks.append({ChatBlock::Default, QString(), QString(), QString()}); + endInsertRows(); + emit countChanged(); + } + return; + } + + // Header line QRegularExpressionMatch m = s_headerRe.match(line); if (m.hasMatch()) { - // Header line — finalize current block, start new one QString typeStr = m.captured(1); QString qualifier = m.captured(2); @@ -103,7 +260,7 @@ void ChatBlockModel::processLine(const QString &line) else type = ChatBlock::Default; beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size()); - m_blocks.append({type, qualifier, QString()}); + m_blocks.append({type, qualifier, QString(), QString()}); endInsertRows(); emit countChanged(); return; @@ -113,7 +270,7 @@ void ChatBlockModel::processLine(const QString &line) if (m_blocks.isEmpty()) { // Mid-stream connect: create a Default block beginInsertRows(QModelIndex(), 0, 0); - m_blocks.append({ChatBlock::Default, QString(), QString()}); + m_blocks.append({ChatBlock::Default, QString(), QString(), QString()}); endInsertRows(); emit countChanged(); } @@ -125,4 +282,4 @@ void ChatBlockModel::processLine(const QString &line) QModelIndex idx = index(m_blocks.size() - 1); emit dataChanged(idx, idx, {ContentRole}); -} \ No newline at end of file +} diff --git a/gui/chatblockmodel.h b/gui/chatblockmodel.h index 0c4ace0..5324422 100644 --- a/gui/chatblockmodel.h +++ b/gui/chatblockmodel.h @@ -21,6 +21,7 @@ struct ChatBlock { Type type = Default; QString name; QString content; + QString renderedHtml; // non-empty when block contains rendered fenced code/diff }; class ChatBlockModel : public QAbstractListModel @@ -33,6 +34,7 @@ public: BlockTypeRole = Qt::UserRole + 1, BlockNameRole, ContentRole, + RenderedHtmlRole, }; explicit ChatBlockModel(QObject *parent = nullptr); @@ -51,6 +53,17 @@ signals: private: void processLine(const QString &line); + // Fence state machine + enum FenceState { NoFence, InFence }; + FenceState m_fenceState = NoFence; + QString m_fenceLang; + QString m_fenceBuffer; + + // 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; + QList m_blocks; QString m_lineBuffer; diff --git a/gui/main.qml b/gui/main.qml index cbc2164..433ed2a 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -401,19 +401,37 @@ ApplicationWindow { } } - // Content - Label { - Layout.fillWidth: true - Layout.leftMargin: 4 - Layout.rightMargin: 4 - Layout.bottomMargin: 4 - text: blockContent || "" - wrapMode: Text.Wrap - textFormat: Text.PlainText - font.family: theme.fontFamily || "monospace" - font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 - color: theme.textColor - } + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + Layout.bottomMargin: 4 + spacing: 0 + + // Rendered HTML (fenced code/diff) — shown when available + Label { + Layout.fillWidth: true + visible: blockRenderedHtml && blockRenderedHtml.length > 0 + text: blockRenderedHtml + textFormat: Text.RichText + wrapMode: Text.WordWrap + font.family: theme.fontFamily || "monospace" + font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 + color: theme.textColor + } + + // Plain text fallback (prose, streaming content) + Label { + Layout.fillWidth: true + visible: (!blockRenderedHtml || blockRenderedHtml.length === 0) + text: blockContent || "" + wrapMode: Text.Wrap + textFormat: Text.PlainText + font.family: theme.fontFamily || "monospace" + font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 + color: theme.textColor + } + } } } } @@ -426,24 +444,25 @@ ApplicationWindow { Layout.margins: 6 spacing: 4 - TextField { + TextArea { id: promptInput Layout.fillWidth: true + Layout.preferredHeight: font.pointSize * 5 + 16 placeholderText: ollie.activeSessionId ? "Message..." : "No session" enabled: ollie.activeSessionId !== "" - onAccepted: { - if (text.trim() !== "") { - ollie.submit(text.trim()) - text = "" + wrapMode: TextEdit.Wrap + Keys.onReturnPressed: { + if (event.modifiers & Qt.ShiftModifier) { + event.accepted = false + } else { + if (text.trim() !== "") { + ollie.submit(text.trim()) + text = "" + } + event.accepted = true } } } - - Button { - text: "Send" - enabled: promptInput.text.trim() !== "" && ollie.activeSessionId !== "" - onClicked: promptInput.accepted() - } } } }