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:
parent
6642ab014b
commit
6e03064d0f
|
|
@ -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 "/"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
378
gui/main.qml
378
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, '<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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue