kate: editor integration — context menu, apply button, inline annotations

Context menu: right-click selection shows Fix/Refactor/Add tests/Document
actions that submit the selection with file+line context to the agent.

Apply button: code blocks in assistant messages get a clickable Apply
button that inserts the code into the target file (detected from
preceding prose) or the active document.

Inline annotations: file:line references in agent output are highlighted
with MovingRanges in the active editor view.
This commit is contained in:
Levi Neely 2026-07-16 20:44:46 +02:00
parent a12e00e231
commit abec12e09f
4 changed files with 230 additions and 5 deletions

View File

@ -426,9 +426,43 @@ QString ChatRenderer::blockToHtml(const ChatBlock &block) const
case ChatBlock::Assistant: {
QList<SubBlock> 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(
"<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 +
@ -520,9 +554,11 @@ void ChatRenderer::render(QTextEdit *view, const QString &rawText)
{
detectTheme();
// Clear diff storage before re-render (diffs get re-indexed)
// Clear storage before re-render (blocks get re-indexed)
if (m_diffStorage)
m_diffStorage->clear();
if (m_applyStorage)
m_applyStorage->clear();
QList<ChatBlock> blocks = parseBlocks(rawText);
QString html = QStringLiteral(

View File

@ -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<ApplyBlock> *storage) { m_applyStorage = storage; }
/// Parse raw chat text into blocks
QList<ChatBlock> parseBlocks(const QString &text) const;
@ -75,6 +83,7 @@ private:
KSyntaxHighlighting::Repository *m_repo;
QStringList *m_diffStorage = nullptr;
QList<ApplyBlock> *m_applyStorage = nullptr;
QString m_lastRawText; // for incremental rendering
int m_lastBlockCount = 0;

View File

@ -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 <QInputDialog>
#include <QScrollBar>
#include <QCheckBox>
#include <QSet>
#include <QFormLayout>
#include <QMainWindow>
#include <QStatusBar>
@ -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<KTextEditor::Document *>(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<int> 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"

View File

@ -17,8 +17,10 @@
#include <QLabel>
#include <QAction>
#include <QEvent>
#include <KTextEditor/MovingRange>
#include <KTextEditor/Attribute>
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<ChatRenderer::ApplyBlock> m_applyBlocks;
// Inline annotations (MovingRanges highlighting lines the agent references)
QList<KTextEditor::MovingRange *> m_annotations;
};
#endif // OLLIE_KATE_PLUGIN_H