ollie/kde/kate/ollie_kate.cpp

630 lines
24 KiB
C++

#include "ollie_kate.h"
#include "ollie_ghost.h"
#include <KPluginFactory>
#include <KSharedConfig>
#include <KConfigGroup>
#include <KTextEditor/Document>
#include <KTextEditor/Editor>
#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 ---
OllieKatePlugin::OllieKatePlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(data);
Q_UNUSED(args);
loadConfig();
}
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()));
}
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();
}
});
// 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()) {
QString cwd = projectBaseDir();
if (cwd.isEmpty()) cwd = QDir::homePath();
m_9p->write("session/new", ("name=kate\ncwd=" + cwd + "\n").toUtf8());
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 = QStringLiteral("Read-only task. Inspect the complete file ") + filePath + QStringLiteral(" and describe it. Do not edit files. Report the file's purpose, main components, important data flow, dependencies, and relevant tests or checks. Do not guess details that are not present in the file or repository.\n\nFile: ") + filePath + QStringLiteral("\n\nGive a concise implementation-oriented overview.");
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 prompt = QStringLiteral("Read-only task. Inspect the actual file ") + filePath + QStringLiteral(" and explain the selected code below. Do not edit files. Explain what it does, how it works, important inputs and outputs, and any risks or edge cases. The selection is the target, not necessarily the complete context.\n\nFile: ") + filePath + QStringLiteral("\nSelected code:\n\n") + selection + QStringLiteral("\n\nGive a concise explanation. Do not propose or apply a patch.");
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();
QString prompt;
if (action == QStringLiteral("Fix")) {
prompt = QStringLiteral("Edit the source file ") + filePath + QStringLiteral(".\n\nThe selected code below is the target. It may be incomplete context:\n\n") + selection + QStringLiteral("\n\nInstructions:\n- Read the actual file and relevant surrounding code before editing.\n- Find the concrete bug or problem in the selected code.\n- Make the smallest safe fix. Do not rewrite unrelated code.\n- Preserve intended behavior outside the fix.\n- Apply the fix directly to the file. Do not only describe a patch.\n- Run formatting and the most relevant tests or diagnostics if available.\n\nAfter editing, report the file changed, the problem fixed, and verification performed.");
} else if (action == QStringLiteral("Refactor")) {
prompt = QStringLiteral("Edit the source file ") + filePath + QStringLiteral(".\n\nThe selected code below is the target. It may be incomplete context:\n\n") + selection + QStringLiteral("\n\nInstructions:\n- Read the actual file and relevant surrounding code before editing.\n- Refactor the selected code without changing its external behavior.\n- Make one focused improvement. Do not rewrite unrelated code.\n- Follow the existing project style. Do not add unnecessary abstractions.\n- Apply the refactor directly to the file. Do not only describe a patch.\n- Run formatting and the most relevant tests or diagnostics if available.\n\nAfter editing, report the file changed, the refactor made, and verification performed.");
} else if (action == QStringLiteral("Write unit tests for")) {
prompt = QStringLiteral("Edit the source tree to add tests for the selected code in ") + filePath + QStringLiteral(".\n\nThe selected code below is the target. It may be incomplete context:\n\n") + selection + QStringLiteral("\n\nInstructions:\n- Read the actual source file, existing tests, and project test conventions first.\n- Add focused unit tests for normal behavior, important edge cases, and errors.\n- Do not change production code unless a test requires a minimal testability fix.\n- Do not rewrite or remove existing tests.\n- Apply the tests directly to the repository. Do not only describe test code.\n- Format the files and run the relevant tests.\n\nAfter editing, report the test files changed and the test command and result.");
} else {
prompt = QStringLiteral("Edit the source file ") + filePath + QStringLiteral(".\n\nThe selected code below is the target. It may be incomplete context:\n\n") + selection + QStringLiteral("\n\nInstructions:\n- Read the actual file and relevant surrounding code before editing.\n- Add documentation comments to the selected declarations.\n- Do not change executable code or unrelated code.\n- Match the language and existing project style.\n- Document behavior, parameters, return values, errors, and non-obvious edge cases.\n- Apply the edit directly to the file. Do not only describe a patch.\n- Run formatting or relevant checks if available.\n\nAfter editing, report the file changed and a one-sentence summary.");
}
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("Read-only task. Review the following diff. Do not edit files. Identify correctness bugs, regressions, security issues, missing tests, and maintainability concerns. For each finding, explain the impact and cite the relevant hunk. If there are no findings, say so explicitly.\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 (QApplication::activePopupWidget() ||
(activeView && activeView->isCompletionActive()))
return QObject::eventFilter(obj, event);
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"