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

649 lines
21 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 <QCheckBox>
#include <QDir>
#include <QFileInfo>
#include <QHash>
#include <QKeyEvent>
#include <QMainWindow>
#include <QMenu>
#include <QStatusBar>
#include <QVBoxLayout>
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<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()
{
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<QString, QString> 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<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;
}
}
if (keyEvent->key() == Qt::Key_Escape) {
m_ghost->dismiss();
return true;
}
}
}
return QObject::eventFilter(obj, event);
}
#include "ollie_kate.moc"