diff --git a/CMakeLists.txt b/CMakeLists.txt index 52dffe8..5a3e3c7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,13 @@ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) if(OLLIE_KF5) # --- Qt5 / KF5 --- find_package(Qt5 REQUIRED COMPONENTS Core DBus Widgets Quick Qml QuickControls2) + + # Override KDE_INSTALL_PLUGINDIR so kcoreaddons_add_plugin installs to the + # Qt5 plugin directory (e.g. /usr/lib/x86_64-linux-gnu/qt5/plugins). + get_target_property(_QT5_QMAKE_EXECUTABLE Qt5::qmake IMPORTED_LOCATION) + execute_process(COMMAND "${_QT5_QMAKE_EXECUTABLE}" -query QT_INSTALL_PLUGINS + OUTPUT_VARIABLE _QT5_PLUGIN_DIR OUTPUT_STRIP_TRAILING_WHITESPACE) + set(KDE_INSTALL_PLUGINDIR "${_QT5_PLUGIN_DIR}" CACHE PATH "" FORCE) find_package(KF5 REQUIRED COMPONENTS Notifications Runner @@ -59,9 +66,10 @@ if(OLLIE_KF5) ) # --- Kate Plugin --- - kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp kate/ollie_ghost.cpp INSTALL_NAMESPACE "ktexteditor") + kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp kate/ollie_ghost.cpp kate/chat_renderer.cpp INSTALL_NAMESPACE "ktexteditor") target_link_libraries(ollie_kate KF5::TextEditor + KF5::SyntaxHighlighting Qt5::Core Qt5::DBus Qt5::Widgets @@ -159,9 +167,11 @@ else() # --- Kate Plugin --- find_package(KF6TextEditor REQUIRED) - kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp kate/ollie_ghost.cpp INSTALL_NAMESPACE "ktexteditor") + find_package(KF6SyntaxHighlighting REQUIRED) + kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp kate/ollie_ghost.cpp kate/chat_renderer.cpp INSTALL_NAMESPACE "ktexteditor") target_link_libraries(ollie_kate KF6::TextEditor + KF6::SyntaxHighlighting Qt6::Core Qt6::DBus Qt6::Widgets @@ -189,7 +199,6 @@ else() plasma_install_package(plasmoid org.ollie.plasmoid) # --- GUI App --- - find_package(KF6SyntaxHighlighting REQUIRED) add_executable(ollie-gui gui/main.cpp gui/thememanager.cpp diff --git a/kate/chat_renderer.cpp b/kate/chat_renderer.cpp new file mode 100644 index 0000000..478410b --- /dev/null +++ b/kate/chat_renderer.cpp @@ -0,0 +1,470 @@ +#include "chat_renderer.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +// --- HTML Highlighter --- + +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("") + text + QStringLiteral(""); + } else { + html += text; + } + } + +private: + QString m_currentLine; +}; + +// --- ChatRenderer --- + +ChatRenderer::ChatRenderer() + : m_repo(new KSyntaxHighlighting::Repository()) +{ +} + +ChatRenderer::~ChatRenderer() +{ + delete m_repo; +} + +void ChatRenderer::detectTheme() const +{ + m_dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5; +} + +QString ChatRenderer::escapeHtml(const QString &text) const +{ + return text.toHtmlEscaped(); +} + +// --- Block Parsing --- + +QList ChatRenderer::parseBlocks(const QString &text) const +{ + QList blocks; + if (text.isEmpty()) return blocks; + + 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()) { + 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); + + int contentStart = match.capturedEnd(); + int contentEnd = (i + 1 < headers.size()) ? headers[i + 1].first : text.size(); + + QString content; + if (typeStr == "info" && !inlineContent.trimmed().isEmpty()) { + content = inlineContent.trimmed(); + } else { + content = text.mid(contentStart, contentEnd - contentStart); + 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; +} + +QList ChatRenderer::parseSubBlocks(const QString &content) const +{ + QList result; + if (content.isEmpty()) return result; + + 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(); + if (match.capturedStart() > lastEnd) { + QString prose = content.mid(lastEnd, match.capturedStart() - lastEnd).trimmed(); + if (!prose.isEmpty()) { + SubBlock sb; + sb.type = SubBlock::Prose; + sb.content = prose; + result.append(sb); + } + } + + QString lang = match.captured(1); + QString code = match.captured(2); + + SubBlock sb; + if (lang == "diff" || (lang.isEmpty() && + (code.startsWith("--- ") || code.startsWith("diff --git")))) { + sb.type = SubBlock::Diff; + } else { + sb.type = SubBlock::Code; + } + sb.language = lang; + sb.content = code; + result.append(sb); + + lastEnd = match.capturedEnd(); + } + + if (lastEnd < content.size()) { + QString prose = content.mid(lastEnd).trimmed(); + if (!prose.isEmpty()) { + SubBlock sb; + sb.type = SubBlock::Prose; + sb.content = prose; + result.append(sb); + } + } + + if (result.isEmpty()) { + SubBlock sb; + sb.type = SubBlock::Prose; + sb.content = content; + result.append(sb); + } + + return result; +} + +// --- HTML Rendering --- + +QString ChatRenderer::highlightCode(const QString &code, const QString &language) const +{ + auto def = m_repo->definitionForName(language); + if (!def.isValid()) + def = m_repo->definitionForFileName("file." + language); + if (!def.isValid()) + return QStringLiteral("
") + escapeHtml(code) + QStringLiteral("
"); + + auto theme = m_dark + ? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme) + : m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme); + + 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 += '\n'; + } + + return QStringLiteral("
") + builder.html + QStringLiteral("
"); +} + +QString ChatRenderer::renderDiff(const QString &diff) const +{ + QString html = QStringLiteral("
");
+    const QStringList lines = diff.split('\n');
+    for (const QString &line : lines) {
+        if (line.startsWith('+')) {
+            html += QStringLiteral("") + escapeHtml(line) + QStringLiteral("\n");
+        } else if (line.startsWith('-')) {
+            html += QStringLiteral("") + escapeHtml(line) + QStringLiteral("\n");
+        } else if (line.startsWith("@@")) {
+            html += QStringLiteral("") + escapeHtml(line) + QStringLiteral("\n");
+        } else {
+            html += escapeHtml(line) + '\n';
+        }
+    }
+    html += QStringLiteral("
"); + return html; +} + +QString ChatRenderer::renderProse(const QString &prose) const +{ + // Simple markdown-ish rendering: bold, italic, inline code, links + QString html = escapeHtml(prose); + + // Inline code: `code` + static const QRegularExpression inlineCode("`([^`]+)`"); + html.replace(inlineCode, QStringLiteral("\\1")); + + // Bold: **text** or __text__ + static const QRegularExpression bold(R"(\*\*(.+?)\*\*)"); + html.replace(bold, QStringLiteral("\\1")); + static const QRegularExpression bold2(R"(__(.+?)__)"); + html.replace(bold2, QStringLiteral("\\1")); + + // Italic: *text* or _text_ + static const QRegularExpression italic(R"((?\\1")); + + // Bullet points: lines starting with - or * + static const QRegularExpression bullet(R"(^(\s*)[\-\*]\s)", QRegularExpression::MultilineOption); + html.replace(bullet, QStringLiteral("\\1• ")); + + // Newlines to
+ html.replace('\n', QStringLiteral("
")); + + return html; +} + +QString ChatRenderer::subBlockToHtml(const SubBlock &sub) const +{ + QString codeBg = m_dark ? QStringLiteral("#1e1e2e") : QStringLiteral("#f5f5f5"); + QString codeBorder = m_dark ? QStringLiteral("#444") : QStringLiteral("#ddd"); + + switch (sub.type) { + case SubBlock::Code: { + QString langLabel; + if (!sub.language.isEmpty()) { + langLabel = QStringLiteral( + "
") + + escapeHtml(sub.language) + QStringLiteral("
"); + } + return QStringLiteral( + "" + "
") + + langLabel + highlightCode(sub.content, sub.language) + + QStringLiteral("
"); + } + case SubBlock::Diff: { + return QStringLiteral( + "" + "
" + "
diff
") + + renderDiff(sub.content) + + QStringLiteral("
"); + } + case SubBlock::Prose: + return QStringLiteral("
") + + renderProse(sub.content) + QStringLiteral("
"); + } + return {}; +} + +QString ChatRenderer::blockToHtml(const ChatBlock &block) const +{ + detectTheme(); + + QString userBg = m_dark ? QStringLiteral("#1a3a5c") : QStringLiteral("#e3f2fd"); + QString assistantBg = m_dark ? QStringLiteral("#2a2a2a") : QStringLiteral("#fafafa"); + QString errorBg = m_dark ? QStringLiteral("#3d1515") : QStringLiteral("#ffebee"); + QString metaBg = m_dark ? QStringLiteral("#1a1a2e") : QStringLiteral("#f5f5f5"); + QString metaColor = m_dark ? QStringLiteral("#888") : QStringLiteral("#666"); + QString borderColor = m_dark ? QStringLiteral("#444") : QStringLiteral("#e0e0e0"); + + switch (block.type) { + case ChatBlock::User: { + QList subs = parseSubBlocks(block.content); + QString inner; + for (const auto &sub : subs) + inner += subBlockToHtml(sub); + + return QStringLiteral( + "" + "" + "" + "" + "
") + inner + + QStringLiteral("
You
"); + } + case ChatBlock::Assistant: { + QList subs = parseSubBlocks(block.content); + QString inner; + for (const auto &sub : subs) + inner += subBlockToHtml(sub); + + return QStringLiteral( + "" + "" + "" + "
") + inner + + QStringLiteral("
Ollie
"); + } + case ChatBlock::Reasoning: { + // Show abbreviated with dimmed styling + QString abbreviated = block.content.left(200); + if (block.content.size() > 200) + abbreviated += QStringLiteral("..."); + + return QStringLiteral( + "" + "
" + "
▸ reasoning
" + "
") + escapeHtml(abbreviated) +
+            QStringLiteral("
"); + } + case ChatBlock::Call: { + QString label = QStringLiteral("⚡ call"); + if (!block.name.isEmpty()) + label += QStringLiteral(": ") + escapeHtml(block.name); + + // Show args abbreviated + QString content = block.content.trimmed(); + QString abbreviated = content.left(150); + if (content.size() > 150) + abbreviated += QStringLiteral("..."); + + return QStringLiteral( + "" + "
" + "
") + label + + QStringLiteral("
" + "
") + escapeHtml(abbreviated) +
+            QStringLiteral("
"); + } + case ChatBlock::Tool: { + QString label = QStringLiteral("◆ tool"); + if (!block.name.isEmpty()) + label += QStringLiteral(": ") + escapeHtml(block.name); + + // Show result abbreviated + QString content = block.content.trimmed(); + QString abbreviated = content.left(200); + if (content.size() > 200) + abbreviated += QStringLiteral("..."); + + return QStringLiteral( + "" + "
" + "
" ) + label + + QStringLiteral("
" + "
") + escapeHtml(abbreviated) +
+            QStringLiteral("
"); + } + case ChatBlock::Error: { + return QStringLiteral( + "" + "
" + "
") +
+            escapeHtml(block.content) +
+            QStringLiteral("
"); + } + case ChatBlock::Info: { + return QStringLiteral( + "
") + + escapeHtml(block.content) + QStringLiteral("
"); + } + } + return {}; +} + +// --- Full Render --- + +void ChatRenderer::render(QTextEdit *view, const QString &rawText) +{ + detectTheme(); + + QList blocks = parseBlocks(rawText); + QString html = QStringLiteral( + ""); + for (const auto &block : blocks) + html += blockToHtml(block); + html += QStringLiteral(""); + + auto *sb = view->verticalScrollBar(); + bool atBottom = (sb->value() >= sb->maximum() - 4); + + view->setHtml(html); + + if (atBottom) + sb->setValue(sb->maximum()); + + m_lastRawText = rawText; + m_lastBlockCount = blocks.size(); +} + +bool ChatRenderer::renderIncremental(QTextEdit *view, const QString &rawText) +{ + // If text is just appended, try incremental + if (!rawText.startsWith(m_lastRawText) || m_lastRawText.isEmpty()) { + render(view, rawText); + return false; + } + + // Full re-render (the HTML approach doesn't support true incremental well + // since setHtml replaces everything). For performance with typical chat + // sizes this is fine — QTextEdit handles it efficiently. + render(view, rawText); + return true; +} diff --git a/kate/chat_renderer.h b/kate/chat_renderer.h new file mode 100644 index 0000000..969ba1c --- /dev/null +++ b/kate/chat_renderer.h @@ -0,0 +1,80 @@ +#ifndef OLLIE_CHAT_RENDERER_H +#define OLLIE_CHAT_RENDERER_H + +#include +#include +#include +#include + +namespace KSyntaxHighlighting { + class Repository; +} + +// Parsed chat block +struct ChatBlock { + enum Type { + User, + Assistant, + Reasoning, + Call, + Tool, + Error, + Info + }; + + Type type; + QString name; // tool/call name + QString content; // raw text content +}; + +// Sub-block within a message (prose, code fence, diff) +struct SubBlock { + enum Type { Prose, Code, Diff }; + + Type type; + QString language; + QString content; +}; + +/** + * ChatRenderer parses ollie chat output into structured blocks + * and renders them as styled HTML in a QTextEdit. + */ +class ChatRenderer +{ +public: + ChatRenderer(); + ~ChatRenderer(); + + /// Parse raw chat text into blocks + QList parseBlocks(const QString &text) const; + + /// Parse a block's content into sub-blocks (prose, code, diff) + QList parseSubBlocks(const QString &content) const; + + /// Render full chat text into the QTextEdit as styled HTML + void render(QTextEdit *view, const QString &rawText); + + /// Render incrementally: assumes rawText is the full text and only + /// the tail has changed since last render. Returns true if it did + /// an incremental update, false if it did a full re-render. + bool renderIncremental(QTextEdit *view, const QString &rawText); + +private: + QString blockToHtml(const ChatBlock &block) const; + QString subBlockToHtml(const SubBlock &sub) const; + QString highlightCode(const QString &code, const QString &language) const; + QString renderDiff(const QString &diff) const; + QString renderProse(const QString &prose) const; + QString escapeHtml(const QString &text) const; + + KSyntaxHighlighting::Repository *m_repo; + QString m_lastRawText; // for incremental rendering + int m_lastBlockCount = 0; + + // Colors (derived from palette) + mutable bool m_dark = false; + void detectTheme() const; +}; + +#endif // OLLIE_CHAT_RENDERER_H diff --git a/kate/ollie_kate.cpp b/kate/ollie_kate.cpp index 7e8bcf9..e5a7b1e 100644 --- a/kate/ollie_kate.cpp +++ b/kate/ollie_kate.cpp @@ -1,5 +1,6 @@ #include "ollie_kate.h" #include "ollie_ghost.h" +#include "chat_renderer.h" #include #include @@ -224,6 +225,7 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m : QObject(mainWindow) , m_plugin(plugin) , m_mainWindow(mainWindow) + , m_renderer(new ChatRenderer()) { m_iface = new QDBusInterface(SERVICE, OBJ_PATH, IFACE, QDBusConnection::sessionBus(), this); @@ -394,6 +396,7 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m OllieKateView::~OllieKateView() { + delete m_renderer; delete m_ghost; delete m_toolView; } @@ -405,18 +408,18 @@ void OllieKateView::onChatUpdated(const QString &session_id, qlonglong offset, c if (session_id != m_sessionId) return; if (offset == m_chatOffset) { - appendChat(new_text); + m_rawChat += new_text; m_chatOffset += new_text.size(); + m_renderer->render(m_chatView, m_rawChat); } else { // Offset mismatch — refetch QDBusMessage msg = QDBusMessage::createMethodCall(SERVICE, OBJ_PATH, IFACE, "GetChat"); msg << m_sessionId << (qlonglong)0; QDBusMessage reply = QDBusConnection::sessionBus().call(msg); if (reply.type() == QDBusMessage::ReplyMessage && reply.arguments().size() >= 2) { - m_chatView->clear(); - m_chatView->setPlainText(reply.arguments()[0].toString()); + m_rawChat = reply.arguments()[0].toString(); m_chatOffset = reply.arguments()[1].toLongLong(); - m_chatView->verticalScrollBar()->setValue(m_chatView->verticalScrollBar()->maximum()); + m_renderer->render(m_chatView, m_rawChat); m_scrollBottomBtn->hide(); } } @@ -447,7 +450,8 @@ void OllieKateView::onSessionKilled(const QString &session_id) if (session_id == m_sessionId) { m_sessionId.clear(); m_chatOffset = 0; - m_chatView->setPlainText(""); + m_rawChat.clear(); + m_chatView->clear(); m_sessionLabel->setText("No session"); m_cwdLabel->setText(""); refreshSessions(); @@ -517,12 +521,13 @@ void OllieKateView::newSession() QDBusReply reply = m_iface->call("CreateSession", cwd, backend, model, "default"); if (reply.isValid()) { m_sessionId = reply.value(); + m_rawChat.clear(); m_chatView->clear(); m_chatOffset = 0; m_sessionLabel->setText("○ " + m_sessionId + " [idle]"); m_cwdLabel->setText(cwd); } else { - m_chatView->setPlainText("Error creating session: " + reply.error().message()); + m_chatView->setHtml("

Error creating session: " + reply.error().message().toHtmlEscaped() + "

"); } } @@ -530,13 +535,13 @@ void OllieKateView::pickSession() { QString baseDir = projectBaseDir(); if (baseDir.isEmpty()) { - m_chatView->setPlainText("Cannot determine project directory."); + m_chatView->setHtml(QStringLiteral("

Cannot determine project directory.

")); return; } QDBusReply reply = m_iface->call("ListSessions"); if (!reply.isValid() || reply.value().isEmpty()) { - m_chatView->setPlainText("No active sessions found."); + m_chatView->setHtml(QStringLiteral("

No active sessions found.

")); return; } @@ -564,13 +569,13 @@ void OllieKateView::pickSession() } } - m_chatView->setPlainText("No session found with cwd: " + baseDir); + m_chatView->setHtml(QStringLiteral("

No session found with cwd: ") + baseDir.toHtmlEscaped() + QStringLiteral("

")); } void OllieKateView::renameSession() { if (m_sessionId.isEmpty()) { - m_chatView->setPlainText("No active session to rename."); + m_chatView->setHtml(QStringLiteral("

No active session to rename.

")); return; } @@ -596,7 +601,7 @@ void OllieKateView::renameSession() m_sessionLabel->setText(dot + m_sessionId + " [idle]"); } } else { - m_chatView->setPlainText("Failed to rename session."); + m_chatView->setHtml(QStringLiteral("

Failed to rename session.

")); } } @@ -623,6 +628,7 @@ QString OllieKateView::projectBaseDir() void OllieKateView::switchToSession(const QString &sessionId) { m_sessionId = sessionId; + m_rawChat.clear(); m_chatView->clear(); m_chatOffset = 0; @@ -648,9 +654,9 @@ void OllieKateView::switchToSession(const QString &sessionId) msg << m_sessionId << (qlonglong)0; QDBusMessage chatReply = QDBusConnection::sessionBus().call(msg); if (chatReply.type() == QDBusMessage::ReplyMessage && chatReply.arguments().size() >= 2) { - m_chatView->setPlainText(chatReply.arguments()[0].toString()); + m_rawChat = chatReply.arguments()[0].toString(); m_chatOffset = chatReply.arguments()[1].toLongLong(); - m_chatView->verticalScrollBar()->setValue(m_chatView->verticalScrollBar()->maximum()); + m_renderer->render(m_chatView, m_rawChat); m_scrollBottomBtn->hide(); } } @@ -659,7 +665,7 @@ void OllieKateView::ensureSession() { if (!m_sessionId.isEmpty()) return; if (!m_iface->isValid()) { - m_chatView->setPlainText("Error: ollie-kde daemon not running.\nStart with: ollie-kde or rc-service --user ollie-kde start"); + m_chatView->setHtml(QStringLiteral("

Error: ollie-kde daemon not running.
Start with: ollie-kde or rc-service --user ollie-kde start

")); return; } newSession(); @@ -670,23 +676,6 @@ QString OllieKateView::activeSessionId() return m_sessionId; } -void OllieKateView::appendChat(const QString &text) -{ - auto *sb = m_chatView->verticalScrollBar(); - bool atBottom = (sb->value() >= sb->maximum() - 4); - - QTextCursor cursor = m_chatView->textCursor(); - cursor.movePosition(QTextCursor::End); - cursor.insertText(text); - - if (atBottom) { - sb->setValue(sb->maximum()); - m_scrollBottomBtn->hide(); - } else { - m_scrollBottomBtn->show(); - } -} - void OllieKateView::refreshSessions() { QDBusReply reply = m_iface->call("ListSessions"); diff --git a/kate/ollie_kate.h b/kate/ollie_kate.h index 44f1d5a..a27bf17 100644 --- a/kate/ollie_kate.h +++ b/kate/ollie_kate.h @@ -18,6 +18,8 @@ #include #include +class ChatRenderer; + class QPushButton; class OllieKateView; class OllieGhostProvider; @@ -93,7 +95,6 @@ private slots: private: void ensureSession(); QString activeSessionId(); - void appendChat(const QString &text); OllieKatePlugin *m_plugin; KTextEditor::MainWindow *m_mainWindow; @@ -112,6 +113,8 @@ private: QString m_sessionId; qlonglong m_chatOffset = 0; + QString m_rawChat; // accumulated raw chat text + ChatRenderer *m_renderer; QPointer m_ghost; bool m_ghostPrefix = false; // true after Meta+O, waiting for second key