kate: simplify plugin — remove chat panel, keep context actions + ghost text

Strip the plugin down to its core value:
- Context menu actions (explain, fix, refactor, document, test, review diff)
- Ghost text completion (unchanged)
- Git++ review diff integration
- Auto agent-per-project matching (silent, no UI)
- Status bar indicator showing current agent

Removed:
- Chat panel (side panel UI, streaming, renderer)
- Inline annotations
- Emoji processing
- Config page (use env vars: OLLIE_COMPLETE_BACKEND, etc)
- Apply code blocks
- libkateprivate/diff viewer integration

3109 LOC → 1192 LOC. Use the KDE GUI app tiled alongside Kate for chat.
This commit is contained in:
Levi Neely 2026-08-06 13:39:30 +02:00
parent 9a1704d728
commit 7aad74dafe
7 changed files with 216 additions and 2161 deletions

View File

@ -21,22 +21,6 @@ include(KDECompilerSettings NO_POLICY_SCOPE)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
# --- Find libkateprivate for Kate's built-in diff viewer ---
file(GLOB _KATEPRIVATE_CANDIDATES
"/usr/lib64/libkateprivate.so*"
"/usr/lib/libkateprivate.so*"
"/usr/lib/x86_64-linux-gnu/libkateprivate.so*"
"/usr/lib/aarch64-linux-gnu/libkateprivate.so*")
if(_KATEPRIVATE_CANDIDATES)
list(GET _KATEPRIVATE_CANDIDATES 0 KATEPRIVATE_LIB)
message(STATUS "Found libkateprivate: ${KATEPRIVATE_LIB}")
set(HAVE_KATE_DIFF TRUE)
else()
set(KATEPRIVATE_LIB "")
set(HAVE_KATE_DIFF FALSE)
message(STATUS "libkateprivate not found -- diffs will render inline")
endif()
if(OLLIE_KF5)
# --- Qt5 / KF5 ---
find_package(Qt5 REQUIRED COMPONENTS Core Concurrent Widgets Quick Qml QuickControls2 Network)
@ -64,7 +48,6 @@ if(OLLIE_KF5)
kcoreaddons_add_plugin(ollie_kate SOURCES
kate/ollie_kate.cpp
kate/ollie_ghost.cpp
kate/chat_renderer.cpp
gui/lib9pclient.cpp
INSTALL_NAMESPACE "ktexteditor"
)
@ -79,11 +62,6 @@ if(OLLIE_KF5)
Qt5::Widgets
$ENV{HOME}/.local/lib/libollie9p.so
)
if(HAVE_KATE_DIFF)
target_compile_definitions(ollie_kate PRIVATE HAVE_KATE_DIFF)
target_include_directories(ollie_kate PRIVATE ${CMAKE_SOURCE_DIR}/kate)
target_link_libraries(ollie_kate ${KATEPRIVATE_LIB})
endif()
# --- GUI App (9P-based) ---
# Strip Qt6-only grouped palette syntax (palette.active.*, palette.inactive.*,
@ -146,7 +124,6 @@ else()
kcoreaddons_add_plugin(ollie_kate SOURCES
kate/ollie_kate.cpp
kate/ollie_ghost.cpp
kate/chat_renderer.cpp
gui/lib9pclient.cpp
INSTALL_NAMESPACE "kf6/ktexteditor"
)
@ -161,11 +138,6 @@ else()
Qt6::Widgets
$ENV{HOME}/.local/lib/libollie9p.so
)
if(HAVE_KATE_DIFF)
target_compile_definitions(ollie_kate PRIVATE HAVE_KATE_DIFF)
target_include_directories(ollie_kate PRIVATE ${CMAKE_SOURCE_DIR}/kate)
target_link_libraries(ollie_kate ${KATEPRIVATE_LIB})
endif()
# --- GUI App (9P-based, no D-Bus) ---
find_package(KF6SyntaxHighlighting REQUIRED)

View File

@ -1,714 +0,0 @@
#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);
static const QRegularExpression endRe(
R"(^\[\[\[end\]\]\]\s*$)",
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();
// Find the [[[end]]] marker after this header (before next header)
int searchEnd = (i + 1 < headers.size()) ? headers[i + 1].first : text.size();
QString segment = text.mid(contentStart, searchEnd - contentStart);
auto endMatch = endRe.match(segment);
int contentEnd;
if (endMatch.hasMatch()) {
contentEnd = contentStart + endMatch.capturedStart();
} else {
// No [[[end]]] found — treat rest until next header as content (streaming)
contentEnd = searchEnd;
}
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 interruptRe(
R"(<system-user-interruption>\n?(.*?)\n?</system-user-interruption>)",
QRegularExpression::DotMatchesEverythingOption);
static const QRegularExpression fenceRe(
R"(^```(\w*)[ \t]*([^\n]*)\n((?:(?!^```).)*?)\n^```\s*$)",
QRegularExpression::MultilineOption | QRegularExpression::DotMatchesEverythingOption);
// Find all interrupt tags
int lastEnd = 0;
auto intIt = interruptRe.globalMatch(content);
QList<QPair<int, QRegularExpressionMatch>> interrupts;
while (intIt.hasNext()) {
auto match = intIt.next();
interrupts.append({match.capturedStart(), match});
}
// Helper: parse a segment for code fences and prose
auto parseSegment = [&](const QString &segment) {
if (segment.trimmed().isEmpty()) return;
int segLastEnd = 0;
auto it = fenceRe.globalMatch(segment);
while (it.hasNext()) {
auto match = it.next();
if (match.capturedStart() > segLastEnd) {
QString prose = segment.mid(segLastEnd, match.capturedStart() - segLastEnd).trimmed();
if (!prose.isEmpty()) {
SubBlock sb;
sb.type = SubBlock::Prose;
sb.content = prose;
result.append(sb);
}
}
QString lang = match.captured(1);
QString meta = match.captured(2).trimmed();
QString code = match.captured(3);
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.fenceMeta = meta;
sb.content = code;
result.append(sb);
segLastEnd = match.capturedEnd();
}
if (segLastEnd < segment.size()) {
QString prose = segment.mid(segLastEnd).trimmed();
if (!prose.isEmpty()) {
SubBlock sb;
sb.type = SubBlock::Prose;
sb.content = prose;
result.append(sb);
}
}
};
if (interrupts.isEmpty()) {
parseSegment(content);
} else {
for (int i = 0; i < interrupts.size(); ++i) {
const auto &[pos, match] = interrupts[i];
if (pos > lastEnd) {
parseSegment(content.mid(lastEnd, pos - lastEnd));
}
SubBlock sb;
sb.type = SubBlock::Interrupt;
sb.content = match.captured(1).trimmed();
result.append(sb);
lastEnd = match.capturedEnd();
}
if (lastEnd < content.size()) {
parseSegment(content.mid(lastEnd));
}
}
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, bool preserveWhitespace) 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: {
// Store diff and render a clickable link to open in Kate's diff viewer
if (m_diffStorage) {
int idx = m_diffStorage->size();
m_diffStorage->append(sub.content);
// Count lines for display
int adds = 0, dels = 0;
const QStringList lines = sub.content.split('\n');
for (const QString &line : lines) {
if (line.startsWith('+') && !line.startsWith("+++")) adds++;
else if (line.startsWith('-') && !line.startsWith("---")) dels++;
}
QString stats;
if (adds > 0) stats += QStringLiteral("<span style=\"color:#4caf50;\">+%1</span> ").arg(adds);
if (dels > 0) stats += QStringLiteral("<span style=\"color:#f44336;\">-%1</span>").arg(dels);
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 12px;\">"
"<span style=\"font-size:small;color:gray;\">▶ diff</span> ") +
stats +
QStringLiteral("<br><a href=\"ollie-diff:///") + QString::number(idx) +
QStringLiteral("\" style=\"color:#2196f3;text-decoration:underline;\">"
"↗ Open in Diff Viewer</a>"
"</td></tr></table>");
}
// Fallback: render inline
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:
if (preserveWhitespace) {
return QStringLiteral("<pre style=\"margin:4px 0;white-space:pre-wrap;font-family:monospace;font-size:small;\">") +
escapeHtml(sub.content) + QStringLiteral("</pre>");
}
return QStringLiteral("<div style=\"margin:4px 0;line-height:1.4;\">") +
renderProse(sub.content) + QStringLiteral("</div>");
case SubBlock::Interrupt: {
QString interruptBg = m_dark ? QStringLiteral("#3d2e00") : QStringLiteral("#fff8e1");
QString interruptBorder = QStringLiteral("#ffc107");
return QStringLiteral(
"<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:8px 0;\">"
"<tr><td style=\"background:") + interruptBg +
QStringLiteral(";padding:8px 12px;border-left:3px solid ") + interruptBorder +
QStringLiteral(";\">"
"<div style=\"font-size:small;color:") + interruptBorder +
QStringLiteral(";font-weight:bold;\">⚠ user interrupt</div>"
"<div style=\"margin:4px 0;line-height:1.4;\">") +
renderProse(sub.content) +
QStringLiteral("</div></td></tr></table>");
}
}
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;
QString lastProse; // track preceding prose for file path extraction
QString lastFilePath; // carry forward across code blocks
for (const auto &sub : subs) {
if (sub.type == SubBlock::Prose) {
lastProse = sub.content;
}
inner += subBlockToHtml(sub);
// Add "Apply" button for code blocks in assistant messages
if (sub.type == SubBlock::Code && m_applyStorage && !sub.content.trimmed().isEmpty()) {
// Parse fence metadata for /path:startLine-endLine
static const QRegularExpression fenceMetaRe(
R"((/[^\s:]+)(?::(\d+)(?:-(\d+))?)?)");
QString filePath;
int startLine = -1, endLine = -1;
auto metaMatch = fenceMetaRe.match(sub.fenceMeta);
if (metaMatch.hasMatch()) {
filePath = metaMatch.captured(1);
lastFilePath = filePath;
if (!metaMatch.captured(2).isEmpty())
startLine = metaMatch.captured(2).toInt();
if (!metaMatch.captured(3).isEmpty())
endLine = metaMatch.captured(3).toInt();
else if (startLine > 0)
endLine = startLine; // single line
} else {
// Fall back to extracting path from preceding prose
static const QRegularExpression filePathRe(
R"((?:`|\b)(/[^\s:`]+\.\w+)(?:`|\b))");
auto proseMatch = filePathRe.match(lastProse);
if (proseMatch.hasMatch()) {
filePath = proseMatch.captured(1);
lastFilePath = filePath;
} else {
filePath = lastFilePath;
}
}
int idx = m_applyStorage->size();
ApplyBlock ab;
ab.filePath = filePath;
ab.code = sub.content;
ab.startLine = startLine;
ab.endLine = endLine;
m_applyStorage->append(ab);
QString rangeStr;
if (startLine > 0 && endLine > 0) {
rangeStr = QStringLiteral(":%1-%2").arg(startLine).arg(endLine);
}
QString applyLabel = filePath.isEmpty()
? QStringLiteral("✎ Apply")
: QStringLiteral("✎ Apply to ") + escapeHtml(filePath.section('/', -1)) + rangeStr;
inner += QStringLiteral(
"<div style=\"margin:-4px 0 6px 0;\">"
"<a href=\"ollie-apply:///") + QString::number(idx) +
QStringLiteral("\" style=\"color:#4caf50;text-decoration:none;"
"font-size:small;padding:2px 8px;"
"border:1px solid #4caf50;border-radius:3px;\">") +
applyLabel + QStringLiteral("</a></div>");
}
}
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);
// Render full call args with sub-block parsing (preserve whitespace)
QList<SubBlock> subs = parseSubBlocks(block.content);
QString inner;
for (const auto &sub : subs)
inner += subBlockToHtml(sub, true);
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>") + inner +
QStringLiteral("</td></tr></table>");
}
case ChatBlock::Tool: {
QString label = QStringLiteral("◆ tool");
if (!block.name.isEmpty())
label += QStringLiteral(": ") + escapeHtml(block.name);
// Render full tool output with sub-block parsing (preserve whitespace)
QList<SubBlock> subs = parseSubBlocks(block.content);
QString inner;
for (const auto &sub : subs)
inner += subBlockToHtml(sub, true);
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>") + inner +
QStringLiteral("</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();
// Clear storage before re-render (blocks get re-indexed)
if (m_diffStorage)
m_diffStorage->clear();
if (m_applyStorage)
m_applyStorage->clear();
m_allBlocks = parseBlocks(rawText);
// Only render the tail window
m_renderStart = qMax(0, m_allBlocks.size() - m_windowSize);
QString html = QStringLiteral(
"<html><body style=\"font-family:sans-serif;font-size:medium;\">");
// Indicate truncation if there are older blocks
if (m_renderStart > 0) {
html += QStringLiteral(
"<div id=\"ollie-load-more\" style=\"text-align:center;padding:8px;"
"color:gray;font-size:small;font-style:italic;\">↑ "
"Scroll up to load earlier messages (%1 more) ↑</div>")
.arg(m_renderStart);
}
for (int i = m_renderStart; i < m_allBlocks.size(); ++i)
html += blockToHtml(m_allBlocks[i]);
html += QStringLiteral("</body></html>");
auto *sb = view->verticalScrollBar();
bool atBottom = (sb->maximum() <= 0) || (sb->value() >= sb->maximum() - 4);
int savedPos = sb->value();
// Block signals to prevent intermediate scroll events during setHtml
sb->blockSignals(true);
view->setHtml(html);
if (atBottom) {
// Move cursor to end and ensure visible — this forces layout and
// scrolls reliably, unlike sb->setValue(sb->maximum()) which can
// read a stale maximum before relayout completes.
QTextCursor cursor = view->textCursor();
cursor.movePosition(QTextCursor::End);
view->setTextCursor(cursor);
view->ensureCursorVisible();
} else {
// Restore exact scroll position — clamp to new maximum
sb->setValue(qMin(savedPos, sb->maximum()));
}
sb->blockSignals(false);
m_lastRawText = rawText;
m_lastBlockCount = m_allBlocks.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;
}
int ChatRenderer::prependOlder(QTextEdit *view, int count)
{
if (m_renderStart <= 0)
return 0;
detectTheme();
int loadFrom = qMax(0, m_renderStart - count);
int loaded = m_renderStart - loadFrom;
if (loaded <= 0)
return 0;
m_renderStart = loadFrom;
// Re-render all currently-visible blocks (now including the older ones)
auto *sb = view->verticalScrollBar();
int oldMax = sb->maximum();
int oldVal = sb->value();
sb->blockSignals(true);
QString html = QStringLiteral(
"<html><body style=\"font-family:sans-serif;font-size:medium;\">");
if (m_renderStart > 0) {
html += QStringLiteral(
"<div id=\"ollie-load-more\" style=\"text-align:center;padding:8px;"
"color:gray;font-size:small;font-style:italic;\">↑ "
"Scroll up to load earlier messages (%1 more) ↑</div>")
.arg(m_renderStart);
}
for (int i = m_renderStart; i < m_allBlocks.size(); ++i)
html += blockToHtml(m_allBlocks[i]);
html += QStringLiteral("</body></html>");
view->setHtml(html);
// Restore scroll position offset by the added content height
int newMax = sb->maximum();
int addedHeight = newMax - oldMax;
sb->setValue(oldVal + addedHeight);
sb->blockSignals(false);
return loaded;
}

View File

@ -1,126 +0,0 @@
#ifndef OLLIE_CHAT_RENDERER_H
#define OLLIE_CHAT_RENDERER_H
#include <QString>
#include <QList>
#include <QStringList>
#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, Interrupt };
Type type;
QString language;
QString fenceMeta; // extra text after language on the fence line (e.g., "/path:10-20")
QString content;
};
/**
* ChatRenderer parses ollie chat output into structured blocks
* and renders them as styled HTML in a QTextEdit.
*
* To avoid performance degradation on long conversations, only a
* tail window of blocks is eagerly rendered. Earlier blocks are
* loaded on demand when the user scrolls up.
*/
class ChatRenderer
{
public:
ChatRenderer();
~ChatRenderer();
/// Set external diff storage. When set, diffs are stored here and
/// rendered as clickable links rather than inline.
void setDiffStorage(QStringList *storage) { m_diffStorage = storage; }
/// Storage for apply-able code blocks. Each entry is the code content.
/// Rendered with a clickable "Apply" link.
struct ApplyBlock {
QString filePath;
QString code;
int startLine = -1; // 1-indexed, inclusive. -1 = insert at cursor
int endLine = -1; // 1-indexed, inclusive. -1 = insert at cursor
};
void setApplyStorage(QList<ApplyBlock> *storage) { m_applyStorage = storage; }
/// Maximum number of blocks to render eagerly (tail window).
static constexpr int DefaultWindowSize = 40;
void setWindowSize(int n) { m_windowSize = n; }
int windowSize() const { return m_windowSize; }
/// 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 the tail window of blocks into the QTextEdit.
/// Parses all blocks but only generates HTML for the last windowSize().
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);
/// Prepend older blocks into the view. Returns the number of
/// blocks actually prepended (0 if all blocks are already shown).
int prependOlder(QTextEdit *view, int count);
/// True if there are older blocks not yet rendered.
bool hasOlderBlocks() const { return m_renderStart > 0; }
/// Total number of parsed blocks.
int totalBlocks() const { return m_allBlocks.size(); }
/// Number of blocks currently rendered.
int renderedBlocks() const { return m_allBlocks.size() - m_renderStart; }
private:
QString blockToHtml(const ChatBlock &block) const;
QString subBlockToHtml(const SubBlock &sub, bool preserveWhitespace = false) 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;
QStringList *m_diffStorage = nullptr;
QList<ApplyBlock> *m_applyStorage = nullptr;
QString m_lastRawText; // for incremental rendering
int m_lastBlockCount = 0;
// Windowed rendering state
QList<ChatBlock> m_allBlocks; // all parsed blocks
int m_renderStart = 0; // index of first rendered block
int m_windowSize = DefaultWindowSize;
// Colors (derived from palette)
mutable bool m_dark = false;
void detectTheme() const;
};
#endif // OLLIE_CHAT_RENDERER_H

View File

@ -1,44 +0,0 @@
/*
SPDX-FileCopyrightText: 2022 Waqar Ahmed <waqar.17a@gmail.com>
SPDX-License-Identifier: LGPL-2.0-or-later
Minimal copy of Kate's DiffParams for external plugin use.
*/
#pragma once
#include <QMetaType>
#include <QString>
#include <QStringList>
#include <functional>
struct DiffParams {
enum Flag {
ShowStage = 1,
ShowUnstage = 2,
ShowDiscard = 4,
ShowFileName = 8,
ShowCommitInfo = 16,
ReloadOnShow = 32,
ShowFullContext = 64,
ShowEditLeftSide = 128,
ShowEditRightSide = 256
};
Q_DECLARE_FLAGS(Flags, Flag)
QString tabTitle;
QString srcFile;
QString destFile;
QString workingDir;
QStringList arguments;
Flags flags;
void clear()
{
tabTitle = srcFile = destFile = workingDir = QString();
arguments.clear();
flags = {};
updateStatusCallback = nullptr;
}
std::function<void()> updateStatusCallback;
};

View File

@ -1,16 +0,0 @@
/*
SPDX-FileCopyrightText: 2022 Waqar Ahmed <waqar.17a@gmail.com>
SPDX-License-Identifier: LGPL-2.0-or-later
Minimal declaration of Utils::showDiff for external plugin use.
*/
#pragma once
#include "diffparams.h"
#include <QByteArray>
namespace KTextEditor { class MainWindow; }
namespace Utils {
void showDiff(const QByteArray &diff, const DiffParams &params, KTextEditor::MainWindow *mainWindow);
}

File diff suppressed because it is too large Load Diff

View File

@ -2,28 +2,19 @@
#define OLLIE_KATE_PLUGIN_H
#include <KTextEditor/Plugin>
#include <KTextEditor/ConfigPage>
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
#if QT_VERSION_MAJOR >= 6
#include <KPluginMetaData>
#endif
#include <QProcess>
#include <QPointer>
#include <QTextBrowser>
#include <QPlainTextEdit>
#include <QLabel>
#include <QAction>
#include <QEvent>
#include <QStandardPaths>
#include <KTextEditor/MovingRange>
#include <KTextEditor/Attribute>
#include "chat_renderer.h"
#include "lib9pclient.h"
class QPushButton;
class OllieKateView;
class OllieGhostProvider;
@ -40,37 +31,18 @@ public:
~OllieKatePlugin() override = default;
QObject *createView(KTextEditor::MainWindow *mainWindow) override;
int configPages() const override;
KTextEditor::ConfigPage *configPage(int number, QWidget *parent) override;
// Completion config accessors
QString completionBackend() const;
QString completionModel() const;
void setCompletionBackend(const QString &backend);
void setCompletionModel(const QString &model);
bool completionEnabled() const;
void setCompletionEnabled(bool enabled);
// Default session backend/model (overrides OLLIE_BACKEND / OLLIE_MODEL)
QString defaultBackend() const;
QString defaultModel() const;
void setDefaultBackend(const QString &backend);
void setDefaultModel(const QString &model);
// Diff view mode: "unified", "side-by-side", "raw"
QString diffMode() const;
void setDiffMode(const QString &mode);
QString completionBackend() const { return m_completionBackend; }
QString completionModel() const { return m_completionModel; }
bool completionEnabled() const { return m_completionEnabled; }
private:
void loadConfig();
void saveConfig();
QString m_completionBackend;
QString m_completionModel;
bool m_completionEnabled = true;
QString m_defaultBackend;
QString m_defaultModel;
QString m_diffMode;
};
class OllieKateView : public QObject
@ -87,83 +59,40 @@ protected:
bool eventFilter(QObject *obj, QEvent *event) override;
private slots:
void onChatUpdated(const QString &session_id, qlonglong offset, const QString &new_text);
void onStateChanged(const QString &session_id, const QString &new_state);
void onSessionCreated(const QString &session_id);
void onSessionKilled(const QString &session_id);
void onProjectChanged();
void submitPrompt();
private:
// Actions
void askAboutFile();
void explainSelection();
void newAgent();
void pickAgent();
void refreshSessions();
void submitSelectionPrompt(const QString &action);
void reviewDiff();
private:
// Agent management
void ensureAgent();
bool selectAgentForProject();
void switchToAgent(const QString &agentId);
bool selectAgentForProject(); // Auto-select agent matching project basedir
QString activeSessionId();
QString projectBaseDir();
QString agentPath() const;
void updateStatusLabel();
// Context menu
void setupContextMenu(KTextEditor::View *view);
void setupGitPlusPlusDiffAction();
// Ghost text
void setupGhostProvider();
OllieKatePlugin *m_plugin;
KTextEditor::MainWindow *m_mainWindow;
QWidget *m_toolView;
QLabel *m_sessionLabel;
QLabel *m_cwdLabel;
QLabel *m_completionStatus;
QTextBrowser *m_chatView;
QPlainTextEdit *m_promptInput;
QAction *m_askFileAction;
QAction *m_explainAction;
QAction *m_newSessionAction;
QPushButton *m_stopBtn;
QPushButton *m_scrollBottomBtn;
// 9P client (direct connection, no shell-out)
Lib9pClient *m_9p;
QString agentPath() const; // Returns "session/kate/agent/{m_agentId}"
// Streaming processes
QProcess *m_chatProc = nullptr;
QProcess *m_stateProc = nullptr;
void startChatStream();
void startStateStream();
void stopStreams();
QString m_sessionId;
QString m_agentId; // Agent within the kate session
qlonglong m_chatOffset = 0;
QString m_rawChat; // accumulated raw chat text
ChatRenderer *m_renderer;
QStringList m_diffBlocks; // stored diff content for viewer
void showDiffBlock(int index);
void onAnchorClicked(const QUrl &url);
QString m_agentId;
QLabel *m_statusLabel = nullptr;
QPointer<OllieGhostProvider> m_ghost;
bool m_ghostPrefix = false; // true after Meta+O, waiting for second key
void processEmojis();
QString projectBaseDir();
void switchToSession(const QString &sessionId);
void setupGhostProvider();
// --- Editor Integration ---
void setupContextMenu(KTextEditor::View *view);
void setupGitPlusPlusDiffAction();
void submitSelectionPrompt(const QString &action);
void reviewDiff();
void applyCodeBlock(int index);
void clearAnnotations();
void annotateFileReferences(const QString &chatText);
QList<ChatRenderer::ApplyBlock> m_applyBlocks;
bool m_loadingOlder = false; // guard against recursive scroll-triggered loads
// Inline annotations (MovingRanges highlighting lines the agent references)
QList<KTextEditor::MovingRange *> m_annotations;
QPointer<KTextEditor::Document> m_annotationDoc;
bool m_ghostPrefix = false;
};
#endif // OLLIE_KATE_PLUGIN_H