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:
parent
a12e00e231
commit
abec12e09f
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Reference in New Issue