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()
|
||||
|
||||
# --- 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 "/"
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
67
gui/main.qml
67
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue