kate: extract chat_renderer, simplify kate plugin
This commit is contained in:
parent
59285cda9f
commit
d3c8aece38
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,470 @@
|
|||
#include "chat_renderer.h"
|
||||
|
||||
#include <KSyntaxHighlighting/Repository>
|
||||
#include <KSyntaxHighlighting/Definition>
|
||||
#include <KSyntaxHighlighting/Theme>
|
||||
#include <KSyntaxHighlighting/AbstractHighlighter>
|
||||
#include <KSyntaxHighlighting/State>
|
||||
#include <KSyntaxHighlighting/Format>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QPalette>
|
||||
#include <QTextCursor>
|
||||
#include <QScrollBar>
|
||||
#include <QRegularExpression>
|
||||
|
||||
// --- 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("<span style=\"color:") + fg.name() + QStringLiteral("\">") + text + QStringLiteral("</span>");
|
||||
} 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<ChatBlock> ChatRenderer::parseBlocks(const QString &text) const
|
||||
{
|
||||
QList<ChatBlock> 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<QPair<int, QRegularExpressionMatch>> 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<SubBlock> ChatRenderer::parseSubBlocks(const QString &content) const
|
||||
{
|
||||
QList<SubBlock> 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("<pre style=\"margin:4px 0;white-space:pre-wrap;\">") + escapeHtml(code) + QStringLiteral("</pre>");
|
||||
|
||||
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("<pre style=\"margin:4px 0;white-space:pre-wrap;\">") + builder.html + QStringLiteral("</pre>");
|
||||
}
|
||||
|
||||
QString ChatRenderer::renderDiff(const QString &diff) const
|
||||
{
|
||||
QString html = QStringLiteral("<pre style=\"margin:4px 0;white-space:pre-wrap;\">");
|
||||
const QStringList lines = diff.split('\n');
|
||||
for (const QString &line : lines) {
|
||||
if (line.startsWith('+')) {
|
||||
html += QStringLiteral("<span style=\"color:#4caf50;\">") + escapeHtml(line) + QStringLiteral("</span>\n");
|
||||
} else if (line.startsWith('-')) {
|
||||
html += QStringLiteral("<span style=\"color:#f44336;\">") + escapeHtml(line) + QStringLiteral("</span>\n");
|
||||
} else if (line.startsWith("@@")) {
|
||||
html += QStringLiteral("<span style=\"color:#2196f3;\">") + escapeHtml(line) + QStringLiteral("</span>\n");
|
||||
} else {
|
||||
html += escapeHtml(line) + '\n';
|
||||
}
|
||||
}
|
||||
html += QStringLiteral("</pre>");
|
||||
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("<code style=\"background:") +
|
||||
(m_dark ? QStringLiteral("#333") : QStringLiteral("#f0f0f0")) +
|
||||
QStringLiteral(";padding:1px 4px;border-radius:3px;\">\\1</code>"));
|
||||
|
||||
// Bold: **text** or __text__
|
||||
static const QRegularExpression bold(R"(\*\*(.+?)\*\*)");
|
||||
html.replace(bold, QStringLiteral("<b>\\1</b>"));
|
||||
static const QRegularExpression bold2(R"(__(.+?)__)");
|
||||
html.replace(bold2, QStringLiteral("<b>\\1</b>"));
|
||||
|
||||
// Italic: *text* or _text_
|
||||
static const QRegularExpression italic(R"((?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*))");
|
||||
html.replace(italic, QStringLiteral("<i>\\1</i>"));
|
||||
|
||||
// Bullet points: lines starting with - or *
|
||||
static const QRegularExpression bullet(R"(^(\s*)[\-\*]\s)", QRegularExpression::MultilineOption);
|
||||
html.replace(bullet, QStringLiteral("\\1• "));
|
||||
|
||||
// Newlines to <br>
|
||||
html.replace('\n', QStringLiteral("<br>"));
|
||||
|
||||
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(
|
||||
"<div style=\"font-size:small;color:gray;padding:2px 8px;\">") +
|
||||
escapeHtml(sub.language) + QStringLiteral("</div>");
|
||||
}
|
||||
return QStringLiteral(
|
||||
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:6px 0;\">"
|
||||
"<tr><td style=\"background:") + codeBg +
|
||||
QStringLiteral(";border:1px solid ") + codeBorder +
|
||||
QStringLiteral(";padding:8px;\">") +
|
||||
langLabel + highlightCode(sub.content, sub.language) +
|
||||
QStringLiteral("</td></tr></table>");
|
||||
}
|
||||
case SubBlock::Diff: {
|
||||
return QStringLiteral(
|
||||
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:6px 0;\">"
|
||||
"<tr><td style=\"background:") + codeBg +
|
||||
QStringLiteral(";border:1px solid ") + codeBorder +
|
||||
QStringLiteral(";padding:8px;\">"
|
||||
"<div style=\"font-size:small;color:gray;padding:2px 8px;\">diff</div>") +
|
||||
renderDiff(sub.content) +
|
||||
QStringLiteral("</td></tr></table>");
|
||||
}
|
||||
case SubBlock::Prose:
|
||||
return QStringLiteral("<div style=\"margin:4px 0;line-height:1.4;\">") +
|
||||
renderProse(sub.content) + QStringLiteral("</div>");
|
||||
}
|
||||
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<SubBlock> subs = parseSubBlocks(block.content);
|
||||
QString inner;
|
||||
for (const auto &sub : subs)
|
||||
inner += subBlockToHtml(sub);
|
||||
|
||||
return QStringLiteral(
|
||||
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:8px 0;\">"
|
||||
"<tr><td width=\"15%\"></td>"
|
||||
"<td style=\"background:") + userBg +
|
||||
QStringLiteral(";padding:10px 14px;border:1px solid ") + borderColor +
|
||||
QStringLiteral(";\">") + inner +
|
||||
QStringLiteral("</td></tr>"
|
||||
"<tr><td></td><td align=\"right\"><span style=\"font-size:small;color:gray;\">You</span></td></tr>"
|
||||
"</table>");
|
||||
}
|
||||
case ChatBlock::Assistant: {
|
||||
QList<SubBlock> subs = parseSubBlocks(block.content);
|
||||
QString inner;
|
||||
for (const auto &sub : subs)
|
||||
inner += subBlockToHtml(sub);
|
||||
|
||||
return QStringLiteral(
|
||||
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:8px 0;\">"
|
||||
"<tr><td style=\"background:") + assistantBg +
|
||||
QStringLiteral(";padding:10px 14px;border:1px solid ") + borderColor +
|
||||
QStringLiteral(";\">") + inner +
|
||||
QStringLiteral("</td><td width=\"5%\"></td></tr>"
|
||||
"<tr><td><span style=\"font-size:small;color:gray;\">Ollie</span></td><td></td></tr>"
|
||||
"</table>");
|
||||
}
|
||||
case ChatBlock::Reasoning: {
|
||||
// Show abbreviated with dimmed styling
|
||||
QString abbreviated = block.content.left(200);
|
||||
if (block.content.size() > 200)
|
||||
abbreviated += QStringLiteral("...");
|
||||
|
||||
return QStringLiteral(
|
||||
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:4px 0;\">"
|
||||
"<tr><td style=\"background:") + metaBg +
|
||||
QStringLiteral(";padding:6px 10px;border-left:3px solid ") + metaColor +
|
||||
QStringLiteral(";\">"
|
||||
"<div style=\"font-size:small;color:") + metaColor +
|
||||
QStringLiteral(";\">▸ reasoning</div>"
|
||||
"<pre style=\"margin:4px 0;white-space:pre-wrap;color:") + metaColor +
|
||||
QStringLiteral(";font-size:small;\">") + escapeHtml(abbreviated) +
|
||||
QStringLiteral("</pre></td></tr></table>");
|
||||
}
|
||||
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(
|
||||
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:4px 0;\">"
|
||||
"<tr><td style=\"background:") + metaBg +
|
||||
QStringLiteral(";padding:6px 10px;border-left:3px solid #ff9800;\">"
|
||||
"<div style=\"font-size:small;color:#ff9800;\">") + label +
|
||||
QStringLiteral("</div>"
|
||||
"<pre style=\"margin:4px 0;white-space:pre-wrap;color:") + metaColor +
|
||||
QStringLiteral(";font-size:small;\">") + escapeHtml(abbreviated) +
|
||||
QStringLiteral("</pre></td></tr></table>");
|
||||
}
|
||||
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(
|
||||
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:4px 0;\">"
|
||||
"<tr><td style=\"background:") + metaBg +
|
||||
QStringLiteral(";padding:6px 10px;border-left:3px solid ") + metaColor +
|
||||
QStringLiteral(";\">"
|
||||
"<div style=\"font-size:small;color:") + metaColor +
|
||||
QStringLiteral(";\">" ) + label +
|
||||
QStringLiteral("</div>"
|
||||
"<pre style=\"margin:4px 0;white-space:pre-wrap;color:") + metaColor +
|
||||
QStringLiteral(";font-size:small;\">") + escapeHtml(abbreviated) +
|
||||
QStringLiteral("</pre></td></tr></table>");
|
||||
}
|
||||
case ChatBlock::Error: {
|
||||
return QStringLiteral(
|
||||
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:6px 0;\">"
|
||||
"<tr><td style=\"background:") + errorBg +
|
||||
QStringLiteral(";padding:8px 12px;border-left:3px solid #e53935;\">"
|
||||
"<pre style=\"margin:0;white-space:pre-wrap;color:#e53935;\">") +
|
||||
escapeHtml(block.content) +
|
||||
QStringLiteral("</pre></td></tr></table>");
|
||||
}
|
||||
case ChatBlock::Info: {
|
||||
return QStringLiteral(
|
||||
"<div style=\"margin:4px 0;font-style:italic;color:") + metaColor +
|
||||
QStringLiteral(";font-size:small;\">") +
|
||||
escapeHtml(block.content) + QStringLiteral("</div>");
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// --- Full Render ---
|
||||
|
||||
void ChatRenderer::render(QTextEdit *view, const QString &rawText)
|
||||
{
|
||||
detectTheme();
|
||||
|
||||
QList<ChatBlock> blocks = parseBlocks(rawText);
|
||||
QString html = QStringLiteral(
|
||||
"<html><body style=\"font-family:sans-serif;font-size:medium;\">");
|
||||
for (const auto &block : blocks)
|
||||
html += blockToHtml(block);
|
||||
html += QStringLiteral("</body></html>");
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
#ifndef OLLIE_CHAT_RENDERER_H
|
||||
#define OLLIE_CHAT_RENDERER_H
|
||||
|
||||
#include <QString>
|
||||
#include <QList>
|
||||
#include <QTextEdit>
|
||||
#include <QRegularExpression>
|
||||
|
||||
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<ChatBlock> parseBlocks(const QString &text) const;
|
||||
|
||||
/// Parse a block's content into sub-blocks (prose, code, diff)
|
||||
QList<SubBlock> 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
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
#include "ollie_kate.h"
|
||||
#include "ollie_ghost.h"
|
||||
#include "chat_renderer.h"
|
||||
|
||||
#include <KPluginFactory>
|
||||
#include <QKeyEvent>
|
||||
|
|
@ -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<QString> 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("<p style='color:#e53935;'>Error creating session: " + reply.error().message().toHtmlEscaped() + "</p>");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -530,13 +535,13 @@ void OllieKateView::pickSession()
|
|||
{
|
||||
QString baseDir = projectBaseDir();
|
||||
if (baseDir.isEmpty()) {
|
||||
m_chatView->setPlainText("Cannot determine project directory.");
|
||||
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>Cannot determine project directory.</p>"));
|
||||
return;
|
||||
}
|
||||
|
||||
QDBusReply<QStringList> reply = m_iface->call("ListSessions");
|
||||
if (!reply.isValid() || reply.value().isEmpty()) {
|
||||
m_chatView->setPlainText("No active sessions found.");
|
||||
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No active sessions found.</p>"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -564,13 +569,13 @@ void OllieKateView::pickSession()
|
|||
}
|
||||
}
|
||||
|
||||
m_chatView->setPlainText("No session found with cwd: " + baseDir);
|
||||
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No session found with cwd: ") + baseDir.toHtmlEscaped() + QStringLiteral("</p>"));
|
||||
}
|
||||
|
||||
void OllieKateView::renameSession()
|
||||
{
|
||||
if (m_sessionId.isEmpty()) {
|
||||
m_chatView->setPlainText("No active session to rename.");
|
||||
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No active session to rename.</p>"));
|
||||
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("<p style='color:#e53935;'>Failed to rename session.</p>"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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("<p style='color:#e53935;'>Error: ollie-kde daemon not running.<br>Start with: ollie-kde or rc-service --user ollie-kde start</p>"));
|
||||
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<QStringList> reply = m_iface->call("ListSessions");
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
#include <QAction>
|
||||
#include <QEvent>
|
||||
|
||||
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<OllieGhostProvider> m_ghost;
|
||||
bool m_ghostPrefix = false; // true after Meta+O, waiting for second key
|
||||
|
|
|
|||
Reference in New Issue