This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/kate/ollie_kate.cpp

487 lines
16 KiB
C++

#include "ollie_kate.h"
#include "ollie_ghost.h"
#include <KPluginFactory>
#include <KSharedConfig>
#include <KConfigGroup>
#include <KTextEditor/Document>
#include <KTextEditor/Editor>
#include <ktexteditor_version.h>
#if KTEXTEDITOR_VERSION_MAJOR < 6
#include <KTextEditor/CodeCompletionInterface>
#endif
#include <QApplication>
#include <QDir>
#include <QFileInfo>
#include <QKeyEvent>
#include <QMainWindow>
#include <QMenu>
#include <QStatusBar>
K_PLUGIN_CLASS_WITH_JSON(OllieKatePlugin, "metadata.json")
// --- Plugin ---
#if QT_VERSION_MAJOR >= 6
OllieKatePlugin::OllieKatePlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(data);
Q_UNUSED(args);
loadConfig();
}
#else
OllieKatePlugin::OllieKatePlugin(QObject *parent, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(args);
loadConfig();
}
#endif
QObject *OllieKatePlugin::createView(KTextEditor::MainWindow *mainWindow)
{
return new OllieKateView(this, mainWindow);
}
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);
}
// --- View ---
OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *mainWindow)
: QObject(mainWindow)
, m_plugin(plugin)
, m_mainWindow(mainWindow)
, m_9p(new Lib9pClient(this))
{
// Connect to 9P server
if (!m_9p->connectDefault()) {
qWarning() << "Ollie: failed to connect to 9P server:" << m_9p->lastError();
}
// Status bar indicator
if (auto *win = qobject_cast<QMainWindow *>(m_mainWindow->window())) {
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);
}
// Auto-select agent for current project
selectAgentForProject();
updateStatusLabel();
// Ghost text completion
setupGhostProvider();
// React to view/project changes
connect(mainWindow, &KTextEditor::MainWindow::viewChanged,
this, [this](KTextEditor::View *view) {
setupGhostProvider();
if (view) setupContextMenu(view);
selectAgentForProject();
updateStatusLabel();
});
QObject *projectView = mainWindow->pluginView("kateprojectplugin");
if (projectView) {
QObject::connect(projectView, SIGNAL(projectChanged()),
this, SLOT(onProjectChanged()));
}
#if KTEXTEDITOR_VERSION >= QT_VERSION_CHECK(5, 78, 0)
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
// Context menu on initial view
if (auto *view = mainWindow->activeView())
setupContextMenu(view);
// Git++ "Review Diff" action
setupGitPlusPlusDiffAction();
}
OllieKateView::~OllieKateView()
{
delete m_ghost;
if (m_statusLabel) {
delete m_statusLabel;
}
}
// --- Agent Management ---
void OllieKateView::ensureAgent()
{
if (!m_agentId.isEmpty()) return;
// Ensure kate session exists
if (m_sessionId.isEmpty()) {
m_9p->write("session/new", "name=kate\n");
m_sessionId = "kate";
}
// 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_agentId = QString::fromUtf8(resp).trimmed();
}
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;
m_sessionId = "kate";
QString canonicalBase = QFileInfo(baseDir).canonicalFilePath();
if (canonicalBase.isEmpty()) canonicalBase = baseDir;
while (canonicalBase.endsWith('/')) canonicalBase.chop(1);
// 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];
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);
}
return true;
}
}
}
return false;
}
void OllieKateView::switchToAgent(const QString &agentId)
{
m_sessionId = "kate";
m_agentId = agentId;
updateStatusLabel();
}
void OllieKateView::onProjectChanged()
{
selectAgentForProject();
updateStatusLabel();
}
QString OllieKateView::projectBaseDir()
{
QObject *projectView = m_mainWindow->pluginView("kateprojectplugin");
if (projectView) {
QString dir = projectView->property("projectBaseDir").toString();
if (!dir.isEmpty()) return dir;
}
auto *view = m_mainWindow->activeView();
if (view && view->document() && !view->document()->url().toLocalFile().isEmpty()) {
return QFileInfo(view->document()->url().toLocalFile()).absolutePath();
}
return QString();
}
QString OllieKateView::agentPath() const
{
if (m_sessionId.isEmpty() || m_agentId.isEmpty()) return {};
return "session/" + m_sessionId + "/agent/" + m_agentId;
}
void OllieKateView::updateStatusLabel()
{
if (!m_statusLabel) return;
if (m_agentId.isEmpty()) {
m_statusLabel->setText(QStringLiteral("○ ollie: no agent"));
} else {
m_statusLabel->setText(QStringLiteral("● ollie: kate/") + m_agentId);
}
}
// --- Context Menu ---
void OllieKateView::setupContextMenu(KTextEditor::View *view)
{
if (!view) return;
connect(view, &KTextEditor::View::contextMenuAboutToShow,
this, [this](KTextEditor::View *v, QMenu *menu) {
// Remove previous ollie actions
const auto actions = menu->actions();
for (auto *a : actions) {
if (a->objectName().startsWith(QStringLiteral("ollie_ctx_"))) {
menu->removeAction(a);
delete a;
}
}
auto *sep = menu->addSeparator();
sep->setObjectName(QStringLiteral("ollie_ctx_sep"));
auto *askAction = menu->addAction(QIcon::fromTheme(QStringLiteral("help-about")),
QStringLiteral("Ollie: Ask about file"));
askAction->setObjectName(QStringLiteral("ollie_ctx_ask"));
connect(askAction, &QAction::triggered, this, &OllieKateView::askAboutFile);
// "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"));
reviewDiffAction->setObjectName(QStringLiteral("ollie_ctx_review_diff"));
connect(reviewDiffAction, &QAction::triggered, this, &OllieKateView::reviewDiff);
}
if (!v->selection()) return;
auto *explainAction = menu->addAction(QIcon::fromTheme(QStringLiteral("help-contents")),
QStringLiteral("Ollie: Explain this"));
explainAction->setObjectName(QStringLiteral("ollie_ctx_explain"));
connect(explainAction, &QAction::triggered, this, &OllieKateView::explainSelection);
auto *fixAction = menu->addAction(QIcon::fromTheme(QStringLiteral("tools-wizard")),
QStringLiteral("Ollie: Fix this"));
fixAction->setObjectName(QStringLiteral("ollie_ctx_fix"));
connect(fixAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Fix")); });
auto *refactorAction = menu->addAction(QIcon::fromTheme(QStringLiteral("edit-rename")),
QStringLiteral("Ollie: Refactor this"));
refactorAction->setObjectName(QStringLiteral("ollie_ctx_refactor"));
connect(refactorAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Refactor")); });
auto *testAction = menu->addAction(QIcon::fromTheme(QStringLiteral("document-new")),
QStringLiteral("Ollie: Add tests for this"));
testAction->setObjectName(QStringLiteral("ollie_ctx_test"));
connect(testAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Write unit tests for")); });
auto *docAction = menu->addAction(QIcon::fromTheme(QStringLiteral("documentation")),
QStringLiteral("Ollie: Document this"));
docAction->setObjectName(QStringLiteral("ollie_ctx_doc"));
connect(docAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Add documentation comments to")); });
});
}
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();
if (!view || !view->selection()) return;
QString selection = view->selectionText();
if (selection.isEmpty()) return;
ensureAgent();
if (m_agentId.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_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::reviewDiff()
{
QString diff;
QObject *gitView = m_mainWindow->pluginView(QStringLiteral("gitplusplus"));
if (gitView) {
QByteArray data = gitView->property("lastDiff").toByteArray();
if (!data.isEmpty())
diff = QString::fromUtf8(data);
}
if (diff.isEmpty()) {
auto *view = m_mainWindow->activeView();
if (view && view->document())
diff = view->document()->text();
}
if (diff.trimmed().isEmpty()) 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());
}
// --- Ghost Text ---
void OllieKateView::setupGhostProvider()
{
auto *view = m_mainWindow->activeView();
if (!view) return;
if (m_ghost && m_ghost->view() == view) return;
if (m_ghost) {
delete m_ghost;
m_ghost = nullptr;
}
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<QKeyEvent *>(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 *ccIface = activeView ? dynamic_cast<KTextEditor::CodeCompletionInterface *>(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;
}
}
}
}
return QObject::eventFilter(obj, event);
}
#include "ollie_kate.moc"