diff --git a/kate/chat_renderer.cpp b/kate/chat_renderer.cpp index 1106d02..aca83a4 100644 --- a/kate/chat_renderer.cpp +++ b/kate/chat_renderer.cpp @@ -426,9 +426,43 @@ QString ChatRenderer::blockToHtml(const ChatBlock &block) const case ChatBlock::Assistant: { QList subs = parseSubBlocks(block.content); QString inner; - for (const auto &sub : subs) + QString lastProse; // track preceding prose for file path extraction + 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()) { + // Try to extract file path from preceding prose + static const QRegularExpression filePathRe( + R"((?:`|\b)(/[^\s:`]+\.\w+)(?:`|\b))"); + QString filePath; + auto fileMatch = filePathRe.match(lastProse); + if (fileMatch.hasMatch()) { + filePath = fileMatch.captured(1); + } + + int idx = m_applyStorage->size(); + ApplyBlock ab; + ab.filePath = filePath; + ab.code = sub.content; + m_applyStorage->append(ab); + + QString applyLabel = filePath.isEmpty() + ? QStringLiteral("✎ Apply") + : QStringLiteral("✎ Apply to ") + escapeHtml(filePath.section('/', -1)); + inner += QStringLiteral( + "
" + "") + + applyLabel + QStringLiteral("
"); + } + } + return QStringLiteral( "" "
clear(); + if (m_applyStorage) + m_applyStorage->clear(); QList blocks = parseBlocks(rawText); QString html = QStringLiteral( diff --git a/kate/chat_renderer.h b/kate/chat_renderer.h index 345bea8..e6f563f 100644 --- a/kate/chat_renderer.h +++ b/kate/chat_renderer.h @@ -51,6 +51,14 @@ public: /// 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; + }; + void setApplyStorage(QList *storage) { m_applyStorage = storage; } + /// Parse raw chat text into blocks QList parseBlocks(const QString &text) const; @@ -75,6 +83,7 @@ private: KSyntaxHighlighting::Repository *m_repo; QStringList *m_diffStorage = nullptr; + QList *m_applyStorage = nullptr; QString m_lastRawText; // for incremental rendering int m_lastBlockCount = 0; diff --git a/kate/ollie_kate.cpp b/kate/ollie_kate.cpp index 8df8e3e..5a0bfc5 100644 --- a/kate/ollie_kate.cpp +++ b/kate/ollie_kate.cpp @@ -1,6 +1,5 @@ #include "ollie_kate.h" #include "ollie_ghost.h" -#include "chat_renderer.h" #ifdef HAVE_KATE_DIFF #include "ktexteditor_utils.h" @@ -28,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -265,6 +265,7 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m , m_renderer(new ChatRenderer()) { m_renderer->setDiffStorage(&m_diffBlocks); + m_renderer->setApplyStorage(&m_applyBlocks); m_iface = new QDBusInterface(SERVICE, OBJ_PATH, IFACE, QDBusConnection::sessionBus(), this); @@ -447,11 +448,19 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m // Set up ghost-text completion on active view setupGhostProvider(); connect(mainWindow, &KTextEditor::MainWindow::viewChanged, - this, [this](KTextEditor::View *) { setupGhostProvider(); }); + this, [this](KTextEditor::View *view) { + setupGhostProvider(); + if (view) setupContextMenu(view); + }); + + // Set up context menu on initial active view + if (auto *view = mainWindow->activeView()) + setupContextMenu(view); } OllieKateView::~OllieKateView() { + clearAnnotations(); delete m_renderer; delete m_ghost; delete m_toolView; @@ -467,6 +476,7 @@ void OllieKateView::onChatUpdated(const QString &session_id, qlonglong offset, c m_rawChat += new_text; m_chatOffset += new_text.size(); m_renderer->render(m_chatView, m_rawChat); + annotateFileReferences(m_rawChat); } else { // Offset mismatch — refetch QDBusMessage msg = QDBusMessage::createMethodCall(SERVICE, OBJ_PATH, IFACE, "GetChat"); @@ -476,6 +486,7 @@ void OllieKateView::onChatUpdated(const QString &session_id, qlonglong offset, c m_rawChat = reply.arguments()[0].toString(); m_chatOffset = reply.arguments()[1].toLongLong(); m_renderer->render(m_chatView, m_rawChat); + annotateFileReferences(m_rawChat); m_scrollBottomBtn->hide(); } } @@ -832,6 +843,12 @@ void OllieKateView::onAnchorClicked(const QUrl &url) if (ok) { showDiffBlock(index); } + } else if (url.scheme() == QStringLiteral("ollie-apply")) { + bool ok; + int index = url.host().toInt(&ok); + if (ok) { + applyCodeBlock(index); + } } } @@ -1028,4 +1045,153 @@ bool OllieKateView::eventFilter(QObject *obj, QEvent *event) return QObject::eventFilter(obj, event); } +// --- Context Menu Integration --- + +void OllieKateView::setupContextMenu(KTextEditor::View *view) +{ + connect(view, &KTextEditor::View::contextMenuAboutToShow, + this, [this](KTextEditor::View *v, QMenu *menu) { + if (!v->selection()) return; + + menu->addSeparator(); + + auto *fixAction = menu->addAction(QIcon::fromTheme("tools-wizard"), "Ollie: Fix this"); + connect(fixAction, &QAction::triggered, this, [this]() { + submitSelectionPrompt("Fix"); + }); + + auto *refactorAction = menu->addAction(QIcon::fromTheme("edit-rename"), "Ollie: Refactor this"); + connect(refactorAction, &QAction::triggered, this, [this]() { + submitSelectionPrompt("Refactor"); + }); + + auto *testAction = menu->addAction(QIcon::fromTheme("document-new"), "Ollie: Add tests for this"); + connect(testAction, &QAction::triggered, this, [this]() { + submitSelectionPrompt("Write unit tests for"); + }); + + auto *docAction = menu->addAction(QIcon::fromTheme("documentation"), "Ollie: Document this"); + connect(docAction, &QAction::triggered, this, [this]() { + submitSelectionPrompt("Add documentation comments to"); + }); + }, Qt::UniqueConnection); +} + +void OllieKateView::submitSelectionPrompt(const QString &action) +{ + auto *view = m_mainWindow->activeView(); + if (!view || !view->selection()) return; + + QString selection = view->selectionText(); + if (selection.isEmpty()) return; + + ensureSession(); + if (m_sessionId.isEmpty()) return; + + QString filePath = view->document()->url().toLocalFile(); + auto cursor = view->selectionRange().start(); + QString location; + if (!filePath.isEmpty()) { + location = " in `" + filePath + ":" + QString::number(cursor.line() + 1) + "`"; + } + + QString prompt = action + " this code" + location + ":\n\n```\n" + selection + "\n```"; + m_iface->call("Submit", m_sessionId, prompt); +} + +// --- Apply Code Blocks --- + +void OllieKateView::applyCodeBlock(int index) +{ + 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(); + + // 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, replace the entire document content if it looks like a full file, + // or insert at cursor position + if (block.code.contains('\n') && block.code.split('\n').size() > 5) { + // Heuristic: if the code block is substantial, insert at cursor + KTextEditor::Cursor cursor = view->cursorPosition(); + doc->insertText(cursor, block.code + QStringLiteral("\n")); + } else { + // Short snippet — insert at cursor + KTextEditor::Cursor cursor = view->cursorPosition(); + doc->insertText(cursor, block.code); + } +} + +// --- Inline Annotations --- + +void OllieKateView::clearAnnotations() +{ + qDeleteAll(m_annotations); + m_annotations.clear(); +} + +void OllieKateView::annotateFileReferences(const QString &chatText) +{ + clearAnnotations(); + + auto *view = m_mainWindow->activeView(); + if (!view || !view->document()) return; + + QString currentFile = view->document()->url().toLocalFile(); + if (currentFile.isEmpty()) 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); + + auto *doc = view->document(); + auto *movingIface = qobject_cast(doc); + 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 = doc->newMovingRange(range); + movingRange->setAttribute(attr); + movingRange->setZDepth(-100.0); // behind text + m_annotations.append(movingRange); + } +} + #include "ollie_kate.moc" diff --git a/kate/ollie_kate.h b/kate/ollie_kate.h index b54e0f6..383281b 100644 --- a/kate/ollie_kate.h +++ b/kate/ollie_kate.h @@ -17,8 +17,10 @@ #include #include #include +#include +#include -class ChatRenderer; +#include "chat_renderer.h" class QPushButton; class OllieKateView; @@ -133,6 +135,18 @@ private: QString projectBaseDir(); void switchToSession(const QString &sessionId); void setupGhostProvider(); + + // --- Editor Integration --- + void setupContextMenu(KTextEditor::View *view); + void submitSelectionPrompt(const QString &action); + void applyCodeBlock(int index); + void clearAnnotations(); + void annotateFileReferences(const QString &chatText); + + QList m_applyBlocks; + + // Inline annotations (MovingRanges highlighting lines the agent references) + QList m_annotations; }; #endif // OLLIE_KATE_PLUGIN_H