diff --git a/CMakeLists.txt b/CMakeLists.txt index f86e0ba..d26254f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/kate/chat_renderer.cpp b/kate/chat_renderer.cpp deleted file mode 100644 index 49d8cc5..0000000 --- a/kate/chat_renderer.cpp +++ /dev/null @@ -1,714 +0,0 @@ -#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); - - static const QRegularExpression endRe( - R"(^\[\[\[end\]\]\]\s*$)", - 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(); - - // 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 ChatRenderer::parseSubBlocks(const QString &content) const -{ - QList result; - if (content.isEmpty()) return result; - - static const QRegularExpression interruptRe( - R"(\n?(.*?)\n?)", - 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> 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("
") + 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, 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( - "
") + - escapeHtml(sub.language) + QStringLiteral("
"); - } - return QStringLiteral( - "" - "
") + - langLabel + highlightCode(sub.content, sub.language) + - QStringLiteral("
"); - } - 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("+%1 ").arg(adds); - if (dels > 0) stats += QStringLiteral("-%1").arg(dels); - - return QStringLiteral( - "" - "
" - "▶ diff ") + - stats + - QStringLiteral("
" - "↗ Open in Diff Viewer" - "
"); - } - - // Fallback: render inline - return QStringLiteral( - "" - "
" - "
diff
") + - renderDiff(sub.content) + - QStringLiteral("
"); - } - case SubBlock::Prose: - if (preserveWhitespace) { - return QStringLiteral("
") +
-                escapeHtml(sub.content) + QStringLiteral("
"); - } - return QStringLiteral("
") + - renderProse(sub.content) + QStringLiteral("
"); - case SubBlock::Interrupt: { - QString interruptBg = m_dark ? QStringLiteral("#3d2e00") : QStringLiteral("#fff8e1"); - QString interruptBorder = QStringLiteral("#ffc107"); - return QStringLiteral( - "" - "
" - "
⚠ user interrupt
" - "
") + - 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; - 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( - ""); - } - } - - 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); - - // Render full call args with sub-block parsing (preserve whitespace) - QList subs = parseSubBlocks(block.content); - QString inner; - for (const auto &sub : subs) - inner += subBlockToHtml(sub, true); - - return QStringLiteral( - "" - "
" - "
") + label + - QStringLiteral("
") + inner + - QStringLiteral("
"); - } - 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 subs = parseSubBlocks(block.content); - QString inner; - for (const auto &sub : subs) - inner += subBlockToHtml(sub, true); - - return QStringLiteral( - "" - "
" - "
") + label + - QStringLiteral("
") + inner + - 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(); - - // 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( - ""); - - // Indicate truncation if there are older blocks - if (m_renderStart > 0) { - html += QStringLiteral( - "
↑ " - "Scroll up to load earlier messages (%1 more) ↑
") - .arg(m_renderStart); - } - - for (int i = m_renderStart; i < m_allBlocks.size(); ++i) - html += blockToHtml(m_allBlocks[i]); - html += QStringLiteral(""); - - 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( - ""); - if (m_renderStart > 0) { - html += QStringLiteral( - "
↑ " - "Scroll up to load earlier messages (%1 more) ↑
") - .arg(m_renderStart); - } - for (int i = m_renderStart; i < m_allBlocks.size(); ++i) - html += blockToHtml(m_allBlocks[i]); - html += QStringLiteral(""); - - 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; -} diff --git a/kate/chat_renderer.h b/kate/chat_renderer.h deleted file mode 100644 index 189b1a6..0000000 --- a/kate/chat_renderer.h +++ /dev/null @@ -1,126 +0,0 @@ -#ifndef OLLIE_CHAT_RENDERER_H -#define OLLIE_CHAT_RENDERER_H - -#include -#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, 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 *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 parseBlocks(const QString &text) const; - - /// Parse a block's content into sub-blocks (prose, code, diff) - QList 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 *m_applyStorage = nullptr; - QString m_lastRawText; // for incremental rendering - int m_lastBlockCount = 0; - - // Windowed rendering state - QList 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 diff --git a/kate/diffparams.h b/kate/diffparams.h deleted file mode 100644 index f7d618f..0000000 --- a/kate/diffparams.h +++ /dev/null @@ -1,44 +0,0 @@ -/* - SPDX-FileCopyrightText: 2022 Waqar Ahmed - SPDX-License-Identifier: LGPL-2.0-or-later - - Minimal copy of Kate's DiffParams for external plugin use. -*/ -#pragma once - -#include -#include -#include -#include - -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 updateStatusCallback; -}; diff --git a/kate/ktexteditor_utils.h b/kate/ktexteditor_utils.h deleted file mode 100644 index a7b22f1..0000000 --- a/kate/ktexteditor_utils.h +++ /dev/null @@ -1,16 +0,0 @@ -/* - SPDX-FileCopyrightText: 2022 Waqar Ahmed - SPDX-License-Identifier: LGPL-2.0-or-later - - Minimal declaration of Utils::showDiff for external plugin use. -*/ -#pragma once - -#include "diffparams.h" -#include - -namespace KTextEditor { class MainWindow; } - -namespace Utils { -void showDiff(const QByteArray &diff, const DiffParams ¶ms, KTextEditor::MainWindow *mainWindow); -} diff --git a/kate/ollie_kate.cpp b/kate/ollie_kate.cpp index 3ffc499..61a78b0 100644 --- a/kate/ollie_kate.cpp +++ b/kate/ollie_kate.cpp @@ -1,41 +1,23 @@ -// TODO #include "ollie_kate.h" #include "ollie_ghost.h" -#ifdef HAVE_KATE_DIFF -#include "ktexteditor_utils.h" -#endif - #include -#include +#include +#include #include #include #include #if KTEXTEDITOR_VERSION_MAJOR < 6 #include -#include #endif -#include -#include -#include -#include -#include #include -#include #include #include -#include -#include -#include -#include -#include -#include +#include #include +#include #include -#include -#include -#include K_PLUGIN_CLASS_WITH_JSON(OllieKatePlugin, "metadata.json") @@ -63,195 +45,12 @@ QObject *OllieKatePlugin::createView(KTextEditor::MainWindow *mainWindow) return new OllieKateView(this, mainWindow); } -int OllieKatePlugin::configPages() const -{ - return 1; -} - -KTextEditor::ConfigPage *OllieKatePlugin::configPage(int number, QWidget *parent) -{ - if (number != 0) return nullptr; - - // Inline config page - class OllieConfigPage : public KTextEditor::ConfigPage - { - public: - OllieConfigPage(OllieKatePlugin *plugin, QWidget *parent) - : KTextEditor::ConfigPage(parent), m_plugin(plugin) - { - auto *layout = new QFormLayout(this); - - // Default session backend/model - m_defaultBackendEdit = new QLineEdit(this); - m_defaultBackendEdit->setPlaceholderText(QStringLiteral("(use OLLIE_BACKEND)")); - m_defaultModelEdit = new QLineEdit(this); - m_defaultModelEdit->setPlaceholderText(QStringLiteral("(use OLLIE_MODEL)")); - - layout->addRow(QStringLiteral("Default backend:"), m_defaultBackendEdit); - layout->addRow(QStringLiteral("Default model:"), m_defaultModelEdit); - - auto *defaultNote = new QLabel( - QStringLiteral("Override OLLIE_BACKEND / OLLIE_MODEL for chat sessions."), - this); - defaultNote->setWordWrap(true); - defaultNote->setStyleSheet(QStringLiteral("color: gray;")); - layout->addRow(defaultNote); - - // Completion backend/model - m_backendEdit = new QLineEdit(this); - m_backendEdit->setPlaceholderText(QStringLiteral("(use OLLIE_COMPLETE_BACKEND)")); - m_modelEdit = new QLineEdit(this); - m_modelEdit->setPlaceholderText(QStringLiteral("(use OLLIE_COMPLETE_MODEL)")); - - layout->addRow(QStringLiteral("Completion backend:"), m_backendEdit); - layout->addRow(QStringLiteral("Completion model:"), m_modelEdit); - - auto *note = new QLabel( - QStringLiteral("Override OLLIE_COMPLETE_BACKEND / OLLIE_COMPLETE_MODEL for ghost text."), - this); - note->setWordWrap(true); - note->setStyleSheet(QStringLiteral("color: gray;")); - layout->addRow(note); - - m_enabledCheck = new QCheckBox(QStringLiteral("Completion enabled on startup"), this); - layout->addRow(m_enabledCheck); - - // Diff viewer mode - auto *diffSep = new QLabel(QString(), this); - layout->addRow(diffSep); - - m_diffModeCombo = new QComboBox(this); - m_diffModeCombo->addItem(QStringLiteral("Unified"), QStringLiteral("unified")); - m_diffModeCombo->addItem(QStringLiteral("Side by Side"), QStringLiteral("side-by-side")); - m_diffModeCombo->addItem(QStringLiteral("Raw"), QStringLiteral("raw")); - layout->addRow(QStringLiteral("Diff view style:"), m_diffModeCombo); - - auto *diffNote = new QLabel( - QStringLiteral("How diffs are displayed in Kate's diff viewer."), - this); - diffNote->setWordWrap(true); - diffNote->setStyleSheet(QStringLiteral("color: gray;")); - layout->addRow(diffNote); - - reset(); - - connect(m_enabledCheck, &QCheckBox::toggled, this, &ConfigPage::changed); - connect(m_backendEdit, &QLineEdit::textChanged, this, &ConfigPage::changed); - connect(m_modelEdit, &QLineEdit::textChanged, this, &ConfigPage::changed); - connect(m_defaultBackendEdit, &QLineEdit::textChanged, this, &ConfigPage::changed); - connect(m_defaultModelEdit, &QLineEdit::textChanged, this, &ConfigPage::changed); - connect(m_diffModeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ConfigPage::changed); - } - - QString name() const override { return QStringLiteral("Ollie"); } - QString fullName() const override { return QStringLiteral("Ollie Completion Settings"); } - QIcon icon() const override { return QIcon::fromTheme(QStringLiteral("system-run")); } - - void apply() override { - m_plugin->setDefaultBackend(m_defaultBackendEdit->text().trimmed()); - m_plugin->setDefaultModel(m_defaultModelEdit->text().trimmed()); - m_plugin->setCompletionBackend(m_backendEdit->text().trimmed()); - m_plugin->setCompletionModel(m_modelEdit->text().trimmed()); - m_plugin->setCompletionEnabled(m_enabledCheck->isChecked()); - m_plugin->setDiffMode(m_diffModeCombo->currentData().toString()); - } - void reset() override { - m_defaultBackendEdit->setText(m_plugin->defaultBackend()); - m_defaultModelEdit->setText(m_plugin->defaultModel()); - m_backendEdit->setText(m_plugin->completionBackend()); - m_modelEdit->setText(m_plugin->completionModel()); - m_enabledCheck->setChecked(m_plugin->completionEnabled()); - int idx = m_diffModeCombo->findData(m_plugin->diffMode()); - m_diffModeCombo->setCurrentIndex(idx >= 0 ? idx : 0); - } - void defaults() override { - m_defaultBackendEdit->clear(); - m_defaultModelEdit->clear(); - m_backendEdit->clear(); - m_modelEdit->clear(); - m_enabledCheck->setChecked(true); - m_diffModeCombo->setCurrentIndex(0); - } - - private: - OllieKatePlugin *m_plugin; - QLineEdit *m_defaultBackendEdit; - QLineEdit *m_defaultModelEdit; - QLineEdit *m_backendEdit; - QLineEdit *m_modelEdit; - QCheckBox *m_enabledCheck; - QComboBox *m_diffModeCombo; - }; - - return new OllieConfigPage(this, parent); -} - -QString OllieKatePlugin::completionBackend() const { return m_completionBackend; } -QString OllieKatePlugin::completionModel() const { return m_completionModel; } - -void OllieKatePlugin::setCompletionBackend(const QString &backend) -{ - m_completionBackend = backend; - saveConfig(); -} - -void OllieKatePlugin::setCompletionModel(const QString &model) -{ - m_completionModel = model; - saveConfig(); -} - -bool OllieKatePlugin::completionEnabled() const { return m_completionEnabled; } - -void OllieKatePlugin::setCompletionEnabled(bool enabled) -{ - m_completionEnabled = enabled; - saveConfig(); -} - -QString OllieKatePlugin::defaultBackend() const { return m_defaultBackend; } -QString OllieKatePlugin::defaultModel() const { return m_defaultModel; } -QString OllieKatePlugin::diffMode() const { return m_diffMode; } - -void OllieKatePlugin::setDefaultBackend(const QString &backend) -{ - m_defaultBackend = backend; - saveConfig(); -} - -void OllieKatePlugin::setDefaultModel(const QString &model) -{ - m_defaultModel = model; - saveConfig(); -} - -void OllieKatePlugin::setDiffMode(const QString &mode) -{ - m_diffMode = mode; - saveConfig(); -} - void OllieKatePlugin::loadConfig() { KConfigGroup grp(KSharedConfig::openConfig(), QStringLiteral("Ollie")); m_completionBackend = grp.readEntry("CompletionBackend", QString()); m_completionModel = grp.readEntry("CompletionModel", QString()); m_completionEnabled = grp.readEntry("CompletionEnabled", true); - m_defaultBackend = grp.readEntry("DefaultBackend", QString()); - m_defaultModel = grp.readEntry("DefaultModel", QString()); - m_diffMode = grp.readEntry("DiffMode", QStringLiteral("unified")); -} - -void OllieKatePlugin::saveConfig() -{ - KConfigGroup grp(KSharedConfig::openConfig(), QStringLiteral("Ollie")); - grp.writeEntry("CompletionBackend", m_completionBackend); - grp.writeEntry("CompletionModel", m_completionModel); - grp.writeEntry("CompletionEnabled", m_completionEnabled); - grp.writeEntry("DefaultBackend", m_defaultBackend); - grp.writeEntry("DefaultModel", m_defaultModel); - grp.writeEntry("DiffMode", m_diffMode); - grp.sync(); } // --- View --- @@ -260,487 +59,128 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m : QObject(mainWindow) , m_plugin(plugin) , m_mainWindow(mainWindow) - , m_renderer(new ChatRenderer()) , m_9p(new Lib9pClient(this)) { - m_renderer->setDiffStorage(&m_diffBlocks); - m_renderer->setApplyStorage(&m_applyBlocks); - // Connect to 9P server if (!m_9p->connectDefault()) { qWarning() << "Ollie: failed to connect to 9P server:" << m_9p->lastError(); } - // Create tool view (side panel) - m_toolView = m_mainWindow->createToolView( - plugin, - "ollie_panel", - KTextEditor::MainWindow::Right, - QIcon::fromTheme("system-run"), - "Ollie"); - - auto *container = new QWidget(m_toolView); - auto *layout = new QVBoxLayout(container); - layout->setContentsMargins(2, 2, 2, 2); - layout->setSpacing(2); - - // Session indicator + kill button - auto *headerLayout = new QHBoxLayout(); - headerLayout->setContentsMargins(0, 0, 0, 0); - m_sessionLabel = new QLabel("No session", container); - m_sessionLabel->setStyleSheet(QStringLiteral("color: gray; padding: 2px;")); - m_sessionLabel->setFont(QApplication::font()); - headerLayout->addWidget(m_sessionLabel); - headerLayout->addStretch(); - - auto *killBtn = new QPushButton("×", container); - killBtn->setFixedSize(20, 20); - killBtn->setToolTip("Kill session"); - killBtn->setFlat(true); - connect(killBtn, &QPushButton::clicked, this, [this]() { - if (m_sessionId.isEmpty()) return; - m_9p->write(agentPath() + "/ctl", "kill"); - }); - headerLayout->addWidget(killBtn); - layout->addLayout(headerLayout); - - // CWD label - m_cwdLabel = new QLabel("", container); - QFont smallFont = QApplication::font(); - smallFont.setPointSizeF(smallFont.pointSizeF() * 0.85); - m_cwdLabel->setStyleSheet(QStringLiteral("color: gray; padding: 0px 2px;")); - m_cwdLabel->setFont(smallFont); - m_cwdLabel->setWordWrap(true); - layout->addWidget(m_cwdLabel); - - // Completion status indicator (in the main window status bar) - bool startEnabled = m_plugin->completionEnabled(); + // Status bar indicator if (auto *win = qobject_cast(m_mainWindow->window())) { - // Reuse existing widget if another view already created it - m_completionStatus = win->statusBar()->findChild(QStringLiteral("ollie_completion_status")); - if (!m_completionStatus) { - m_completionStatus = new QLabel(); - m_completionStatus->setObjectName(QStringLiteral("ollie_completion_status")); - m_completionStatus->setFont(smallFont); - win->statusBar()->addPermanentWidget(m_completionStatus); - } - m_completionStatus->setText(startEnabled - ? QStringLiteral("✦ completion on") - : QStringLiteral("✦ completion off")); - m_completionStatus->setStyleSheet(startEnabled - ? QStringLiteral("color: green; padding: 0px 4px;") - : QStringLiteral("color: gray; padding: 0px 4px;")); - } else { - m_completionStatus = nullptr; + m_statusLabel = new QLabel(); + m_statusLabel->setObjectName(QStringLiteral("ollie_status")); + QFont smallFont = QApplication::font(); + smallFont.setPointSizeF(smallFont.pointSizeF() * 0.85); + m_statusLabel->setFont(smallFont); + m_statusLabel->setStyleSheet(QStringLiteral("color: gray; padding: 0px 4px;")); + win->statusBar()->addPermanentWidget(m_statusLabel); } - // Agent management buttons - auto *buttonBar = new QHBoxLayout(); + // Auto-select agent for current project + selectAgentForProject(); + updateStatusLabel(); - auto *newBtn = new QPushButton("New", container); - newBtn->setToolTip("Create a new agent in this session"); - connect(newBtn, &QPushButton::clicked, this, &OllieKateView::newAgent); - buttonBar->addWidget(newBtn); - - auto *pickBtn = new QPushButton("Pick", container); - pickBtn->setToolTip("Switch to another agent in this session"); - connect(pickBtn, &QPushButton::clicked, this, &OllieKateView::pickAgent); - buttonBar->addWidget(pickBtn); - - buttonBar->addStretch(); - layout->addLayout(buttonBar); - - // Chat view - m_chatView = new QTextBrowser(container); - m_chatView->setReadOnly(true); - m_chatView->setOpenLinks(false); - m_chatView->setOpenExternalLinks(false); - QFont chatFont(QApplication::font()); - chatFont.setPointSize(qMax(12, QApplication::font().pointSize() + 2)); - m_chatView->setFont(chatFont); - m_chatView->setPlaceholderText("No active session.\nClick 'New' or use context actions."); - connect(m_chatView, &QTextBrowser::anchorClicked, this, &OllieKateView::onAnchorClicked); - layout->addWidget(m_chatView); - - // Scroll-to-bottom button (floats over chat view) - m_scrollBottomBtn = new QPushButton("↓", container); - m_scrollBottomBtn->setFixedSize(28, 28); - m_scrollBottomBtn->setToolTip("Scroll to bottom"); - m_scrollBottomBtn->hide(); - connect(m_scrollBottomBtn, &QPushButton::clicked, this, [this]() { - auto *sb = m_chatView->verticalScrollBar(); - sb->setValue(sb->maximum()); - m_scrollBottomBtn->hide(); - }); - // Position over bottom-right of chat view, updated on resize - auto repositionBtn = [this]() { - if (!m_chatView) return; - QPoint pos = m_chatView->geometry().bottomRight() - QPoint(36, 36); - m_scrollBottomBtn->move(pos); - }; - m_chatView->installEventFilter(this); // handle resize in eventFilter - repositionBtn(); - - // Also show/hide on manual scroll + lazy-load older messages - connect(m_chatView->verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int value) { - auto *sb = m_chatView->verticalScrollBar(); - if (value >= sb->maximum() - 4) { - m_scrollBottomBtn->hide(); - } else if (!m_chatView->toPlainText().isEmpty()) { - m_scrollBottomBtn->show(); - } - - // Lazy-load: when scrolled near the top, prepend older blocks - if (value <= 50 && m_renderer->hasOlderBlocks() && !m_loadingOlder) { - m_loadingOlder = true; - m_renderer->prependOlder(m_chatView, 20); - m_loadingOlder = false; - } - }); - - // Prompt input - auto *promptLayout = new QHBoxLayout(); - m_promptInput = new QPlainTextEdit(container); - m_promptInput->setPlaceholderText("Message..."); - m_promptInput->setMaximumHeight(80); - m_promptInput->setTabChangesFocus(true); - m_promptInput->installEventFilter(this); - connect(m_promptInput, &QPlainTextEdit::textChanged, this, &OllieKateView::processEmojis); - promptLayout->addWidget(m_promptInput); - - auto *sendBtn = new QPushButton("⏎", container); - sendBtn->setFixedWidth(30); - connect(sendBtn, &QPushButton::clicked, this, &OllieKateView::submitPrompt); - promptLayout->addWidget(sendBtn); - - m_stopBtn = new QPushButton("■", container); - m_stopBtn->setToolTip("Stop (interrupt the current agent turn)"); - m_stopBtn->setFixedWidth(30); - connect(m_stopBtn, &QPushButton::clicked, this, [this]() { - if (!m_sessionId.isEmpty()) { - m_9p->write(agentPath() + "/ctl", "stop"); - } - }); - promptLayout->addWidget(m_stopBtn); - - layout->addLayout(promptLayout); - - // Start 9P streaming processes - startChatStream(); - startStateStream(); - - // Connect to existing session/agent if any (read-only) - refreshSessions(); - - // Set up ghost-text completion on active view + // Ghost text completion setupGhostProvider(); + + // React to view/project changes connect(mainWindow, &KTextEditor::MainWindow::viewChanged, this, [this](KTextEditor::View *view) { setupGhostProvider(); if (view) setupContextMenu(view); - // Auto-select agent matching the new project selectAgentForProject(); + updateStatusLabel(); }); - // React to Kate project changes QObject *projectView = mainWindow->pluginView("kateprojectplugin"); if (projectView) { QObject::connect(projectView, SIGNAL(projectChanged()), this, SLOT(onProjectChanged())); } #if KTEXTEDITOR_VERSION >= QT_VERSION_CHECK(5, 78, 0) - // Also watch for the project plugin appearing later connect(mainWindow, &KTextEditor::MainWindow::pluginViewCreated, this, [this](const QString &name, QObject *view) { if (name == "kateprojectplugin") { QObject::connect(view, SIGNAL(projectChanged()), this, SLOT(onProjectChanged())); selectAgentForProject(); + updateStatusLabel(); } }); #endif - // Set up context menu on initial active view + // Context menu on initial view if (auto *view = mainWindow->activeView()) setupContextMenu(view); - // Register "Review Diff" action with Git++ plugin (kate-worktree) + // Git++ "Review Diff" action setupGitPlusPlusDiffAction(); } OllieKateView::~OllieKateView() { - clearAnnotations(); - delete m_renderer; delete m_ghost; - delete m_toolView; -} - -// --- Signal handlers --- - -void OllieKateView::onChatUpdated(const QString &session_id, qlonglong offset, const QString &new_text) -{ - if (session_id != m_sessionId) return; - - if (offset == m_chatOffset) { - m_rawChat += new_text; - m_chatOffset += new_text.size(); - m_renderer->render(m_chatView, m_rawChat); - annotateFileReferences(m_rawChat); - } else { - // Offset mismatch — refetch - QByteArray chatData = m_9p->read(agentPath() + "/log"); - if (!chatData.isEmpty()) { - m_rawChat = QString::fromUtf8(chatData); - m_chatOffset = chatData.size(); - m_renderer->render(m_chatView, m_rawChat); - annotateFileReferences(m_rawChat); - m_scrollBottomBtn->hide(); - } + if (m_statusLabel) { + delete m_statusLabel; } } -void OllieKateView::onStateChanged(const QString &session_id, const QString &new_state) +// --- Agent Management --- + +void OllieKateView::ensureAgent() { - if (session_id != m_sessionId) return; - QString dot; - if (new_state == "idle") dot = "○ "; - else if (new_state.startsWith("calling")) dot = "⚡ "; - else dot = "● "; - m_sessionLabel->setText(dot + "kate/" + m_agentId + " [" + new_state + "]"); -} + if (!m_agentId.isEmpty()) return; -void OllieKateView::onSessionCreated(const QString &session_id) -{ - // If we have no session, adopt this one - if (m_sessionId.isEmpty()) { - m_sessionId = session_id; - m_chatView->clear(); - m_chatOffset = 0; - } -} - -void OllieKateView::onSessionKilled(const QString &session_id) -{ - if (session_id == m_sessionId) { - m_sessionId.clear(); - m_chatOffset = 0; - m_rawChat.clear(); - m_chatView->clear(); - m_sessionLabel->setText("No session"); - m_cwdLabel->setText(""); - // Defer session switch to let Qt finish processing the signal. - // Calling render() synchronously here can crash because the QTextDocument - // is still tearing down resources from the clear() above. - QTimer::singleShot(0, this, &OllieKateView::refreshSessions); - } -} - -// --- Actions --- - -void OllieKateView::submitPrompt() -{ - QString text = m_promptInput->toPlainText().trimmed(); - if (text.isEmpty()) return; - - if (m_agentId.isEmpty()) { - m_chatView->setHtml(QStringLiteral("

No agent selected. Click 'New' to create one.

")); - return; - } - - m_9p->write(agentPath() + "/prompt", text.toUtf8()); - m_promptInput->clear(); -} - -void OllieKateView::askAboutFile() -{ - auto *view = m_mainWindow->activeView(); - if (!view || !view->document()) return; - - QString filePath = view->document()->url().toLocalFile(); - if (filePath.isEmpty()) return; - - if (m_agentId.isEmpty()) { - m_chatView->setHtml(QStringLiteral("

No agent selected. Click 'New' to create one.

")); - return; - } - - QString prompt = "Describe and explain this file: " + filePath; - m_9p->write(agentPath() + "/prompt", prompt.toUtf8()); -} - -void OllieKateView::explainSelection() -{ - auto *view = m_mainWindow->activeView(); - if (!view) return; - - QString selection = view->selectionText(); - if (selection.isEmpty()) { - // No selection — explain current line - auto cursor = view->cursorPosition(); - selection = view->document()->line(cursor.line()); - } - if (selection.isEmpty()) return; - - if (m_agentId.isEmpty()) { - m_chatView->setHtml(QStringLiteral("

No agent selected. Click 'New' to create one.

")); - return; - } - - QString filePath = view->document()->url().toLocalFile(); - QString context = filePath.isEmpty() ? "" : " (from " + filePath + ")"; - QString prompt = "Explain this code" + context + ":\n\n```\n" + selection + "\n```"; - m_9p->write(agentPath() + "/prompt", prompt.toUtf8()); -} - -void OllieKateView::newAgent() -{ // Ensure kate session exists if (m_sessionId.isEmpty()) { - QString sessionCfg = "name=kate\n"; - m_9p->write("session/new", sessionCfg.toUtf8()); + m_9p->write("session/new", "name=kate\n"); m_sessionId = "kate"; } - - // If a matching agent already exists for this project, just select it - if (selectAgentForProject()) { - return; - } - - // No matching agent — create one + + // Try to find existing agent for project + if (selectAgentForProject()) return; + + // Create a new agent QString cwd = projectBaseDir(); if (cwd.isEmpty()) cwd = QDir::homePath(); - + QString agentCfg = "cwd=" + cwd + "\n"; QByteArray resp = m_9p->rdwr("session/kate/agent/new", agentCfg.toUtf8()); - if (resp.isEmpty()) { - m_chatView->setHtml(QStringLiteral("

Failed to create agent: ") + - m_9p->lastError().toHtmlEscaped() + QStringLiteral("

")); - return; + if (!resp.isEmpty()) { + m_agentId = QString::fromUtf8(resp).trimmed(); } - - QString newId = QString::fromUtf8(resp).trimmed(); - switchToAgent(newId); -} - -void OllieKateView::pickAgent() -{ - if (m_sessionId.isEmpty()) { - m_chatView->setHtml(QStringLiteral("

No session. Use 'New' to create one.

")); - return; - } - - QStringList agents = m_9p->ls("session/" + m_sessionId + "/agent"); - agents.removeAll("new"); // Filter out the "new" control file - agents.removeAll("idx"); // Filter out the "idx" index file - - if (agents.isEmpty()) { - m_chatView->setHtml(QStringLiteral("

No agents exist. Use 'New' to create one.

")); - return; - } - - if (agents.size() == 1) { - m_chatView->setHtml(QStringLiteral("

Only one agent exists. Use 'New' to create another.

")); - return; - } - - // Build list of agents with their cwd - QStringList items; - for (const QString &aid : agents) { - QString cfgVal = QString::fromUtf8(m_9p->read("session/" + m_sessionId + "/agent/" + aid + "/cfg")).trimmed(); - QString cwd; - for (const QString &line : cfgVal.split(QChar('\n'))) { - if (line.startsWith("cwd=")) { - cwd = line.mid(4); - break; - } - } - QString marker = (aid == m_agentId) ? " ← current" : ""; - items << aid + " (" + cwd + ")" + marker; - } - - bool ok; - QString selected = QInputDialog::getItem( - m_toolView, "Pick Agent", - "Select an agent:", - items, agents.indexOf(m_agentId), false, &ok); - - if (!ok || selected.isEmpty()) return; - - // Extract agent ID from selection - QString newAgentId = selected.split(' ').first(); - if (newAgentId == m_agentId) return; // Already on this agent - - switchToAgent(newAgentId); -} - -void OllieKateView::switchToAgent(const QString &agentId) -{ - stopStreams(); - m_sessionId = "kate"; - m_agentId = agentId; - m_rawChat.clear(); - m_chatView->clear(); - m_chatOffset = 0; - - QString stateVal = QString::fromUtf8(m_9p->read(agentPath() + "/state")).trimmed(); - QString state = stateVal.isEmpty() ? "idle" : stateVal; - QString dot = (state == "idle") ? "○ " : (state.startsWith("calling") ? "⚡ " : "● "); - m_sessionLabel->setText(dot + "kate/" + m_agentId + " [" + state + "]"); - - // Get cwd from agent config - QString cfgVal = QString::fromUtf8(m_9p->read(agentPath() + "/cfg")).trimmed(); - for (const QString &line : cfgVal.split(QChar('\n'))) { - if (line.startsWith("cwd=")) { - m_cwdLabel->setText(line.mid(4)); - break; - } - } - - // Fetch existing chat - QByteArray chatData = m_9p->read(agentPath() + "/log"); - if (!chatData.isEmpty()) { - m_rawChat = QString::fromUtf8(chatData); - m_chatOffset = chatData.size(); - m_renderer->render(m_chatView, m_rawChat); - } - - startChatStream(); - startStateStream(); + updateStatusLabel(); } bool OllieKateView::selectAgentForProject() { QString baseDir = projectBaseDir(); if (baseDir.isEmpty()) return false; - + // Check if kate session exists QByteArray sessCheck = m_9p->read("session/kate/id"); - if (sessCheck.isEmpty()) { - return false; - } + if (sessCheck.isEmpty()) return false; m_sessionId = "kate"; - - // Normalize the project path for comparison + QString canonicalBase = QFileInfo(baseDir).canonicalFilePath(); if (canonicalBase.isEmpty()) canonicalBase = baseDir; while (canonicalBase.endsWith('/')) canonicalBase.chop(1); - - // Read agent index: name\tid\tcwd\tbackend\tmodel per line + + // Read agent index QByteArray idxRaw = m_9p->read("session/kate/agent/idx"); QString idxData = QString::fromUtf8(idxRaw).trimmed(); - + for (const QString &line : idxData.split(QChar('\n'), Qt::SkipEmptyParts)) { QStringList fields = line.split(QChar('\t')); if (fields.size() >= 3) { QString agentId = fields[1]; QString agentCwd = fields[2]; - - // Normalize agent cwd for comparison + QString canonicalCwd = QFileInfo(agentCwd).canonicalFilePath(); if (canonicalCwd.isEmpty()) canonicalCwd = agentCwd; while (canonicalCwd.endsWith('/')) canonicalCwd.chop(1); - + if (canonicalCwd == canonicalBase) { if (agentId != m_agentId) { switchToAgent(agentId); @@ -749,372 +189,62 @@ bool OllieKateView::selectAgentForProject() } } } - return false; } +void OllieKateView::switchToAgent(const QString &agentId) +{ + m_sessionId = "kate"; + m_agentId = agentId; + updateStatusLabel(); +} + void OllieKateView::onProjectChanged() { selectAgentForProject(); + updateStatusLabel(); } -// --- Helpers --- - QString OllieKateView::projectBaseDir() { - // Try Kate's project plugin first QObject *projectView = m_mainWindow->pluginView("kateprojectplugin"); if (projectView) { QString dir = projectView->property("projectBaseDir").toString(); if (!dir.isEmpty()) return dir; } - // Fall back to active file's directory auto *view = m_mainWindow->activeView(); if (view && view->document() && !view->document()->url().toLocalFile().isEmpty()) { return QFileInfo(view->document()->url().toLocalFile()).absolutePath(); } - return QString(); } -void OllieKateView::switchToSession(const QString &sessionId) +QString OllieKateView::agentPath() const { - stopStreams(); - m_sessionId = sessionId; - m_rawChat.clear(); - m_chatView->clear(); - m_chatOffset = 0; - - // Get agent ID for this session - QStringList agents = m_9p->ls("session/" + sessionId + "/agent"); - agents.removeAll("new"); - agents.removeAll("idx"); - m_agentId = agents.value(0); - - // Fetch state - QString stateVal = QString::fromUtf8(m_9p->read(agentPath() + "/state")).trimmed(); - QString state = stateVal.isEmpty() ? "idle" : stateVal; - QString dot = (state == "idle") ? "○ " : (state.startsWith("calling") ? "⚡ " : "● "); - m_sessionLabel->setText(dot + m_sessionId + "/" + m_agentId + " [" + state + "]"); - - // Fetch cwd - QString cfgVal = QString::fromUtf8(m_9p->read(agentPath() + "/cfg")).trimmed(); - if (!cfgVal.isEmpty()) { - for (const QString &line : cfgVal.split(QChar('\n'))) { - if (line.startsWith("cwd=")) { - m_cwdLabel->setText(line.mid(4)); - break; - } - } - } - // Fetch existing chat via 9P - QByteArray chatData = m_9p->read(agentPath() + "/log"); - if (!chatData.isEmpty()) { - m_rawChat = QString::fromUtf8(chatData); - m_chatOffset = chatData.size(); - m_renderer->render(m_chatView, m_rawChat); - m_scrollBottomBtn->hide(); - } - - // Start streaming for new data - startChatStream(); - startStateStream(); + if (m_sessionId.isEmpty() || m_agentId.isEmpty()) return {}; + return "session/" + m_sessionId + "/agent/" + m_agentId; } -QString OllieKateView::activeSessionId() +void OllieKateView::updateStatusLabel() { - return m_sessionId; -} - -void OllieKateView::processEmojis() -{ - static const QHash quickEmojis = { - {";)", QStringLiteral("\U0001F609")}, {":)", QStringLiteral("\U0001F642")}, - {":D", QStringLiteral("\U0001F603")}, {":(", QStringLiteral("\U0001F61E")}, - {":P", QStringLiteral("\U0001F61B")}, {":p", QStringLiteral("\U0001F61B")}, - {"<3", QStringLiteral("\u2764\uFE0F")}, {":O", QStringLiteral("\U0001F62E")}, - {":o", QStringLiteral("\U0001F62E")}, {"B)", QStringLiteral("\U0001F60E")}, - {":*", QStringLiteral("\U0001F618")}, {"XD", QStringLiteral("\U0001F606")}, - {"xD", QStringLiteral("\U0001F606")}, {":|", QStringLiteral("\U0001F610")}, - {">:(", QStringLiteral("\U0001F620")}, {"'(", QStringLiteral("\U0001F622")}, - }; - - static const QHash shortcodes = { - {"thumbsup", QStringLiteral("\U0001F44D")}, {"thumbsdown", QStringLiteral("\U0001F44E")}, - {"fire", QStringLiteral("\U0001F525")}, {"rocket", QStringLiteral("\U0001F680")}, - {"check", QStringLiteral("\u2705")}, {"x", QStringLiteral("\u274C")}, - {"eyes", QStringLiteral("\U0001F440")}, {"thinking", QStringLiteral("\U0001F914")}, - {"shrug", QStringLiteral("\U0001F937")}, {"wave", QStringLiteral("\U0001F44B")}, - {"clap", QStringLiteral("\U0001F44F")}, {"pray", QStringLiteral("\U0001F64F")}, - {"heart", QStringLiteral("\u2764\uFE0F")}, {"broken_heart", QStringLiteral("\U0001F494")}, - {"star", QStringLiteral("\u2B50")}, {"sparkles", QStringLiteral("\u2728")}, - {"zap", QStringLiteral("\u26A1")}, {"100", QStringLiteral("\U0001F4AF")}, - {"tada", QStringLiteral("\U0001F389")}, {"party", QStringLiteral("\U0001F389")}, - {"beer", QStringLiteral("\U0001F37A")}, {"beers", QStringLiteral("\U0001F37B")}, - {"coffee", QStringLiteral("\u2615")}, {"wine", QStringLiteral("\U0001F377")}, - {"pizza", QStringLiteral("\U0001F355")}, {"taco", QStringLiteral("\U0001F32E")}, - {"cookie", QStringLiteral("\U0001F36A")}, {"cake", QStringLiteral("\U0001F382")}, - {"sun", QStringLiteral("\u2600\uFE0F")}, {"moon", QStringLiteral("\U0001F319")}, - {"rainbow", QStringLiteral("\U0001F308")}, {"ocean", QStringLiteral("\U0001F30A")}, - {"dog", QStringLiteral("\U0001F436")}, {"cat", QStringLiteral("\U0001F431")}, - {"bug", QStringLiteral("\U0001F41B")}, {"snake", QStringLiteral("\U0001F40D")}, - {"unicorn", QStringLiteral("\U0001F984")}, {"skull", QStringLiteral("\U0001F480")}, - {"ghost", QStringLiteral("\U0001F47B")}, {"robot", QStringLiteral("\U0001F916")}, - {"poop", QStringLiteral("\U0001F4A9")}, {"brain", QStringLiteral("\U0001F9E0")}, - {"bulb", QStringLiteral("\U0001F4A1")}, {"gear", QStringLiteral("\u2699\uFE0F")}, - {"lock", QStringLiteral("\U0001F512")}, {"key", QStringLiteral("\U0001F511")}, - {"warning", QStringLiteral("\u26A0\uFE0F")}, {"no_entry", QStringLiteral("\u26D4")}, - {"muscle", QStringLiteral("\U0001F4AA")}, {"ok_hand", QStringLiteral("\U0001F44C")}, - {"fist", QStringLiteral("\u270A")}, {"v", QStringLiteral("\u270C\uFE0F")}, - {"metal", QStringLiteral("\U0001F918")}, {"middle_finger", QStringLiteral("\U0001F595")}, - {"smile", QStringLiteral("\U0001F604")}, {"joy", QStringLiteral("\U0001F602")}, - {"rofl", QStringLiteral("\U0001F923")}, {"wink", QStringLiteral("\U0001F609")}, - {"blush", QStringLiteral("\U0001F60A")}, {"heart_eyes", QStringLiteral("\U0001F60D")}, - {"cool", QStringLiteral("\U0001F60E")}, {"angry", QStringLiteral("\U0001F620")}, - {"cry", QStringLiteral("\U0001F622")}, {"sob", QStringLiteral("\U0001F62D")}, - {"scream", QStringLiteral("\U0001F631")}, {"facepalm", QStringLiteral("\U0001F926")}, - {"gift", QStringLiteral("\U0001F381")}, {"balloon", QStringLiteral("\U0001F388")}, - {"trophy", QStringLiteral("\U0001F3C6")}, {"crown", QStringLiteral("\U0001F451")}, - {"gem", QStringLiteral("\U0001F48E")}, {"money", QStringLiteral("\U0001F4B0")}, - {"computer", QStringLiteral("\U0001F4BB")}, {"phone", QStringLiteral("\U0001F4F1")}, - {"earth", QStringLiteral("\U0001F30D")}, {"airplane", QStringLiteral("\u2708\uFE0F")}, - }; - - static bool processing = false; - if (processing) return; - processing = true; - - QTextCursor cursor = m_promptInput->textCursor(); - int pos = cursor.position(); - QString text = m_promptInput->toPlainText(); - - // Quick emoticons (check last 2-4 chars before cursor) - for (int len = 2; len <= qMin(4, pos); ++len) { - QString candidate = text.mid(pos - len, len); - auto it = quickEmojis.find(candidate); - if (it != quickEmojis.end()) { - cursor.setPosition(pos - len); - cursor.setPosition(pos, QTextCursor::KeepAnchor); - cursor.insertText(it.value()); - processing = false; - return; - } - } - - // :shortcode: (find closing : at cursor, opening : before it) - if (pos >= 3 && text[pos - 1] == ':') { - int start = text.lastIndexOf(':', pos - 2); - if (start >= 0 && start < pos - 2) { - QString code = text.mid(start + 1, pos - start - 2); - auto it = shortcodes.find(code); - if (it != shortcodes.end()) { - cursor.setPosition(start); - cursor.setPosition(pos, QTextCursor::KeepAnchor); - cursor.insertText(it.value()); - } - } - } - - processing = false; -} - -void OllieKateView::onAnchorClicked(const QUrl &url) -{ - if (url.scheme() == QStringLiteral("ollie-diff")) { - bool ok; - int index = url.path().mid(1).toInt(&ok); // path is "/N" - if (ok) { - showDiffBlock(index); - } - } else if (url.scheme() == QStringLiteral("ollie-apply")) { - bool ok; - int index = url.path().mid(1).toInt(&ok); // path is "/N" - if (ok) { - applyCodeBlock(index); - } + if (!m_statusLabel) return; + if (m_agentId.isEmpty()) { + m_statusLabel->setText(QStringLiteral("○ ollie: no agent")); + } else { + m_statusLabel->setText(QStringLiteral("● ollie: kate/") + m_agentId); } } -void OllieKateView::showDiffBlock(int index) -{ - if (index < 0 || index >= m_diffBlocks.size()) return; - - QByteArray diffData = m_diffBlocks[index].toUtf8(); - QString mode = m_plugin->diffMode(); - - // Set Kate's diff style preference before opening - { - KConfigGroup grp(KSharedConfig::openConfig(QStringLiteral("katerc")), - QStringLiteral("Diff")); - // Kate uses: 0 = Side By Side, 1 = Unified, 2 = Raw - int style = 1; // unified default - if (mode == QStringLiteral("side-by-side")) style = 0; - else if (mode == QStringLiteral("raw")) style = 2; - grp.writeEntry("DiffStyle", style); - grp.sync(); - } - -#ifdef HAVE_KATE_DIFF - DiffParams params; - params.tabTitle = QStringLiteral("Ollie Diff %1").arg(index + 1); - params.flags = DiffParams::Flag(DiffParams::ShowFileName); - Utils::showDiff(diffData, params, m_mainWindow); -#else - // Fallback: open as a new document with Diff highlighting - auto *view = m_mainWindow->openUrl(QUrl()); - if (view) { - view->document()->setText(QString::fromUtf8(diffData)); - view->document()->setHighlightingMode(QStringLiteral("Diff")); - } -#endif -} - -void OllieKateView::refreshSessions() -{ - // At startup, try to select agent matching current project - selectAgentForProject(); -} - -void OllieKateView::setupGhostProvider() -{ - auto *view = m_mainWindow->activeView(); - if (!view) - return; - - // If ghost provider is already on this view, keep it - if (m_ghost && m_ghost->view() == view) - return; - - // Remove old provider - if (m_ghost) { - delete m_ghost; - m_ghost = nullptr; - } - - // Create ghost provider with 9P-based completion - m_ghost = new OllieGhostProvider(view, m_plugin, this); - - // Install event filter on the view widget for Tab interception - if (QWidget *proxy = view->focusProxy()) { - proxy->installEventFilter(this); - } -} - -void OllieKateView::setupGitPlusPlusDiffAction() -{ - QObject *gitView = m_mainWindow->pluginView(QStringLiteral("gitplusplus")); - if (!gitView) return; - - auto *action = new QAction(QIcon::fromTheme(QStringLiteral("document-preview")), - QStringLiteral("Ollie: Review Diff"), this); - connect(action, &QAction::triggered, this, &OllieKateView::reviewDiff); - - QMetaObject::invokeMethod(gitView, "registerDiffAction", - Q_ARG(QAction *, action)); -} - -bool OllieKateView::eventFilter(QObject *obj, QEvent *event) -{ - // Reposition scroll-to-bottom button when chat view resizes - if (obj == m_chatView && event->type() == QEvent::Resize) { - QPoint pos = m_chatView->geometry().bottomRight() - QPoint(36, 36); - m_scrollBottomBtn->move(pos); - } - - // Prompt input: Enter sends, Shift+Enter / Ctrl+J inserts newline - if (obj == m_promptInput && event->type() == QEvent::KeyPress) { - auto *ke = static_cast(event); - if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { - if (ke->modifiers() & (Qt::ShiftModifier | Qt::ControlModifier)) { - m_promptInput->insertPlainText("\n"); - } else { - submitPrompt(); - } - return true; - } - if (ke->key() == Qt::Key_J && (ke->modifiers() & Qt::ControlModifier)) { - m_promptInput->insertPlainText("\n"); - return true; - } - } - - if (event->type() == QEvent::KeyPress && m_ghost) { - auto *keyEvent = static_cast(event); - - // Second key of Meta+O chord - if (m_ghostPrefix) { - m_ghostPrefix = false; - switch (keyEvent->key()) { - case Qt::Key_N: - m_ghost->cycle(+1); - return true; - case Qt::Key_P: - m_ghost->cycle(-1); - return true; - case Qt::Key_C: - m_ghost->setEnabled(!m_ghost->isEnabled()); - if (m_ghost->isEnabled()) { - m_completionStatus->setText(QStringLiteral("\u2726 completion on")); - m_completionStatus->setStyleSheet(QStringLiteral("color: green; padding: 0px 2px;")); - } else { - m_completionStatus->setText(QStringLiteral("\u2726 completion off")); - m_completionStatus->setStyleSheet(QStringLiteral("color: gray; padding: 0px 2px;")); - } - return true; - case Qt::Key_D: - m_ghost->dismiss(); - return true; - case Qt::Key_Tab: - m_ghost->requestCompletion(); - return true; - default: - // Not a recognized chord — pass through - break; - } - } - - // Meta+O starts the chord prefix - if (keyEvent->key() == Qt::Key_O && keyEvent->modifiers() == Qt::MetaModifier) { - m_ghostPrefix = true; - return true; - } - - if (m_ghost->hasSuggestion()) { - // Don't intercept keys while a popup or completion widget is active - auto *activeView = m_mainWindow->activeView(); -#if KTEXTEDITOR_VERSION_MAJOR >= 6 - if (QApplication::activePopupWidget() || - (activeView && activeView->isCompletionActive())) - return QObject::eventFilter(obj, event); -#else - auto *ccIface = activeView ? dynamic_cast(activeView) : nullptr; - if (QApplication::activePopupWidget() || - (ccIface && ccIface->isCompletionActive())) - return QObject::eventFilter(obj, event); -#endif - - if (keyEvent->key() == Qt::Key_Tab && keyEvent->modifiers() == Qt::NoModifier) { - if (m_ghost->accept()) { - return true; // consumed - } - } - } - } - return QObject::eventFilter(obj, event); -} - -// --- Context Menu Integration --- +// --- Context Menu --- void OllieKateView::setupContextMenu(KTextEditor::View *view) { if (!view) return; - // Connect directly to the view's contextMenuAboutToShow signal. - // This signal is emitted by KateView before showing the context menu. connect(view, &KTextEditor::View::contextMenuAboutToShow, this, [this](KTextEditor::View *v, QMenu *menu) { - // Remove previous ollie actions to avoid duplicates + // Remove previous ollie actions const auto actions = menu->actions(); for (auto *a : actions) { if (a->objectName().startsWith(QStringLiteral("ollie_ctx_"))) { @@ -1131,7 +261,7 @@ void OllieKateView::setupContextMenu(KTextEditor::View *view) askAction->setObjectName(QStringLiteral("ollie_ctx_ask")); connect(askAction, &QAction::triggered, this, &OllieKateView::askAboutFile); - // "Review Diff" — visible when the document is a diff + // "Review Diff" when document is a diff if (v->document()->highlightingMode() == QStringLiteral("Diff")) { auto *reviewDiffAction = menu->addAction(QIcon::fromTheme(QStringLiteral("document-preview")), QStringLiteral("Ollie: Review Diff")); @@ -1168,6 +298,57 @@ void OllieKateView::setupContextMenu(KTextEditor::View *view) }); } +void OllieKateView::setupGitPlusPlusDiffAction() +{ + QObject *gitView = m_mainWindow->pluginView(QStringLiteral("gitplusplus")); + if (!gitView) return; + + auto *action = new QAction(QIcon::fromTheme(QStringLiteral("document-preview")), + QStringLiteral("Ollie: Review Diff"), this); + connect(action, &QAction::triggered, this, &OllieKateView::reviewDiff); + + QMetaObject::invokeMethod(gitView, "registerDiffAction", + Q_ARG(QAction *, action)); +} + +// --- Actions --- + +void OllieKateView::askAboutFile() +{ + auto *view = m_mainWindow->activeView(); + if (!view || !view->document()) return; + + QString filePath = view->document()->url().toLocalFile(); + if (filePath.isEmpty()) return; + + ensureAgent(); + if (m_agentId.isEmpty()) return; + + QString prompt = "Describe and explain this file: " + filePath; + m_9p->write(agentPath() + "/prompt", prompt.toUtf8()); +} + +void OllieKateView::explainSelection() +{ + auto *view = m_mainWindow->activeView(); + if (!view) return; + + QString selection = view->selectionText(); + if (selection.isEmpty()) { + auto cursor = view->cursorPosition(); + selection = view->document()->line(cursor.line()); + } + if (selection.isEmpty()) return; + + ensureAgent(); + if (m_agentId.isEmpty()) return; + + QString filePath = view->document()->url().toLocalFile(); + QString context = filePath.isEmpty() ? "" : " (from " + filePath + ")"; + QString prompt = "Explain this code" + context + ":\n\n```\n" + selection + "\n```"; + m_9p->write(agentPath() + "/prompt", prompt.toUtf8()); +} + void OllieKateView::submitSelectionPrompt(const QString &action) { auto *view = m_mainWindow->activeView(); @@ -1176,10 +357,8 @@ void OllieKateView::submitSelectionPrompt(const QString &action) QString selection = view->selectionText(); if (selection.isEmpty()) return; - if (m_agentId.isEmpty()) { - m_chatView->setHtml(QStringLiteral("

No agent selected. Click 'New' to create one.

")); - return; - } + ensureAgent(); + if (m_agentId.isEmpty()) return; QString filePath = view->document()->url().toLocalFile(); auto cursor = view->selectionRange().start(); @@ -1196,7 +375,6 @@ void OllieKateView::reviewDiff() { QString diff; - // Try to get diff from Git++ plugin (kate-worktree) QObject *gitView = m_mainWindow->pluginView(QStringLiteral("gitplusplus")); if (gitView) { QByteArray data = gitView->property("lastDiff").toByteArray(); @@ -1204,7 +382,6 @@ void OllieKateView::reviewDiff() diff = QString::fromUtf8(data); } - // Fall back to active document content (e.g. diff opened as plain text) if (diff.isEmpty()) { auto *view = m_mainWindow->activeView(); if (view && view->document()) @@ -1213,220 +390,97 @@ void OllieKateView::reviewDiff() if (diff.trimmed().isEmpty()) return; - if (m_agentId.isEmpty()) { - m_chatView->setHtml(QStringLiteral("

No agent selected. Click 'New' to create one.

")); - return; - } + ensureAgent(); + if (m_agentId.isEmpty()) return; QString prompt = QStringLiteral("Review this diff. Point out potential issues, bugs, " "or improvements:\n\n```diff\n") + diff + QStringLiteral("\n```"); m_9p->write(agentPath() + "/prompt", prompt.toUtf8()); } -// --- Apply Code Blocks --- +// --- Ghost Text --- -void OllieKateView::applyCodeBlock(int index) +void OllieKateView::setupGhostProvider() { - if (index < 0 || index >= m_applyBlocks.size()) return; - - const auto &block = m_applyBlocks[index]; - if (block.code.trimmed().isEmpty()) return; - - KTextEditor::View *view = nullptr; - KTextEditor::Document *doc = nullptr; - - if (!block.filePath.isEmpty()) { - // Open the target file in Kate - view = m_mainWindow->openUrl(QUrl::fromLocalFile(block.filePath)); - } else { - // No file path detected — apply to the currently active document - view = m_mainWindow->activeView(); - } - - if (!view || !view->document()) return; - doc = view->document(); - - // Line-range replace mode: replace lines startLine through endLine - if (block.startLine > 0 && block.endLine > 0) { - int start = block.startLine - 1; // convert to 0-indexed - int end = block.endLine - 1; - if (end >= doc->lines()) end = doc->lines() - 1; - if (start > end) start = end; - - KTextEditor::Range range( - KTextEditor::Cursor(start, 0), - KTextEditor::Cursor(end, doc->lineLength(end))); - doc->replaceText(range, block.code); - view->setCursorPosition(KTextEditor::Cursor(start, 0)); - return; - } - - // If there's a selection in the target view, replace it with the code block - if (view->selection()) { - doc->replaceText(view->selectionRange(), block.code); - return; - } - - // Otherwise insert at cursor position - KTextEditor::Cursor cursor = view->cursorPosition(); - doc->insertText(cursor, block.code); -} - -// --- Inline Annotations --- - -void OllieKateView::clearAnnotations() -{ - if (m_annotationDoc) { - // Document still alive — safe to delete its MovingRanges - qDeleteAll(m_annotations); - } - // If document is gone, ranges are already destroyed — just drop pointers - m_annotations.clear(); - m_annotationDoc = nullptr; -} - -void OllieKateView::annotateFileReferences(const QString &chatText) -{ - clearAnnotations(); - auto *view = m_mainWindow->activeView(); - if (!view || !view->document()) return; + if (!view) return; - QString currentFile = view->document()->url().toLocalFile(); - if (currentFile.isEmpty()) return; + if (m_ghost && m_ghost->view() == view) return; - // Match patterns like /path/to/file.ext:123 or /path/to/file.ext:123:45 - static const QRegularExpression fileRefRe( - R"((?:^|[\s`])(/[^\s:]+):(\d+)(?::(\d+))?)", - QRegularExpression::MultilineOption); + if (m_ghost) { + delete m_ghost; + m_ghost = nullptr; + } - auto *doc = view->document(); - m_annotationDoc = doc; -#if KTEXTEDITOR_VERSION_MAJOR < 6 - auto *movingIface = qobject_cast(doc); + m_ghost = new OllieGhostProvider(view, m_plugin, this); + + if (QWidget *proxy = view->focusProxy()) { + proxy->installEventFilter(this); + } +} + +// --- Event Filter (ghost text key handling) --- + +bool OllieKateView::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::KeyPress && m_ghost) { + auto *keyEvent = static_cast(event); + + // Second key of Meta+O chord + if (m_ghostPrefix) { + m_ghostPrefix = false; + switch (keyEvent->key()) { + case Qt::Key_N: + m_ghost->cycle(+1); + return true; + case Qt::Key_P: + m_ghost->cycle(-1); + return true; + case Qt::Key_C: + m_ghost->setEnabled(!m_ghost->isEnabled()); + if (m_statusLabel) { + // Update status to reflect completion state + updateStatusLabel(); + } + return true; + case Qt::Key_D: + m_ghost->dismiss(); + return true; + case Qt::Key_Tab: + m_ghost->requestCompletion(); + return true; + default: + break; + } + } + + // Meta+O starts the chord prefix + if (keyEvent->key() == Qt::Key_O && keyEvent->modifiers() == Qt::MetaModifier) { + m_ghostPrefix = true; + return true; + } + + if (m_ghost->hasSuggestion()) { + auto *activeView = m_mainWindow->activeView(); +#if KTEXTEDITOR_VERSION_MAJOR >= 6 + if (QApplication::activePopupWidget() || + (activeView && activeView->isCompletionActive())) + return QObject::eventFilter(obj, event); #else - auto *movingIface = doc; + auto *ccIface = activeView ? dynamic_cast(activeView) : nullptr; + if (QApplication::activePopupWidget() || + (ccIface && ccIface->isCompletionActive())) + return QObject::eventFilter(obj, event); #endif - if (!movingIface) return; - KTextEditor::Attribute::Ptr attr(new KTextEditor::Attribute()); - attr->setBackground(m_renderer ? QColor(255, 255, 0, 40) : QColor(255, 255, 0, 40)); - attr->setFontBold(false); - - QSet annotatedLines; - - auto it = fileRefRe.globalMatch(chatText); - while (it.hasNext()) { - auto match = it.next(); - QString path = match.captured(1); - int line = match.captured(2).toInt() - 1; // 0-indexed - - // Only annotate if it references the currently open file - if (path != currentFile) continue; - if (line < 0 || line >= doc->lines()) continue; - if (annotatedLines.contains(line)) continue; - annotatedLines.insert(line); - - KTextEditor::Range range(line, 0, line, doc->lineLength(line)); - auto *movingRange = movingIface->newMovingRange(range); - movingRange->setAttribute(attr); - movingRange->setZDepth(-100.0); // behind text - m_annotations.append(movingRange); - } -} - -// --- 9P helpers --- - -QString OllieKateView::agentPath() const -{ - if (m_sessionId.isEmpty() || m_agentId.isEmpty()) return {}; - return "session/" + m_sessionId + "/agent/" + m_agentId; -} - -// ninepBin and serverAddr only used for streaming processes -static QString ninepBin() -{ - QString bin = QStandardPaths::findExecutable("9p"); - if (!bin.isEmpty()) return bin; - QString plan9 = qEnvironmentVariable("PLAN9"); - if (!plan9.isEmpty() && QFileInfo::exists(plan9 + "/bin/9p")) - return plan9 + "/bin/9p"; - return "9p"; -} - -static QString serverAddr() -{ - QString ns = qEnvironmentVariable("NAMESPACE"); - if (ns.isEmpty()) { - QString user = qEnvironmentVariable("USER"); - QString display = qEnvironmentVariable("DISPLAY"); - ns = "/tmp/ns." + user + "." + display; - } - return "unix!" + ns + "/ollie"; -} - -// --- 9P Streaming --- - -void OllieKateView::startChatStream() -{ - if (m_chatProc || m_sessionId.isEmpty()) return; - m_chatProc = new QProcess(this); - connect(m_chatProc, &QProcess::readyReadStandardOutput, this, [this]() { - QByteArray data = m_chatProc->readAllStandardOutput(); - if (!data.isEmpty()) { - QString text = QString::fromUtf8(data); - m_rawChat += text; - m_chatOffset += data.size(); - // Append streaming text and re-render tail - m_renderer->render(m_chatView, m_rawChat); + if (keyEvent->key() == Qt::Key_Tab && keyEvent->modifiers() == Qt::NoModifier) { + if (m_ghost->accept()) { + return true; + } + } } - }); - connect(m_chatProc, QOverload::of(&QProcess::finished), - this, [this]() { m_chatProc->deleteLater(); m_chatProc = nullptr; }); - m_chatProc->start(ninepBin(), {"-a", serverAddr(), "read", agentPath() + "/chat"}); -} - -void OllieKateView::startStateStream() -{ - if (m_stateProc || m_sessionId.isEmpty()) return; - m_stateProc = new QProcess(this); - connect(m_stateProc, &QProcess::readyReadStandardOutput, this, [this]() { - QByteArray data = m_stateProc->readAllStandardOutput(); - QString state = QString::fromUtf8(data).trimmed(); - if (!state.isEmpty()) { - QStringList lines = state.split('\n'); - state = lines.last().trimmed(); - QString dot = (state == "idle") ? "○ " : (state.startsWith("calling") ? "⚡ " : "● "); - m_sessionLabel->setText(dot + "kate/" + m_agentId + " [" + state + "]"); - } - // statewait returns once per state change — restart - m_stateProc->deleteLater(); - m_stateProc = nullptr; - startStateStream(); - }); - connect(m_stateProc, QOverload::of(&QProcess::finished), - this, [this]() { - if (m_stateProc) { m_stateProc->deleteLater(); m_stateProc = nullptr; } - }); - m_stateProc->start(ninepBin(), {"-a", serverAddr(), "read", agentPath() + "/statewait"}); -} - -void OllieKateView::stopStreams() -{ - if (m_chatProc) { - m_chatProc->disconnect(); - m_chatProc->kill(); - m_chatProc->waitForFinished(1000); - m_chatProc->deleteLater(); - m_chatProc = nullptr; - } - if (m_stateProc) { - m_stateProc->disconnect(); - m_stateProc->kill(); - m_stateProc->waitForFinished(1000); - m_stateProc->deleteLater(); - m_stateProc = nullptr; } + return QObject::eventFilter(obj, event); } #include "ollie_kate.moc" diff --git a/kate/ollie_kate.h b/kate/ollie_kate.h index 9d3eb31..aa31c6b 100644 --- a/kate/ollie_kate.h +++ b/kate/ollie_kate.h @@ -2,28 +2,19 @@ #define OLLIE_KATE_PLUGIN_H #include -#include #include #include #if QT_VERSION_MAJOR >= 6 #include #endif -#include #include -#include -#include #include #include #include -#include -#include -#include -#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 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 m_applyBlocks; - bool m_loadingOlder = false; // guard against recursive scroll-triggered loads - - // Inline annotations (MovingRanges highlighting lines the agent references) - QList m_annotations; - QPointer m_annotationDoc; + bool m_ghostPrefix = false; }; #endif // OLLIE_KATE_PLUGIN_H