#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 #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); } void OllieKatePlugin::saveConfig() { KConfigGroup grp(KSharedConfig::openConfig(), QStringLiteral("Ollie")); grp.writeEntry("CompletionEnabled", m_completionEnabled); grp.sync(); } void OllieKatePlugin::setCompletionEnabled(bool enabled) { m_completionEnabled = enabled; saveConfig(); } int OllieKatePlugin::configPages() const { return 1; } KTextEditor::ConfigPage *OllieKatePlugin::configPage(int number, QWidget *parent) { if (number != 0) return nullptr; class OllieConfigPage : public KTextEditor::ConfigPage { public: OllieConfigPage(OllieKatePlugin *plugin, QWidget *parent) : KTextEditor::ConfigPage(parent), m_plugin(plugin) { auto *layout = new QVBoxLayout(this); m_enabledCheck = new QCheckBox(QStringLiteral("Automatic completion (ghost text)"), this); layout->addWidget(m_enabledCheck); layout->addStretch(); reset(); connect(m_enabledCheck, &QCheckBox::toggled, this, &ConfigPage::changed); } QString name() const override { return QStringLiteral("Ollie"); } QString fullName() const override { return QStringLiteral("Ollie Settings"); } QIcon icon() const override { return QIcon::fromTheme(QStringLiteral("system-run")); } void apply() override { m_plugin->setCompletionEnabled(m_enabledCheck->isChecked()); } void reset() override { m_enabledCheck->setChecked(m_plugin->completionEnabled()); } void defaults() override { m_enabledCheck->setChecked(true); } private: OllieKatePlugin *m_plugin; QCheckBox *m_enabledCheck; }; return new OllieConfigPage(this, parent); } // --- View --- OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *mainWindow) : QObject(mainWindow) , m_plugin(plugin) , m_mainWindow(mainWindow) , m_9p(new Lib9pClient(this)) , m_heartbeatTimer(new QTimer(this)) { // Connect to 9P server m_connected = m_9p->connectDefault(); if (!m_connected) { qWarning() << "Ollie: failed to connect to 9P server:" << m_9p->lastError(); } // Heartbeat timer — detect server death and reconnect m_heartbeatTimer->setInterval(5000); connect(m_heartbeatTimer, &QTimer::timeout, this, &OllieKateView::heartbeat); m_heartbeatTimer->start(); // 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() { m_heartbeatTimer->stop(); 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"; } // Look for an existing agent in the kate session QStringList agents = m_9p->ls("session/kate/agent"); agents.removeAll("new"); agents.removeAll("idx"); if (!agents.isEmpty()) { m_agentId = agents.first(); } else { // Create one 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() { // Check if kate session exists QByteArray sessCheck = m_9p->read("session/kate/id"); if (sessCheck.isEmpty()) return false; m_sessionId = "kate"; // Find first agent in the session QStringList agents = m_9p->ls("session/kate/agent"); agents.removeAll("new"); agents.removeAll("idx"); if (agents.isEmpty()) return false; QString agentId = agents.first(); if (agentId != m_agentId) { m_agentId = agentId; } // Update cwd to match current project QString baseDir = projectBaseDir(); if (!baseDir.isEmpty() && !m_agentId.isEmpty()) { m_9p->write(agentPath() + "/ctl", ("cwd " + baseDir).toUtf8()); } updateStatusLabel(); return true; } void OllieKateView::switchToAgent(const QString &agentId) { m_sessionId = "kate"; m_agentId = agentId; updateStatusLabel(); } void OllieKateView::onProjectChanged() { // Update the agent's cwd to the new project directory QString baseDir = projectBaseDir(); if (!baseDir.isEmpty() && !m_agentId.isEmpty()) { m_9p->write(agentPath() + "/ctl", ("cwd " + baseDir).toUtf8()); } 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_connected) { m_statusLabel->setText(QStringLiteral("✗ ollie: disconnected")); m_statusLabel->setStyleSheet(QStringLiteral("color: red; padding: 0px 4px;")); } else if (m_agentId.isEmpty()) { m_statusLabel->setText(QStringLiteral("○ ollie: no agent")); m_statusLabel->setStyleSheet(QStringLiteral("color: gray; padding: 0px 4px;")); } else { m_statusLabel->setText(QStringLiteral("● ollie: kate/") + m_agentId); m_statusLabel->setStyleSheet(QStringLiteral("color: green; padding: 0px 4px;")); } } // --- Connection Recovery --- void OllieKateView::heartbeat() { // Try a cheap read to verify daemon is alive if (!m_9p->isConnected()) { if (!m_9p->connectDefault()) { if (m_connected) { m_connected = false; updateStatusLabel(); } return; } } QByteArray result = m_9p->read(QStringLiteral("help")); bool alive = !result.isEmpty(); if (alive && !m_connected) { // Recovered — re-establish state m_connected = true; selectAgentForProject(); updateStatusLabel(); } else if (!alive && m_connected) { // Lost connection m_connected = false; m_9p->disconnect(); updateStatusLabel(); } } // --- 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); } auto *startAction = menu->addAction(QIcon::fromTheme(QStringLiteral("system-run")), QStringLiteral("Ollie: Start Session")); startAction->setObjectName(QStringLiteral("ollie_ctx_start")); connect(startAction, &QAction::triggered, this, &OllieKateView::startSession); 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")); }); auto *verbatimAction = menu->addAction(QIcon::fromTheme(QStringLiteral("document-send")), QStringLiteral("Ollie: Send verbatim")); verbatimAction->setObjectName(QStringLiteral("ollie_ctx_verbatim")); connect(verbatimAction, &QAction::triggered, this, &OllieKateView::sendVerbatim); }); } 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::sendVerbatim() { auto *view = m_mainWindow->activeView(); if (!view || !view->selection()) return; QString selection = view->selectionText(); if (selection.isEmpty()) return; ensureAgent(); if (m_agentId.isEmpty()) return; // Derive fence language from document highlighting mode QString mode = view->document()->highlightingMode().toLower(); // Map common Kate highlighting names to fence languages static const QHash modeMap = { {"c++", "cpp"}, {"c", "c"}, {"cmake", "cmake"}, {"javascript", "javascript"}, {"typescript", "typescript"}, {"python", "python"}, {"rust", "rust"}, {"go", "go"}, {"bash", "bash"}, {"zsh", "bash"}, {"shell", "bash"}, {"json", "json"}, {"yaml", "yaml"}, {"toml", "toml"}, {"html", "html"}, {"css", "css"}, {"sql", "sql"}, {"java", "java"}, {"kotlin", "kotlin"}, {"ruby", "ruby"}, {"perl", "perl"}, {"lua", "lua"}, {"diff", "diff"}, {"markdown", "markdown"}, {"xml", "xml"}, }; QString lang = modeMap.value(mode, mode); QString prompt = "```" + lang + "\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()); } void OllieKateView::startSession() { ensureAgent(); // Update cwd to current project QString baseDir = projectBaseDir(); if (!baseDir.isEmpty() && !m_agentId.isEmpty()) { m_9p->write(agentPath() + "/ctl", ("cwd " + baseDir).toUtf8()); } updateStatusLabel(); } // --- 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); m_ghost->setEnabled(m_plugin->completionEnabled()); 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; } } if (keyEvent->key() == Qt::Key_Escape) { m_ghost->dismiss(); return true; } } } return QObject::eventFilter(obj, event); } #include "ollie_kate.moc"