#include "ollie_kate.h" #include "ollie_ghost.h" #include #include #include #include #include #include #if KTEXTEDITOR_VERSION_MAJOR < 6 #include #endif #include #include #include #include #include #include #include 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(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(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(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"