From e052424d620ecdfa1ef7a20b402170309f3ebd01 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:23:08 +0200 Subject: [PATCH] phase 5: KRunner plugin - 'ollie ': one-shot mode (create, submit, await, notify, kill) - 'ollie list': show active sessions with state - 'ollie kill ': terminate a session - KNotification shows result when one-shot completes - Installs to /usr/lib64/qt6/plugins/kf6/krunner/ --- CMakeLists.txt | 14 +++ Makefile | 7 ++ krunner/metadata.json | 15 +++ krunner/ollie_runner.cpp | 220 +++++++++++++++++++++++++++++++++++++++ krunner/ollie_runner.h | 27 +++++ 5 files changed, 283 insertions(+) create mode 100644 krunner/metadata.json create mode 100644 krunner/ollie_runner.cpp create mode 100644 krunner/ollie_runner.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9078943..3315a48 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,9 +10,13 @@ set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) include(KDEInstallDirs) include(KDECMakeSettings) +include(KDECompilerSettings NO_POLICY_SCOPE) + +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets Quick) find_package(KF6StatusNotifierItem REQUIRED) +find_package(KF6 REQUIRED COMPONENTS Runner Notifications CoreAddons Config) find_package(Plasma REQUIRED) # libollie.so built by Go (expected in build/ after `make lib`) @@ -57,6 +61,16 @@ target_link_libraries(ollie-tray KF6::StatusNotifierItem ) +# --- KRunner Plugin --- + +kcoreaddons_add_plugin(krunner_ollie SOURCES krunner/ollie_runner.cpp INSTALL_NAMESPACE "kf6/krunner") +target_link_libraries(krunner_ollie + KF6::Runner + Qt6::Core + Qt6::DBus + Qt6::Widgets +) + # --- Plasmoid QML Plugin --- qt6_add_qml_module(ollie-plasmoid-plugin diff --git a/Makefile b/Makefile index e8b8fa8..4b4a49c 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,13 @@ run: daemon tray: daemon ./$(BUILD_DIR)/ollie-tray +# Install KRunner plugin +krunner-install: daemon + sudo cp $(BUILD_DIR)/lib/kf6/krunner/krunner_ollie.so \ + /usr/lib64/qt6/plugins/kf6/krunner/ + kquitapp6 krunner 2>/dev/null; true + @echo "KRunner plugin installed. Restart krunner or re-login." + clean: rm -rf $(BUILD_DIR) rm -f $(LIB_DIR)/libollie.so $(LIB_DIR)/libollie.h diff --git a/krunner/metadata.json b/krunner/metadata.json new file mode 100644 index 0000000..f887577 --- /dev/null +++ b/krunner/metadata.json @@ -0,0 +1,15 @@ +{ + "KPlugin": { + "Id": "org.ollie.krunner", + "Name": "Ollie", + "Description": "AI agent prompts and session management", + "Icon": "system-run", + "Authors": [ + { + "Name": "ollie-kde" + } + ] + }, + "X-Plasma-API-Minimum-Version": "6.0", + "X-KDE-PluginInfo-EnabledByDefault": true +} diff --git a/krunner/ollie_runner.cpp b/krunner/ollie_runner.cpp new file mode 100644 index 0000000..33411b2 --- /dev/null +++ b/krunner/ollie_runner.cpp @@ -0,0 +1,220 @@ +#include "ollie_runner.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +K_PLUGIN_CLASS_WITH_JSON(OllieRunner, "metadata.json") + +static const char *SERVICE = "org.ollie.SessionManager"; +static const char *OBJ_PATH = "/org/ollie/SessionManager"; +static const char *IFACE = "org.ollie.SessionManager"; + +OllieRunner::OllieRunner(QObject *parent, const KPluginMetaData &data) + : AbstractRunner(parent, data) +{ + m_iface = new QDBusInterface(SERVICE, OBJ_PATH, IFACE, + QDBusConnection::sessionBus(), this); +} + +void OllieRunner::match(KRunner::RunnerContext &context) +{ + const QString query = context.query().trimmed(); + + // Only activate on "ollie" prefix + if (!query.startsWith(QLatin1String("ollie ")) && query != QLatin1String("ollie")) { + return; + } + + QString args = query.mid(6).trimmed(); // everything after "ollie " + + if (args.isEmpty() || args == QLatin1String("list")) { + // List sessions + QStringList sessions = listSessions(); + if (sessions.isEmpty()) { + KRunner::QueryMatch match(this); + match.setId("ollie-no-sessions"); + match.setText("No active sessions"); + match.setIconName("dialog-information"); + match.setRelevance(0.5); + context.addMatch(match); + } else { + for (const QString &entry : sessions) { + QStringList parts = entry.split('\t'); + if (parts.size() < 4) continue; + + KRunner::QueryMatch match(this); + match.setId("ollie-session-" + parts[0]); + QString state = parts[1] == "idle" ? "○" : "●"; + match.setText(state + " " + parts[0].left(14) + " [" + parts[3] + "]"); + match.setSubtext(parts[2] + " — " + parts[1]); + match.setIconName("system-run"); + match.setRelevance(0.7); + match.setData(parts[0]); // session ID + context.addMatch(match); + } + } + return; + } + + if (args.startsWith(QLatin1String("kill "))) { + QString target = args.mid(5).trimmed(); + KRunner::QueryMatch match(this); + match.setId("ollie-kill-" + target); + match.setText("Kill session: " + target); + match.setSubtext("Terminate ollie session"); + match.setIconName("process-stop"); + match.setRelevance(0.8); + match.setData("kill:" + target); + context.addMatch(match); + return; + } + + // Default: one-shot prompt + KRunner::QueryMatch match(this); + match.setId("ollie-prompt"); + match.setText("Ask Ollie: " + args); + match.setSubtext("One-shot prompt — result via notification"); + match.setIconName("system-run"); + match.setRelevance(0.9); + match.setData("prompt:" + args); + context.addMatch(match); +} + +void OllieRunner::run(const KRunner::RunnerContext &context, const KRunner::QueryMatch &match) +{ + Q_UNUSED(context); + QString data = match.data().toString(); + + if (data.startsWith("kill:")) { + QString id = data.mid(5); + killSession(id); + return; + } + + if (data.startsWith("prompt:")) { + QString prompt = data.mid(7); + QString result = oneShotRun(prompt); + + // Show popup window with result + auto *dialog = new QDialog(nullptr); + dialog->setWindowTitle("Ollie — " + prompt.left(40)); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->resize(520, 380); + + auto *layout = new QVBoxLayout(dialog); + + auto *textEdit = new QTextEdit(dialog); + textEdit->setReadOnly(true); + textEdit->setPlainText(result); + textEdit->setFont(QFont("monospace", 11)); + layout->addWidget(textEdit); + + auto *buttonLayout = new QHBoxLayout(); + buttonLayout->addStretch(); + + auto *copyBtn = new QPushButton("Copy", dialog); + QObject::connect(copyBtn, &QPushButton::clicked, [result]() { + QApplication::clipboard()->setText(result); + }); + buttonLayout->addWidget(copyBtn); + + auto *closeBtn = new QPushButton("Close", dialog); + QObject::connect(closeBtn, &QPushButton::clicked, dialog, &QDialog::close); + buttonLayout->addWidget(closeBtn); + + layout->addLayout(buttonLayout); + + dialog->show(); + dialog->raise(); + dialog->activateWindow(); + return; + } + + // Clicked a session from list — not much to do in KRunner + // Could open the plasmoid in the future +} + +// --- D-Bus helpers --- + +QString OllieRunner::oneShotRun(const QString &prompt) +{ + // 1. Create session + QDBusReply createReply = m_iface->call( + "CreateSession", QDir::homePath(), QString(), QString(), "default"); + if (!createReply.isValid()) { + return "Error: could not create session"; + } + QString sessionId = createReply.value(); + + // 2. Submit prompt + QDBusReply submitReply = m_iface->call("Submit", sessionId, prompt); + if (!submitReply.isValid() || !submitReply.value()) { + killSession(sessionId); + return "Error: could not submit prompt"; + } + + // 3. Poll for completion (max 30s) + QString state = "thinking"; + for (int i = 0; i < 60; i++) { + QThread::msleep(500); + + QDBusReply stateReply = m_iface->call("GetState", sessionId); + if (!stateReply.isValid()) break; + state = stateReply.value(); + if (state == "idle") break; + } + + // 4. Get the chat output + QDBusMessage msg = QDBusMessage::createMethodCall(SERVICE, OBJ_PATH, IFACE, "GetChat"); + msg << sessionId << (qlonglong)0; + QDBusMessage reply = QDBusConnection::sessionBus().call(msg); + + QString chatText; + if (reply.type() == QDBusMessage::ReplyMessage && reply.arguments().size() >= 1) { + chatText = reply.arguments()[0].toString(); + } + + // 5. Extract assistant response (text between [assistant]\n and next \n\n[) + QString result; + int aStart = chatText.indexOf("[assistant]\n"); + if (aStart >= 0) { + aStart += 12; // skip "[assistant]\n" + int aEnd = chatText.indexOf("\n\n[", aStart); + if (aEnd < 0) aEnd = chatText.length(); + result = chatText.mid(aStart, aEnd - aStart).trimmed(); + } else { + result = chatText; // fallback: show everything + } + + // 6. Kill session + killSession(sessionId); + + return result.isEmpty() ? "(no response)" : result; +} + +QStringList OllieRunner::listSessions() +{ + QDBusReply reply = m_iface->call("ListSessions"); + if (reply.isValid()) return reply.value(); + return {}; +} + +void OllieRunner::killSession(const QString &id) +{ + m_iface->call("KillSession", id); +} + +#include "ollie_runner.moc" diff --git a/krunner/ollie_runner.h b/krunner/ollie_runner.h new file mode 100644 index 0000000..3b364e4 --- /dev/null +++ b/krunner/ollie_runner.h @@ -0,0 +1,27 @@ +#ifndef OLLIE_RUNNER_H +#define OLLIE_RUNNER_H + +#include +#include +#include +#include + +class OllieRunner : public KRunner::AbstractRunner +{ + Q_OBJECT + +public: + OllieRunner(QObject *parent, const KPluginMetaData &data); + + void match(KRunner::RunnerContext &context) override; + void run(const KRunner::RunnerContext &context, const KRunner::QueryMatch &match) override; + +private: + QDBusInterface *m_iface; + + QString oneShotRun(const QString &prompt); + QStringList listSessions(); + void killSession(const QString &id); +}; + +#endif // OLLIE_RUNNER_H