phase 5: KRunner plugin

- 'ollie <prompt>': one-shot mode (create, submit, await, notify, kill)
- 'ollie list': show active sessions with state
- 'ollie kill <id>': terminate a session
- KNotification shows result when one-shot completes
- Installs to /usr/lib64/qt6/plugins/kf6/krunner/
This commit is contained in:
Levi Neely 2026-07-05 23:23:08 +02:00
parent f4b5ce3ea0
commit e052424d62
5 changed files with 283 additions and 0 deletions

View File

@ -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

View File

@ -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

15
krunner/metadata.json Normal file
View File

@ -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
}

220
krunner/ollie_runner.cpp Normal file
View File

@ -0,0 +1,220 @@
#include "ollie_runner.h"
#include <KPluginFactory>
#include <QDBusReply>
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDir>
#include <QThread>
#include <QVariantMap>
#include <QDialog>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QTextEdit>
#include <QPushButton>
#include <QClipboard>
#include <QApplication>
#include <QLabel>
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<QString> 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<bool> 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<QString> 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<QStringList> 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"

27
krunner/ollie_runner.h Normal file
View File

@ -0,0 +1,27 @@
#ifndef OLLIE_RUNNER_H
#define OLLIE_RUNNER_H
#include <KRunner/AbstractRunner>
#include <KRunner/RunnerContext>
#include <KRunner/QueryMatch>
#include <QDBusInterface>
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