phase 4: plasma widget (plasmoid)

- QML plasmoid with session list, live chat view, inline prompt input
- C++ QML plugin (OllieDBusClient) for D-Bus interaction
- Real-time updates via D-Bus signals (ChatUpdated, StateChanged)
- State indicators: ○ idle, ● thinking, ⚡ calling tool
- Auto-scroll chat, interrupt button, new session button
- Makefile: plasmoid-install/uninstall targets for local dev
This commit is contained in:
Levi Neely 2026-07-05 22:58:02 +02:00
parent 238efabcb5
commit f4b5ce3ea0
7 changed files with 558 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

16
plasmoid/metadata.json Normal file
View File

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

View File

@ -0,0 +1,193 @@
#include "olliedbusclient.h"
#include <QDBusReply>
#include <QDBusConnection>
#include <QDir>
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<QStringList> 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<QVariant> 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<QString> 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<bool> 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<bool> reply = m_iface->call("Submit", sessionId, prompt);
return reply.isValid() && reply.value();
}
bool OllieDBusClient::interrupt(const QString &sessionId)
{
QDBusReply<bool> 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();
}
}

View File

@ -0,0 +1,60 @@
#ifndef OLLIE_DBUS_CLIENT_H
#define OLLIE_DBUS_CLIENT_H
#include <QObject>
#include <QDBusInterface>
#include <QDBusConnection>
#include <QStringList>
#include <QVariantList>
#include <QTimer>
#include <QQmlEngine>
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