diff --git a/CMakeLists.txt b/CMakeLists.txt index d3f2a7d..9567afa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 "/" diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp new file mode 100644 index 0000000..ea3efeb --- /dev/null +++ b/gui/chatblockmodel.cpp @@ -0,0 +1,273 @@ +#include "chatblockmodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 ChatBlockModel::parseBlocks(const QString &text) const +{ + QList 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> 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', "
"); + } + + // 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("%2").arg(fg.name(), text); + } else { + html += text; + } + } + block = block.next(); + if (block.isValid()) + html += "
"; + } + + return html; +} + +void ChatBlockModel::copyToClipboard(const QString &text) const +{ + QGuiApplication::clipboard()->setText(text); +} diff --git a/gui/chatblockmodel.h b/gui/chatblockmodel.h new file mode 100644 index 0000000..b7b2b7d --- /dev/null +++ b/gui/chatblockmodel.h @@ -0,0 +1,75 @@ +#ifndef CHAT_BLOCK_MODEL_H +#define CHAT_BLOCK_MODEL_H + +#include +#include +#include + +// 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 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 parseBlocks(const QString &text) const; + QVariantList parseSubBlocks(const QString &content) const; + + QString m_rawText; + QList m_blocks; +}; + +#endif // CHAT_BLOCK_MODEL_H diff --git a/gui/main.cpp b/gui/main.cpp index 287cb50..15319b5 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -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")); diff --git a/gui/main.qml b/gui/main.qml index f282853..779f83c 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -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, '$1') + } + + 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" + } + } + } + } + } + } + } + } }