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:
parent
1aa1455f33
commit
24edd5941a
|
|
@ -132,6 +132,7 @@ else()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# --- GUI App (9P-based, no D-Bus) ---
|
# --- GUI App (9P-based, no D-Bus) ---
|
||||||
|
find_package(KF6SyntaxHighlighting REQUIRED)
|
||||||
add_executable(ollie-gui
|
add_executable(ollie-gui
|
||||||
gui/main.cpp
|
gui/main.cpp
|
||||||
gui/ollie9pclient.cpp
|
gui/ollie9pclient.cpp
|
||||||
|
|
@ -147,6 +148,7 @@ else()
|
||||||
Qt6::Quick
|
Qt6::Quick
|
||||||
Qt6::QuickControls2
|
Qt6::QuickControls2
|
||||||
KF6::ConfigCore
|
KF6::ConfigCore
|
||||||
|
KF6::SyntaxHighlighting
|
||||||
)
|
)
|
||||||
qt_add_resources(ollie-gui "gui"
|
qt_add_resources(ollie-gui "gui"
|
||||||
PREFIX "/"
|
PREFIX "/"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,47 @@
|
||||||
#include "chatblockmodel.h"
|
#include "chatblockmodel.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QPalette>
|
||||||
#include <QRegularExpressionMatch>
|
#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(
|
const QRegularExpression ChatBlockModel::s_headerRe(
|
||||||
R"(^\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\]$)");
|
R"(^\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\]$)");
|
||||||
|
|
@ -39,6 +80,8 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
|
||||||
return b.name;
|
return b.name;
|
||||||
case ContentRole:
|
case ContentRole:
|
||||||
return b.content;
|
return b.content;
|
||||||
|
case RenderedHtmlRole:
|
||||||
|
return b.renderedHtml;
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +92,7 @@ QHash<int, QByteArray> ChatBlockModel::roleNames() const
|
||||||
{BlockTypeRole, "blockType"},
|
{BlockTypeRole, "blockType"},
|
||||||
{BlockNameRole, "blockName"},
|
{BlockNameRole, "blockName"},
|
||||||
{ContentRole, "blockContent"},
|
{ContentRole, "blockContent"},
|
||||||
|
{RenderedHtmlRole, "blockRenderedHtml"},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +113,10 @@ void ChatBlockModel::reset()
|
||||||
beginResetModel();
|
beginResetModel();
|
||||||
m_blocks.clear();
|
m_blocks.clear();
|
||||||
m_lineBuffer.clear();
|
m_lineBuffer.clear();
|
||||||
|
// Flush any unclosed fence buffer as plain prose
|
||||||
|
m_fenceState = NoFence;
|
||||||
|
m_fenceLang.clear();
|
||||||
|
m_fenceBuffer.clear();
|
||||||
endResetModel();
|
endResetModel();
|
||||||
emit countChanged();
|
emit countChanged();
|
||||||
}
|
}
|
||||||
|
|
@ -84,11 +132,120 @@ void ChatBlockModel::trimFront(int keepCount)
|
||||||
emit countChanged();
|
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)
|
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);
|
QRegularExpressionMatch m = s_headerRe.match(line);
|
||||||
if (m.hasMatch()) {
|
if (m.hasMatch()) {
|
||||||
// Header line — finalize current block, start new one
|
|
||||||
QString typeStr = m.captured(1);
|
QString typeStr = m.captured(1);
|
||||||
QString qualifier = m.captured(2);
|
QString qualifier = m.captured(2);
|
||||||
|
|
||||||
|
|
@ -103,7 +260,7 @@ void ChatBlockModel::processLine(const QString &line)
|
||||||
else type = ChatBlock::Default;
|
else type = ChatBlock::Default;
|
||||||
|
|
||||||
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
|
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
|
||||||
m_blocks.append({type, qualifier, QString()});
|
m_blocks.append({type, qualifier, QString(), QString()});
|
||||||
endInsertRows();
|
endInsertRows();
|
||||||
emit countChanged();
|
emit countChanged();
|
||||||
return;
|
return;
|
||||||
|
|
@ -113,7 +270,7 @@ void ChatBlockModel::processLine(const QString &line)
|
||||||
if (m_blocks.isEmpty()) {
|
if (m_blocks.isEmpty()) {
|
||||||
// Mid-stream connect: create a Default block
|
// Mid-stream connect: create a Default block
|
||||||
beginInsertRows(QModelIndex(), 0, 0);
|
beginInsertRows(QModelIndex(), 0, 0);
|
||||||
m_blocks.append({ChatBlock::Default, QString(), QString()});
|
m_blocks.append({ChatBlock::Default, QString(), QString(), QString()});
|
||||||
endInsertRows();
|
endInsertRows();
|
||||||
emit countChanged();
|
emit countChanged();
|
||||||
}
|
}
|
||||||
|
|
@ -125,4 +282,4 @@ void ChatBlockModel::processLine(const QString &line)
|
||||||
|
|
||||||
QModelIndex idx = index(m_blocks.size() - 1);
|
QModelIndex idx = index(m_blocks.size() - 1);
|
||||||
emit dataChanged(idx, idx, {ContentRole});
|
emit dataChanged(idx, idx, {ContentRole});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ struct ChatBlock {
|
||||||
Type type = Default;
|
Type type = Default;
|
||||||
QString name;
|
QString name;
|
||||||
QString content;
|
QString content;
|
||||||
|
QString renderedHtml; // non-empty when block contains rendered fenced code/diff
|
||||||
};
|
};
|
||||||
|
|
||||||
class ChatBlockModel : public QAbstractListModel
|
class ChatBlockModel : public QAbstractListModel
|
||||||
|
|
@ -33,6 +34,7 @@ public:
|
||||||
BlockTypeRole = Qt::UserRole + 1,
|
BlockTypeRole = Qt::UserRole + 1,
|
||||||
BlockNameRole,
|
BlockNameRole,
|
||||||
ContentRole,
|
ContentRole,
|
||||||
|
RenderedHtmlRole,
|
||||||
};
|
};
|
||||||
|
|
||||||
explicit ChatBlockModel(QObject *parent = nullptr);
|
explicit ChatBlockModel(QObject *parent = nullptr);
|
||||||
|
|
@ -51,6 +53,17 @@ signals:
|
||||||
private:
|
private:
|
||||||
void processLine(const QString &line);
|
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;
|
QList<ChatBlock> m_blocks;
|
||||||
QString m_lineBuffer;
|
QString m_lineBuffer;
|
||||||
|
|
||||||
|
|
|
||||||
67
gui/main.qml
67
gui/main.qml
|
|
@ -401,19 +401,37 @@ ApplicationWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content
|
ColumnLayout {
|
||||||
Label {
|
Layout.fillWidth: true
|
||||||
Layout.fillWidth: true
|
Layout.leftMargin: 4
|
||||||
Layout.leftMargin: 4
|
Layout.rightMargin: 4
|
||||||
Layout.rightMargin: 4
|
Layout.bottomMargin: 4
|
||||||
Layout.bottomMargin: 4
|
spacing: 0
|
||||||
text: blockContent || ""
|
|
||||||
wrapMode: Text.Wrap
|
// Rendered HTML (fenced code/diff) — shown when available
|
||||||
textFormat: Text.PlainText
|
Label {
|
||||||
font.family: theme.fontFamily || "monospace"
|
Layout.fillWidth: true
|
||||||
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
|
visible: blockRenderedHtml && blockRenderedHtml.length > 0
|
||||||
color: theme.textColor
|
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
|
Layout.margins: 6
|
||||||
spacing: 4
|
spacing: 4
|
||||||
|
|
||||||
TextField {
|
TextArea {
|
||||||
id: promptInput
|
id: promptInput
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: font.pointSize * 5 + 16
|
||||||
placeholderText: ollie.activeSessionId ? "Message..." : "No session"
|
placeholderText: ollie.activeSessionId ? "Message..." : "No session"
|
||||||
enabled: ollie.activeSessionId !== ""
|
enabled: ollie.activeSessionId !== ""
|
||||||
onAccepted: {
|
wrapMode: TextEdit.Wrap
|
||||||
if (text.trim() !== "") {
|
Keys.onReturnPressed: {
|
||||||
ollie.submit(text.trim())
|
if (event.modifiers & Qt.ShiftModifier) {
|
||||||
text = ""
|
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in New Issue