diff --git a/CMakeLists.txt b/CMakeLists.txt index 93b847c..9078943 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,8 +8,12 @@ set(CMAKE_AUTOMOC ON) find_package(ECM REQUIRED NO_MODULE) set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) -find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets) +include(KDEInstallDirs) +include(KDECMakeSettings) + +find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets Quick) find_package(KF6StatusNotifierItem REQUIRED) +find_package(Plasma REQUIRED) # libollie.so built by Go (expected in build/ after `make lib`) set(OLLIE_LIB "${CMAKE_SOURCE_DIR}/build/libollie.so") @@ -17,7 +21,6 @@ set(OLLIE_HEADER "${CMAKE_SOURCE_DIR}/build/libollie.h") # --- Daemon --- -# Generate D-Bus adaptor from XML qt6_add_dbus_adaptor(DBUS_ADAPTOR_SRCS daemon/org.ollie.SessionManager.xml daemon/session_manager.h SessionManager) @@ -54,6 +57,31 @@ target_link_libraries(ollie-tray KF6::StatusNotifierItem ) +# --- Plasmoid QML Plugin --- + +qt6_add_qml_module(ollie-plasmoid-plugin + URI org.ollie.private + VERSION 1.0 + SOURCES + plasmoid/plugin/olliedbusclient.h + plasmoid/plugin/olliedbusclient.cpp + OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/org/ollie/private +) +target_link_libraries(ollie-plasmoid-plugin PRIVATE + Qt6::Core + Qt6::DBus + Qt6::Quick +) +target_include_directories(ollie-plasmoid-plugin PRIVATE + ${CMAKE_SOURCE_DIR}/plasmoid/plugin +) + +# --- Plasmoid Package --- + +plasma_install_package(plasmoid org.ollie.plasmoid) + # --- Install --- -install(TARGETS ollie-kde ollie-tray DESTINATION bin) +install(TARGETS ollie-kde ollie-tray DESTINATION ${KDE_INSTALL_BINDIR}) +install(TARGETS ollie-plasmoid-plugin + DESTINATION ${KDE_INSTALL_QMLDIR}/org/ollie/private) diff --git a/Makefile b/Makefile index 1b933ae..e8b8fa8 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,28 @@ clean: rm -rf $(BUILD_DIR) rm -f $(LIB_DIR)/libollie.so $(LIB_DIR)/libollie.h +# Install plasmoid to user's local plasma directory +plasmoid-install: daemon + kpackagetool6 -t Plasma/Applet -u plasmoid/ 2>/dev/null || \ + kpackagetool6 -t Plasma/Applet -i plasmoid/ + sudo mkdir -p /usr/lib64/qt6/qml/org/ollie/private + sudo cp $(BUILD_DIR)/org/ollie/private/ollie-plasmoid-pluginplugin.so \ + /usr/lib64/qt6/qml/org/ollie/private/libollie-plasmoid-pluginplugin.so + sudo cp $(BUILD_DIR)/org/ollie/private/ollie-plasmoid-plugin.qmltypes \ + /usr/lib64/qt6/qml/org/ollie/private/ + sudo cp $(BUILD_DIR)/libollie-plasmoid-plugin.so /usr/lib64/ + printf 'module org.ollie.private\nplugin ollie-plasmoid-pluginplugin\ntypeinfo ollie-plasmoid-plugin.qmltypes\ndepends QtQuick\n' | \ + sudo tee /usr/lib64/qt6/qml/org/ollie/private/qmldir > /dev/null + sudo ldconfig + @echo "Plasmoid installed. Restart plasmashell or add widget." + +plasmoid-uninstall: + kpackagetool6 -t Plasma/Applet -r org.ollie.plasmoid 2>/dev/null || true + sudo rm -rf /usr/lib64/qt6/qml/org/ollie + sudo rm -f /usr/lib64/libollie-plasmoid-plugin.so + sudo ldconfig + @echo "Plasmoid uninstalled." + install: daemon install -Dm755 $(BUILD_DIR)/ollie-kde $(DESTDIR)/usr/bin/ollie-kde install -Dm644 $(LIB_DIR)/libollie.so $(DESTDIR)/usr/lib/libollie.so diff --git a/lib/capi.go b/lib/capi.go index 6ef468c..48cd4d6 100644 --- a/lib/capi.go +++ b/lib/capi.go @@ -161,17 +161,41 @@ func ollie_create_session(cwdC *C.char, backendC *C.char, modelC *C.char, agentC } // Subscribe to events for the chat log + callback dispatch + streamingRole := "" // tracks current streaming role ("assistant" or "reasoning") core.Bus().Subscribe("event", func(ev agent.Event) { var text string switch ev.Role { - case "assistant": - text = ev.Content - case "info": - text = "[info] " + ev.Content - case "tool": - text = "[tool:" + ev.Name + "] " + ev.Content + case "assistant", "reasoning": + if streamingRole != ev.Role { + if streamingRole != "" { + text += "\n\n" + } + text += "[" + ev.Role + "]\n" + streamingRole = ev.Role + } + text += ev.Content default: - text = "[" + ev.Role + "] " + ev.Content + if ev.Role == "usage" { + return + } + if streamingRole != "" { + text += "\n\n" + streamingRole = "" + } + switch ev.Role { + case "user": + text += "[user]\n" + ev.Content + "\n\n" + case "call": + text += "[call:" + ev.Name + "]\n" + ev.Content + "\n\n" + case "tool": + text += "[tool:" + ev.Name + "]\n" + ev.Content + "\n\n" + case "error": + text += "[error]\n" + ev.Content + "\n\n" + case "info": + text += "[info] " + ev.Content + "\n\n" + default: + text += "[" + ev.Role + "]\n" + ev.Content + "\n\n" + } } if text == "" { return diff --git a/plasmoid/contents/ui/main.qml b/plasmoid/contents/ui/main.qml new file mode 100644 index 0000000..c1a0561 --- /dev/null +++ b/plasmoid/contents/ui/main.qml @@ -0,0 +1,205 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.plasma.plasmoid +import org.kde.plasma.components as PlasmaComponents +import org.kde.plasma.extras as PlasmaExtras +import org.kde.kirigami as Kirigami +import org.ollie.private as Ollie + +PlasmoidItem { + id: root + + // Convenience alias + readonly property var dbus: Ollie.OllieDBusClient + + preferredRepresentation: compactRepresentation + + toolTipMainText: "Ollie" + toolTipSubText: { + let n = dbus.sessions.length + if (n === 0) return "No active sessions" + return n + " session" + (n > 1 ? "s" : "") + } + + compactRepresentation: Item { + PlasmaComponents.Label { + anchors.centerIn: parent + text: { + let state = dbus.activeState + let n = dbus.sessions.length + if (n === 0) return "○" + if (state === "idle") return "◉" + if (state.startsWith("calling")) return "⚡" + return "●" + } + font.pixelSize: parent.height * 0.6 + } + MouseArea { + anchors.fill: parent + onClicked: root.expanded = !root.expanded + } + } + + fullRepresentation: ColumnLayout { + Layout.preferredWidth: 420 + Layout.preferredHeight: 520 + spacing: 0 + + // --- Header --- + RowLayout { + Layout.fillWidth: true + Layout.margins: 8 + + PlasmaExtras.Heading { + level: 4 + text: "Sessions" + Layout.fillWidth: true + } + + PlasmaComponents.ToolButton { + icon.name: "list-add" + onClicked: dbus.createSession("") + PlasmaComponents.ToolTip { text: "New session" } + } + } + + // --- Session list --- + ListView { + id: sessionList + Layout.fillWidth: true + Layout.preferredHeight: Math.min(contentHeight + 4, 130) + Layout.maximumHeight: 130 + Layout.leftMargin: 8 + Layout.rightMargin: 8 + clip: true + model: dbus.sessions + + delegate: ItemDelegate { + width: sessionList.width + highlighted: modelData.id === dbus.activeSessionId + + contentItem: RowLayout { + spacing: 6 + + Label { + text: { + let state = modelData.state + if (state === "idle") return "○" + if (state.startsWith("calling")) return "⚡" + return "●" + } + font.bold: true + color: modelData.state !== "idle" + ? Kirigami.Theme.positiveTextColor + : Kirigami.Theme.textColor + } + + Label { + text: modelData.id.substring(0, 14) + Layout.fillWidth: true + elide: Text.ElideRight + font.family: "monospace" + font.pixelSize: 11 + } + + Label { + text: modelData.agent + opacity: 0.7 + font.pixelSize: 11 + } + + PlasmaComponents.ToolButton { + icon.name: "process-stop" + implicitWidth: 22; implicitHeight: 22 + onClicked: dbus.killSession(modelData.id) + } + } + + onClicked: dbus.activeSessionId = modelData.id + } + } + + Kirigami.Separator { + Layout.fillWidth: true + visible: dbus.sessions.length > 0 + } + + // --- Chat view --- + ScrollView { + id: chatScroll + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: 4 + + TextArea { + id: chatArea + readOnly: true + wrapMode: TextEdit.Wrap + text: dbus.chatText || (dbus.activeSessionId + ? "(waiting for output...)" + : "Select or create a session") + font.family: "monospace" + font.pixelSize: 12 + color: dbus.chatText + ? Kirigami.Theme.textColor + : Kirigami.Theme.disabledTextColor + background: null + + onTextChanged: { + cursorPosition = text.length + } + } + } + + Kirigami.Separator { + Layout.fillWidth: true + } + + // --- Prompt input --- + RowLayout { + Layout.fillWidth: true + Layout.margins: 6 + spacing: 4 + + TextField { + id: promptInput + Layout.fillWidth: true + placeholderText: dbus.activeSessionId + ? "Message..." + : "No active session" + enabled: dbus.activeSessionId !== "" + onAccepted: { + if (text.trim() !== "" && dbus.activeSessionId !== "") { + dbus.submit( + dbus.activeSessionId, text.trim()) + text = "" + } + } + } + + PlasmaComponents.ToolButton { + icon.name: "media-playback-stop" + enabled: dbus.activeState !== "idle" + && dbus.activeSessionId !== "" + onClicked: dbus.interrupt( + dbus.activeSessionId) + PlasmaComponents.ToolTip { text: "Interrupt" } + } + } + + // --- Status bar --- + Label { + Layout.fillWidth: true + Layout.leftMargin: 8 + Layout.bottomMargin: 4 + font.pixelSize: 10 + opacity: 0.6 + text: { + let id = dbus.activeSessionId + if (!id) return "ollie-kde" + return dbus.activeState + " │ " + id.substring(0, 14) + } + } + } +} diff --git a/plasmoid/metadata.json b/plasmoid/metadata.json new file mode 100644 index 0000000..f55d168 --- /dev/null +++ b/plasmoid/metadata.json @@ -0,0 +1,16 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Id": "org.ollie.plasmoid", + "Name": "Ollie", + "Description": "AI agent session manager", + "Icon": "system-run", + "Category": "Utilities", + "Authors": [ + { + "Name": "ollie-kde" + } + ] + }, + "X-Plasma-API-Minimum-Version": "6.0" +} diff --git a/plasmoid/plugin/olliedbusclient.cpp b/plasmoid/plugin/olliedbusclient.cpp new file mode 100644 index 0000000..76a4f6c --- /dev/null +++ b/plasmoid/plugin/olliedbusclient.cpp @@ -0,0 +1,193 @@ +#include "olliedbusclient.h" + +#include +#include +#include + +static const char *SERVICE = "org.ollie.SessionManager"; +static const char *OBJ_PATH = "/org/ollie/SessionManager"; +static const char *IFACE = "org.ollie.SessionManager"; + +OllieDBusClient::OllieDBusClient(QObject *parent) + : QObject(parent) +{ + m_iface = new QDBusInterface(SERVICE, OBJ_PATH, IFACE, + QDBusConnection::sessionBus(), this); + + // Connect D-Bus signals + QDBusConnection bus = QDBusConnection::sessionBus(); + bus.connect(SERVICE, OBJ_PATH, IFACE, "SessionCreated", + this, SLOT(onSessionCreated(QString))); + bus.connect(SERVICE, OBJ_PATH, IFACE, "SessionKilled", + this, SLOT(onSessionKilled(QString))); + bus.connect(SERVICE, OBJ_PATH, IFACE, "StateChanged", + this, SLOT(onStateChanged(QString,QString))); + bus.connect(SERVICE, OBJ_PATH, IFACE, "ChatUpdated", + this, SLOT(onChatUpdated(QString,qlonglong,QString))); + + refreshSessions(); +} + +void OllieDBusClient::setActiveSessionId(const QString &id) +{ + if (m_activeSessionId == id) return; + m_activeSessionId = id; + m_chatText.clear(); + m_chatOffset = 0; + emit activeSessionIdChanged(); + emit chatTextChanged(); + fetchChat(); +} + +void OllieDBusClient::refreshSessions() +{ + QDBusReply reply = m_iface->call("ListSessions"); + if (!reply.isValid()) return; + + m_sessions.clear(); + for (const QString &entry : reply.value()) { + QStringList parts = entry.split('\t'); + if (parts.size() >= 4) { + QVariantMap session; + session["id"] = parts[0]; + session["state"] = parts[1]; + session["model"] = parts[2]; + session["agent"] = parts[3]; + m_sessions.append(session); + } + } + emit sessionsChanged(); + + // Update active state + for (const QVariant &v : m_sessions) { + QVariantMap s = v.toMap(); + if (s["id"].toString() == m_activeSessionId) { + QString newState = s["state"].toString(); + if (newState != m_activeState) { + m_activeState = newState; + emit activeStateChanged(); + } + return; + } + } + // Active session no longer exists + if (!m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) { + setActiveSessionId(m_sessions.first().toMap()["id"].toString()); + } else if (m_sessions.isEmpty() && !m_activeSessionId.isEmpty()) { + m_activeSessionId.clear(); + m_activeState = "idle"; + m_chatText.clear(); + m_chatOffset = 0; + emit activeSessionIdChanged(); + emit activeStateChanged(); + emit chatTextChanged(); + } +} + +void OllieDBusClient::fetchChat() +{ + if (m_activeSessionId.isEmpty()) return; + + QDBusMessage msg = QDBusMessage::createMethodCall(SERVICE, OBJ_PATH, IFACE, "GetChat"); + msg << m_activeSessionId << m_chatOffset; + QDBusMessage reply = QDBusConnection::sessionBus().call(msg); + + if (reply.type() != QDBusMessage::ReplyMessage) return; + QList args = reply.arguments(); + if (args.size() < 2) return; + + QString text = args[0].toString(); + qlonglong newOffset = args[1].toLongLong(); + + if (!text.isEmpty()) { + if (m_chatOffset == 0) { + m_chatText = text; + } else { + m_chatText += text; + } + m_chatOffset = newOffset; + emit chatTextChanged(); + } +} + +QString OllieDBusClient::createSession(const QString &cwd) +{ + QString dir = cwd.isEmpty() ? QDir::homePath() : cwd; + QDBusReply reply = m_iface->call("CreateSession", dir, QString(), QString(), "default"); + if (reply.isValid()) { + QString id = reply.value(); + refreshSessions(); + setActiveSessionId(id); + return id; + } + return QString(); +} + +bool OllieDBusClient::killSession(const QString &sessionId) +{ + QDBusReply reply = m_iface->call("KillSession", sessionId); + if (reply.isValid() && reply.value()) { + if (sessionId == m_activeSessionId) { + m_activeSessionId.clear(); + m_chatText.clear(); + m_chatOffset = 0; + m_activeState = "idle"; + emit activeSessionIdChanged(); + emit chatTextChanged(); + emit activeStateChanged(); + } + refreshSessions(); + return true; + } + return false; +} + +bool OllieDBusClient::submit(const QString &sessionId, const QString &prompt) +{ + QDBusReply reply = m_iface->call("Submit", sessionId, prompt); + return reply.isValid() && reply.value(); +} + +bool OllieDBusClient::interrupt(const QString &sessionId) +{ + QDBusReply reply = m_iface->call("Interrupt", sessionId); + return reply.isValid() && reply.value(); +} + +// --- D-Bus signal handlers --- + +void OllieDBusClient::onSessionCreated(const QString &session_id) +{ + Q_UNUSED(session_id); + refreshSessions(); +} + +void OllieDBusClient::onSessionKilled(const QString &session_id) +{ + Q_UNUSED(session_id); + refreshSessions(); +} + +void OllieDBusClient::onStateChanged(const QString &session_id, const QString &new_state) +{ + if (session_id == m_activeSessionId) { + m_activeState = new_state; + emit activeStateChanged(); + } + refreshSessions(); +} + +void OllieDBusClient::onChatUpdated(const QString &session_id, qlonglong offset, const QString &new_text) +{ + if (session_id != m_activeSessionId) return; + + // Append directly from the signal — no need to poll + if (offset == m_chatOffset) { + m_chatText += new_text; + m_chatOffset += new_text.size(); + emit chatTextChanged(); + } else { + // Offset mismatch — do a full fetch + fetchChat(); + } +} diff --git a/plasmoid/plugin/olliedbusclient.h b/plasmoid/plugin/olliedbusclient.h new file mode 100644 index 0000000..e9bc963 --- /dev/null +++ b/plasmoid/plugin/olliedbusclient.h @@ -0,0 +1,60 @@ +#ifndef OLLIE_DBUS_CLIENT_H +#define OLLIE_DBUS_CLIENT_H + +#include +#include +#include +#include +#include +#include +#include + +class OllieDBusClient : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QVariantList sessions READ sessions NOTIFY sessionsChanged) + Q_PROPERTY(QString activeSessionId READ activeSessionId WRITE setActiveSessionId NOTIFY activeSessionIdChanged) + Q_PROPERTY(QString chatText READ chatText NOTIFY chatTextChanged) + Q_PROPERTY(QString activeState READ activeState NOTIFY activeStateChanged) + +public: + explicit OllieDBusClient(QObject *parent = nullptr); + + QVariantList sessions() const { return m_sessions; } + QString activeSessionId() const { return m_activeSessionId; } + void setActiveSessionId(const QString &id); + QString chatText() const { return m_chatText; } + QString activeState() const { return m_activeState; } + + Q_INVOKABLE void refreshSessions(); + Q_INVOKABLE void fetchChat(); + Q_INVOKABLE QString createSession(const QString &cwd); + Q_INVOKABLE bool killSession(const QString &sessionId); + Q_INVOKABLE bool submit(const QString &sessionId, const QString &prompt); + Q_INVOKABLE bool interrupt(const QString &sessionId); + +signals: + void sessionsChanged(); + void activeSessionIdChanged(); + void chatTextChanged(); + void activeStateChanged(); + +private slots: + void onSessionCreated(const QString &session_id); + void onSessionKilled(const QString &session_id); + void onStateChanged(const QString &session_id, const QString &new_state); + void onChatUpdated(const QString &session_id, qlonglong offset, const QString &new_text); + +private: + QDBusInterface *m_iface; + QVariantList m_sessions; + QString m_activeSessionId; + QString m_chatText; + qlonglong m_chatOffset = 0; + QString m_activeState = "idle"; +}; + +#endif // OLLIE_DBUS_CLIENT_H