gui: syntax-highlight fenced code/diff blocks at stream end

- 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
This commit is contained in:
Levi Neely 2026-08-01 11:36:43 +02:00
parent 1aa1455f33
commit 24edd5941a
4 changed files with 219 additions and 28 deletions

View File

@ -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 "/"

View File

@ -1,6 +1,47 @@
#include "chatblockmodel.h"
#include <QApplication>
#include <QPalette>
#include <QRegularExpressionMatch>
#include <KSyntaxHighlighting/Repository>
#include <KSyntaxHighlighting/Definition>
#include <KSyntaxHighlighting/Theme>
#include <KSyntaxHighlighting/AbstractHighlighter>
#include <KSyntaxHighlighting/Format>
#include <KSyntaxHighlighting/State>
// --- 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("<span style=\"color:") + fg.name() + QStringLiteral("\">") + text + QStringLiteral("</span>");
} 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<int, QByteArray> 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("<div>") + escapeHtml(code).replace('\n', QStringLiteral("<br>")) + QStringLiteral("</div>");
}
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("<br>");
}
delete repo;
return QStringLiteral("<div>") + builder.html + QStringLiteral("</div>");
}
QString ChatBlockModel::renderDiff(const QString &diff) const
{
QString html = QStringLiteral("<div>");
const QStringList lines = diff.split('\n');
for (const QString &line : lines) {
if (line.startsWith('+')) {
html += QStringLiteral("<span style=\"color:#4caf50;\">") + escapeHtml(line) + QStringLiteral("</span><br>");
} else if (line.startsWith('-')) {
html += QStringLiteral("<span style=\"color:#f44336;\">") + escapeHtml(line) + QStringLiteral("</span><br>");
} else if (line.startsWith("@@")) {
html += QStringLiteral("<span style=\"color:#2196f3;\">") + escapeHtml(line) + QStringLiteral("</span><br>");
} else {
html += escapeHtml(line) + QStringLiteral("<br>");
}
}
html += QStringLiteral("</div>");
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});
}
}

View File

@ -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<ChatBlock> m_blocks;
QString m_lineBuffer;

View File

@ -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()
}
}
}
}