rich output rendering for ollie-gui

Replace flat TextArea chat view with structured ListView:
- ChatBlockModel parses raw chat text into typed blocks (user,
  assistant, reasoning, call, tool, error, info)
- Sub-blocks parsed from markdown: prose, fenced code, diffs
- Code blocks rendered with KSyntaxHighlighting (auto light/dark theme)
- Diff blocks with red/green line coloring
- Copy button on code and diff blocks
- File paths linkified and clickable (opens with xdg-open)
- Reasoning/call/tool blocks collapsible (click to expand)
- User blocks highlighted with subtle accent background
- Error blocks in red
- Scroll pinning preserved (auto-scroll when at bottom)
This commit is contained in:
Levi Neely 2026-07-16 06:29:18 +02:00
parent 6642ab014b
commit 6e03064d0f
5 changed files with 707 additions and 25 deletions

View File

@ -186,9 +186,11 @@ else()
plasma_install_package(plasmoid org.ollie.plasmoid)
# --- GUI App ---
find_package(KF6SyntaxHighlighting REQUIRED)
add_executable(ollie-gui
gui/main.cpp
gui/thememanager.cpp
gui/chatblockmodel.cpp
plasmoid/plugin/olliedbusclient.cpp
)
target_include_directories(ollie-gui PRIVATE
@ -202,6 +204,7 @@ else()
Qt6::Quick
Qt6::QuickControls2
KF6::ConfigCore
KF6::SyntaxHighlighting
)
qt_add_resources(ollie-gui "gui"
PREFIX "/"

273
gui/chatblockmodel.cpp Normal file
View File

@ -0,0 +1,273 @@
#include "chatblockmodel.h"
#include <QRegularExpression>
#include <KSyntaxHighlighting/Repository>
#include <KSyntaxHighlighting/Definition>
#include <KSyntaxHighlighting/Theme>
#include <KSyntaxHighlighting/SyntaxHighlighter>
#include <QTextDocument>
#include <QGuiApplication>
#include <QClipboard>
#include <QPalette>
static KSyntaxHighlighting::Repository *s_repo = nullptr;
static KSyntaxHighlighting::Repository &repo()
{
if (!s_repo)
s_repo = new KSyntaxHighlighting::Repository();
return *s_repo;
}
ChatBlockModel::ChatBlockModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int ChatBlockModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) return 0;
return m_blocks.size();
}
QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= m_blocks.size())
return {};
const ChatBlock &block = m_blocks[index.row()];
switch (role) {
case BlockTypeRole: {
switch (block.type) {
case ChatBlock::User: return QStringLiteral("user");
case ChatBlock::Assistant: return QStringLiteral("assistant");
case ChatBlock::Reasoning: return QStringLiteral("reasoning");
case ChatBlock::Call: return QStringLiteral("call");
case ChatBlock::Tool: return QStringLiteral("tool");
case ChatBlock::Error: return QStringLiteral("error");
case ChatBlock::Info: return QStringLiteral("info");
}
return {};
}
case NameRole:
return block.name;
case ContentRole:
return block.content;
case SubBlocksRole:
return parseSubBlocks(block.content);
}
return {};
}
QHash<int, QByteArray> ChatBlockModel::roleNames() const
{
return {
{BlockTypeRole, "blockType"},
{NameRole, "blockName"},
{ContentRole, "blockContent"},
{SubBlocksRole, "subBlocks"},
};
}
void ChatBlockModel::setRawText(const QString &text)
{
if (text == m_rawText) return;
m_rawText = text;
reparse();
emit rawTextChanged();
}
void ChatBlockModel::reparse()
{
beginResetModel();
m_blocks = parseBlocks(m_rawText);
endResetModel();
}
QList<ChatBlock> ChatBlockModel::parseBlocks(const QString &text) const
{
QList<ChatBlock> blocks;
if (text.isEmpty()) return blocks;
// Block headers: [user], [assistant], [reasoning], [call:name], [tool:name], [error], [info] msg
static const QRegularExpression headerRe(
R"(^\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\](.*)$)",
QRegularExpression::MultilineOption);
auto it = headerRe.globalMatch(text);
QList<QPair<int, QRegularExpressionMatch>> headers;
while (it.hasNext()) {
auto match = it.next();
headers.append({match.capturedStart(), match});
}
if (headers.isEmpty()) {
// No headers found — treat entire text as assistant block
ChatBlock b;
b.type = ChatBlock::Assistant;
b.content = text;
blocks.append(b);
return blocks;
}
for (int i = 0; i < headers.size(); ++i) {
const auto &[pos, match] = headers[i];
QString typeStr = match.captured(1);
QString name = match.captured(2);
QString inlineContent = match.captured(3); // for [info] msg style
int contentStart = match.capturedEnd();
int contentEnd = (i + 1 < headers.size()) ? headers[i + 1].first : text.size();
QString content;
if (typeStr == "info" && !inlineContent.trimmed().isEmpty()) {
// [info] has inline content on same line
content = inlineContent.trimmed();
} else {
content = text.mid(contentStart, contentEnd - contentStart);
// Strip leading newline and trailing double-newline
if (content.startsWith('\n'))
content = content.mid(1);
while (content.endsWith("\n\n"))
content.chop(1);
}
ChatBlock b;
if (typeStr == "user") b.type = ChatBlock::User;
else if (typeStr == "assistant") b.type = ChatBlock::Assistant;
else if (typeStr == "reasoning") b.type = ChatBlock::Reasoning;
else if (typeStr == "call") b.type = ChatBlock::Call;
else if (typeStr == "tool") b.type = ChatBlock::Tool;
else if (typeStr == "error") b.type = ChatBlock::Error;
else if (typeStr == "info") b.type = ChatBlock::Info;
b.name = name;
b.content = content;
blocks.append(b);
}
return blocks;
}
QVariantList ChatBlockModel::parseSubBlocks(const QString &content) const
{
QVariantList result;
if (content.isEmpty()) return result;
// Split on code fences: ```language\n...\n```
static const QRegularExpression fenceRe(
R"(^```(\w*)\s*\n((?:(?!^```).)*?)\n^```\s*$)",
QRegularExpression::MultilineOption | QRegularExpression::DotMatchesEverythingOption);
int lastEnd = 0;
auto it = fenceRe.globalMatch(content);
while (it.hasNext()) {
auto match = it.next();
// Prose before this fence
if (match.capturedStart() > lastEnd) {
QString prose = content.mid(lastEnd, match.capturedStart() - lastEnd).trimmed();
if (!prose.isEmpty()) {
QVariantMap m;
m["type"] = "prose";
m["content"] = prose;
result.append(m);
}
}
QString lang = match.captured(1);
QString code = match.captured(2);
// Detect diffs
QString type = "code";
if (lang == "diff" || (lang.isEmpty() &&
(code.startsWith("--- ") || code.startsWith("diff --git")))) {
type = "diff";
}
QVariantMap m;
m["type"] = type;
m["language"] = lang;
m["content"] = code;
result.append(m);
lastEnd = match.capturedEnd();
}
// Trailing prose
if (lastEnd < content.size()) {
QString prose = content.mid(lastEnd).trimmed();
if (!prose.isEmpty()) {
QVariantMap m;
m["type"] = "prose";
m["content"] = prose;
result.append(m);
}
}
// If no fences found at all, the entire content is prose
if (result.isEmpty()) {
QVariantMap m;
m["type"] = "prose";
m["content"] = content;
result.append(m);
}
return result;
}
QString ChatBlockModel::highlightedHtml(const QString &code, const QString &language) const
{
auto def = repo().definitionForName(language);
if (!def.isValid()) {
// Try by file extension
def = repo().definitionForFileName("file." + language);
}
if (!def.isValid()) {
// Return escaped plain text
return code.toHtmlEscaped().replace('\n', "<br>");
}
// Determine theme based on current palette
bool dark = QGuiApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
auto theme = dark
? repo().defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
: repo().defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
QTextDocument doc;
doc.setPlainText(code);
auto *highlighter = new KSyntaxHighlighting::SyntaxHighlighter(&doc);
highlighter->setDefinition(def);
highlighter->setTheme(theme);
highlighter->rehighlight();
// Extract HTML from the highlighted document
QString html;
QTextBlock block = doc.begin();
while (block.isValid()) {
for (auto it = block.begin(); !it.atEnd(); ++it) {
QTextFragment fragment = it.fragment();
if (!fragment.isValid()) continue;
QTextCharFormat fmt = fragment.charFormat();
QString text = fragment.text().toHtmlEscaped();
if (fmt.foreground().style() != Qt::NoBrush) {
QColor fg = fmt.foreground().color();
html += QString("<span style=\"color:%1;\">%2</span>").arg(fg.name(), text);
} else {
html += text;
}
}
block = block.next();
if (block.isValid())
html += "<br>";
}
return html;
}
void ChatBlockModel::copyToClipboard(const QString &text) const
{
QGuiApplication::clipboard()->setText(text);
}

75
gui/chatblockmodel.h Normal file
View File

@ -0,0 +1,75 @@
#ifndef CHAT_BLOCK_MODEL_H
#define CHAT_BLOCK_MODEL_H
#include <QAbstractListModel>
#include <QList>
#include <QString>
// A single block in the chat output
struct ChatBlock {
enum Type {
User, // [user] markdown
Assistant, // [assistant] markdown
Reasoning, // [reasoning] content
Call, // [call:name] args
Tool, // [tool:name] output
Error, // [error] text
Info // [info] text
};
Type type;
QString name; // tool/call name, empty for others
QString content; // raw text content
};
// A sub-block within a markdown block (prose, code fence, diff)
struct ContentBlock {
enum Type {
Prose, // regular markdown text
Code, // fenced code block
Diff // unified diff (detected by content pattern)
};
Type type;
QString language; // for Code blocks
QString content;
};
class ChatBlockModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(QString rawText READ rawText WRITE setRawText NOTIFY rawTextChanged)
public:
enum Roles {
BlockTypeRole = Qt::UserRole + 1,
NameRole,
ContentRole,
SubBlocksRole, // QVariantList of sub-block maps
};
explicit ChatBlockModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
QString rawText() const { return m_rawText; }
void setRawText(const QString &text);
Q_INVOKABLE QString highlightedHtml(const QString &code, const QString &language) const;
Q_INVOKABLE void copyToClipboard(const QString &text) const;
signals:
void rawTextChanged();
private:
void reparse();
QList<ChatBlock> parseBlocks(const QString &text) const;
QVariantList parseSubBlocks(const QString &content) const;
QString m_rawText;
QList<ChatBlock> m_blocks;
};
#endif // CHAT_BLOCK_MODEL_H

View File

@ -10,6 +10,7 @@
#include "olliedbusclient.h"
#include "thememanager.h"
#include "chatblockmodel.h"
static QIcon makeBrainIcon()
{
@ -72,8 +73,10 @@ int main(int argc, char *argv[])
// Register OllieDBusClient as a singleton
auto *dbus = new OllieDBusClient(&app);
auto *chatModel = new ChatBlockModel(&app);
engine.rootContext()->setContextProperty("dbus", dbus);
engine.rootContext()->setContextProperty("theme", theme);
engine.rootContext()->setContextProperty("chatModel", chatModel);
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
const QUrl url(QStringLiteral("qrc:/gui/main.qml"));

View File

@ -350,44 +350,62 @@ ApplicationWindow {
}
// Chat view
ScrollView {
id: chatScroll
// Chat view — structured blocks
Item {
Layout.fillWidth: true
Layout.fillHeight: true
property bool atBottom: true
Flickable {
id: chatFlickable
contentWidth: chatArea.width
contentHeight: chatArea.implicitHeight
Binding {
target: chatModel
property: "rawText"
value: dbus.chatText || ""
}
ListView {
id: chatList
anchors.fill: parent
anchors.margins: 4
clip: true
boundsBehavior: Flickable.StopAtBounds
spacing: 8
model: chatModel
onContentYChanged: {
let bottom = contentHeight - height
chatScroll.atBottom = (bottom <= 0 || contentY >= bottom - 4)
parent.atBottom = (bottom <= 0 || contentY >= bottom - 4)
}
onContentHeightChanged: {
if (chatScroll.atBottom) {
if (parent.atBottom) {
contentY = Math.max(0, contentHeight - height)
}
}
TextArea {
id: chatArea
readOnly: true
selectByMouse: true
width: chatFlickable.width
wrapMode: TextEdit.Wrap
text: dbus.chatText || (dbus.activeSessionId
// Placeholder when empty
Label {
anchors.centerIn: parent
visible: chatList.count === 0
text: dbus.activeSessionId
? "(waiting for output...)"
: "Select or create a session")
font.family: "monospace"
color: dbus.chatText
? palette.text
: palette.placeholderText
background: null
: "Select or create a session"
color: palette.placeholderText
}
delegate: Loader {
width: chatList.width
property var block: model
sourceComponent: {
switch (blockType) {
case "user": return userDelegate
case "assistant": return assistantDelegate
case "reasoning": return reasoningDelegate
case "call": return callDelegate
case "tool": return toolDelegate
case "error": return errorDelegate
case "info": return infoDelegate
default: return assistantDelegate
}
}
}
}
@ -396,10 +414,10 @@ ApplicationWindow {
anchors.bottom: parent.bottom
anchors.margins: 6
icon.name: "go-down"
visible: !chatScroll.atBottom
visible: !parent.atBottom
onClicked: {
chatFlickable.contentY = Math.max(0, chatFlickable.contentHeight - chatFlickable.height)
chatScroll.atBottom = true
chatList.contentY = Math.max(0, chatList.contentHeight - chatList.height)
parent.atBottom = true
}
ToolTip.text: "Scroll to bottom"
ToolTip.visible: hovered
@ -477,4 +495,314 @@ ApplicationWindow {
}
}
}
// --- Chat block delegates ---
function linkifyPaths(text) {
// Match /absolute/path or /absolute/path:line
return text.replace(/(\/[\w\-.\/]+(?::\d+)?)/g, '<a href="file://$1">$1</a>')
}
Component {
id: userDelegate
ColumnLayout {
spacing: 4
Rectangle {
Layout.fillWidth: true
implicitHeight: userContent.implicitHeight + 12
color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.08)
radius: 6
ColumnLayout {
id: userContent
anchors.fill: parent
anchors.margins: 6
spacing: 4
Repeater {
model: block.subBlocks
delegate: Loader {
Layout.fillWidth: true
property var subBlock: modelData
sourceComponent: subBlock.type === "code" ? codeSubDelegate
: subBlock.type === "diff" ? diffSubDelegate
: proseSubDelegate
}
}
}
}
}
}
Component {
id: assistantDelegate
ColumnLayout {
spacing: 4
Repeater {
model: block.subBlocks
delegate: Loader {
Layout.fillWidth: true
property var subBlock: modelData
sourceComponent: subBlock.type === "code" ? codeSubDelegate
: subBlock.type === "diff" ? diffSubDelegate
: proseSubDelegate
}
}
}
}
Component {
id: reasoningDelegate
ColumnLayout {
spacing: 2
Label {
text: "\u25B6 reasoning"
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.5
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: reasoningText.visible = !reasoningText.visible
}
}
Label {
id: reasoningText
Layout.fillWidth: true
text: block.blockContent
wrapMode: Text.Wrap
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.85
opacity: 0.5
visible: false
}
}
}
Component {
id: callDelegate
ColumnLayout {
spacing: 2
Label {
text: "\u25B6 call: " + (block.blockName || "")
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.5
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: callText.visible = !callText.visible
}
}
Label {
id: callText
Layout.fillWidth: true
text: block.blockContent
wrapMode: Text.Wrap
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.85
opacity: 0.4
visible: false
}
}
}
Component {
id: toolDelegate
ColumnLayout {
spacing: 2
Label {
text: "\u25B6 tool: " + (block.blockName || "")
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.5
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: toolText.visible = !toolText.visible
}
}
Label {
id: toolText
Layout.fillWidth: true
text: block.blockContent
wrapMode: Text.Wrap
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.85
opacity: 0.4
visible: false
maximumLineCount: 50
elide: Text.ElideRight
}
}
}
Component {
id: errorDelegate
Rectangle {
implicitHeight: errorLabel.implicitHeight + 12
color: Qt.rgba(1, 0, 0, 0.08)
radius: 4
Label {
id: errorLabel
anchors.fill: parent
anchors.margins: 6
text: block.blockContent
wrapMode: Text.Wrap
color: "#e53935"
font.family: "monospace"
}
}
}
Component {
id: infoDelegate
Label {
text: block.blockContent
wrapMode: Text.Wrap
font.pixelSize: Qt.application.font.pixelSize * 0.85
opacity: 0.6
font.italic: true
}
}
// --- Sub-block delegates ---
Component {
id: proseSubDelegate
Text {
width: parent ? parent.width : implicitWidth
text: root.linkifyPaths(subBlock.content)
wrapMode: Text.Wrap
textFormat: Text.RichText
color: palette.text
font.pixelSize: Qt.application.font.pixelSize
lineHeight: 1.4
onLinkActivated: function(link) {
Qt.openUrlExternally(link)
}
}
}
Component {
id: codeSubDelegate
Rectangle {
implicitHeight: codeCol.implicitHeight
color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6)
radius: 4
border.width: 1
border.color: palette.mid
ColumnLayout {
id: codeCol
anchors.fill: parent
spacing: 0
// Header with language + copy button
RowLayout {
Layout.fillWidth: true
Layout.margins: 4
Label {
text: subBlock.language || "code"
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.6
Layout.fillWidth: true
}
ToolButton {
icon.name: "edit-copy"
implicitWidth: 22
implicitHeight: 22
onClicked: {
chatModel.copyToClipboard(subBlock.content)
}
ToolTip.text: "Copy"
ToolTip.visible: hovered
}
}
// Highlighted code
Text {
Layout.fillWidth: true
Layout.margins: 8
text: chatModel.highlightedHtml(subBlock.content, subBlock.language)
textFormat: Text.RichText
wrapMode: Text.Wrap
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.9
color: palette.text
}
}
}
}
Component {
id: diffSubDelegate
Rectangle {
implicitHeight: diffCol.implicitHeight
color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6)
radius: 4
border.width: 1
border.color: palette.mid
ColumnLayout {
id: diffCol
anchors.fill: parent
spacing: 0
RowLayout {
Layout.fillWidth: true
Layout.margins: 4
Label {
text: "diff"
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.6
Layout.fillWidth: true
}
ToolButton {
icon.name: "edit-copy"
implicitWidth: 22
implicitHeight: 22
onClicked: {
chatModel.copyToClipboard(subBlock.content)
}
ToolTip.text: "Copy"
ToolTip.visible: hovered
}
}
// Diff lines with coloring
Column {
Layout.fillWidth: true
Layout.margins: 8
spacing: 0
Repeater {
model: subBlock.content.split("\n")
Text {
width: parent.width
text: modelData
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.9
wrapMode: Text.Wrap
color: {
if (modelData.startsWith("+")) return "#4caf50"
if (modelData.startsWith("-")) return "#f44336"
if (modelData.startsWith("@@")) return "#2196f3"
return palette.text
}
background: Rectangle {
color: {
if (modelData.startsWith("+")) return Qt.rgba(0.3, 0.8, 0.3, 0.05)
if (modelData.startsWith("-")) return Qt.rgba(0.8, 0.3, 0.3, 0.05)
return "transparent"
}
}
}
}
}
}
}
}
}