diff --git a/kde/gui/ChatPane.qml b/kde/gui/ChatPane.qml
index 6f5e413..79dfded 100644
--- a/kde/gui/ChatPane.qml
+++ b/kde/gui/ChatPane.qml
@@ -400,6 +400,16 @@ Item {
ToolTip.text: "Background processes"
}
+ // Peers button
+ ToolButton {
+ text: "🔗 Peers"
+ font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11
+ implicitHeight: 28
+ onClicked: peerManagerWindow.show()
+ ToolTip.visible: hovered
+ ToolTip.text: "Manage peer connections"
+ }
+
Item { Layout.fillWidth: true } // Spacer
}
diff --git a/kde/gui/PeerManagerWindow.qml b/kde/gui/PeerManagerWindow.qml
new file mode 100644
index 0000000..0c9d7d3
--- /dev/null
+++ b/kde/gui/PeerManagerWindow.qml
@@ -0,0 +1,491 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import QtQuick.Window
+
+Window {
+ id: peerWindow
+ title: "Peer Connections" + (currentSessionName ? " — " + currentSessionName : "")
+ width: 700
+ height: 500
+ minimumWidth: 500
+ minimumHeight: 400
+
+ property string currentSessionId: ollie.activeSessionId
+ property string currentAgentId: ollie.activeAgentId
+ property string currentSessionName: ""
+ property var agents: [] // [{id, name, x, y, peers: [name1, name2...]}]
+ property var selectedNode: null
+ property var connectingFrom: null // Node we're drawing connection from
+
+ function refresh() {
+ if (!currentSessionId) {
+ agents = []
+ return
+ }
+
+ var agentList = ollie.listAgentsInSession(currentSessionId)
+ var newAgents = []
+ var radius = Math.min(canvas.width, canvas.height) * 0.35
+ var centerX = canvas.width / 2
+ var centerY = canvas.height / 2
+
+ for (var i = 0; i < agentList.length; i++) {
+ var a = agentList[i]
+ // Skip sub-agents (depth > 0)
+ if (a.depth > 0) continue
+
+ var peers = ollie.listPeers(currentSessionId, a.id)
+
+ // Position in circle, or keep existing position
+ var existing = findAgent(a.id)
+ var angle = (2 * Math.PI * newAgents.length) / Math.max(agentList.length, 1) - Math.PI / 2
+
+ newAgents.push({
+ id: a.id,
+ name: a.name,
+ state: a.state,
+ x: existing ? existing.x : centerX + radius * Math.cos(angle),
+ y: existing ? existing.y : centerY + radius * Math.sin(angle),
+ peers: peers
+ })
+ }
+ agents = newAgents
+ canvas.requestPaint()
+ }
+
+ function findAgent(id) {
+ for (var i = 0; i < agents.length; i++) {
+ if (agents[i].id === id) return agents[i]
+ }
+ return null
+ }
+
+ function findAgentByName(name) {
+ for (var i = 0; i < agents.length; i++) {
+ if (agents[i].name === name) return agents[i]
+ }
+ return null
+ }
+
+ function nodeAt(x, y) {
+ var nodeRadius = 30
+ for (var i = agents.length - 1; i >= 0; i--) {
+ var a = agents[i]
+ var dx = x - a.x
+ var dy = y - a.y
+ if (dx * dx + dy * dy <= nodeRadius * nodeRadius) {
+ return a
+ }
+ }
+ return null
+ }
+
+ function updateSessionName() {
+ if (currentSessionId) {
+ var cfg = ollie.readSessionConfig(currentSessionId)
+ currentSessionName = cfg.name || currentSessionId
+ } else {
+ currentSessionName = ""
+ }
+ }
+
+ Timer {
+ id: refreshTimer
+ interval: 3000
+ running: peerWindow.visible
+ repeat: true
+ onTriggered: refresh()
+ }
+
+ onVisibleChanged: {
+ if (visible) {
+ currentSessionId = ollie.activeSessionId
+ currentAgentId = ollie.activeAgentId
+ updateSessionName()
+ refresh()
+ }
+ }
+
+ Connections {
+ target: ollie
+ function onActiveSessionIdChanged() {
+ if (peerWindow.visible) {
+ currentSessionId = ollie.activeSessionId
+ currentAgentId = ollie.activeAgentId
+ updateSessionName()
+ selectedNode = null
+ connectingFrom = null
+ refresh()
+ }
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: 8
+ spacing: 8
+
+ // Instructions
+ Label {
+ Layout.fillWidth: true
+ text: "Drag nodes to position. Click two nodes to connect/disconnect. Right-click for options."
+ opacity: 0.7
+ font.pixelSize: 11
+ }
+
+ // Canvas for graph
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ color: palette.base
+ border.color: palette.mid
+ border.width: 1
+ clip: true
+
+ Canvas {
+ id: canvas
+ anchors.fill: parent
+
+ property real panX: 0
+ property real panY: 0
+ property real zoom: 1.0
+ property point lastMouse
+ property bool panning: false
+ property var draggingNode: null
+ property point dragOffset
+
+ onPaint: {
+ var ctx = getContext("2d")
+ ctx.reset()
+ ctx.fillStyle = palette.base
+ ctx.fillRect(0, 0, width, height)
+
+ ctx.save()
+ ctx.translate(panX, panY)
+ ctx.scale(zoom, zoom)
+
+ var nodeRadius = 30
+
+ // Draw edges first
+ ctx.strokeStyle = palette.highlight
+ ctx.lineWidth = 2
+ for (var i = 0; i < agents.length; i++) {
+ var a = agents[i]
+ for (var j = 0; j < a.peers.length; j++) {
+ var peer = findAgentByName(a.peers[j])
+ if (peer && peer.id > a.id) { // Draw each edge once
+ ctx.beginPath()
+ ctx.moveTo(a.x, a.y)
+ ctx.lineTo(peer.x, peer.y)
+ ctx.stroke()
+ }
+ }
+ }
+
+ // Draw connection preview
+ if (connectingFrom && mouseArea.containsMouse) {
+ ctx.strokeStyle = "#888"
+ ctx.setLineDash([5, 5])
+ ctx.beginPath()
+ ctx.moveTo(connectingFrom.x, connectingFrom.y)
+ var mx = (mouseArea.mouseX - panX) / zoom
+ var my = (mouseArea.mouseY - panY) / zoom
+ ctx.lineTo(mx, my)
+ ctx.stroke()
+ ctx.setLineDash([])
+ }
+
+ // Draw nodes
+ for (var i = 0; i < agents.length; i++) {
+ var a = agents[i]
+ var isSelected = selectedNode && selectedNode.id === a.id
+ var isConnecting = connectingFrom && connectingFrom.id === a.id
+ var isActive = a.id === currentAgentId
+
+ // Node circle
+ ctx.beginPath()
+ ctx.arc(a.x, a.y, nodeRadius, 0, 2 * Math.PI)
+
+ // Fill based on state
+ if (a.state === "idle") {
+ ctx.fillStyle = "#4CAF50"
+ } else if (a.state === "thinking" || a.state === "calling") {
+ ctx.fillStyle = "#2196F3"
+ } else if (a.state === "paused") {
+ ctx.fillStyle = "#9E9E9E"
+ } else {
+ ctx.fillStyle = "#FF9800"
+ }
+ ctx.fill()
+
+ // Border
+ if (isSelected || isConnecting) {
+ ctx.strokeStyle = palette.highlightedText
+ ctx.lineWidth = 3
+ } else if (isActive) {
+ ctx.strokeStyle = palette.highlight
+ ctx.lineWidth = 2
+ } else {
+ ctx.strokeStyle = palette.text
+ ctx.lineWidth = 1
+ }
+ ctx.stroke()
+
+ // Label
+ ctx.fillStyle = palette.text
+ ctx.font = "12px sans-serif"
+ ctx.textAlign = "center"
+ ctx.textBaseline = "middle"
+ var label = a.name.length > 10 ? a.name.substring(0, 9) + "…" : a.name
+ ctx.fillText(label, a.x, a.y + nodeRadius + 15)
+ }
+
+ ctx.restore()
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
+
+ onPressed: function(mouse) {
+ var x = (mouse.x - canvas.panX) / canvas.zoom
+ var y = (mouse.y - canvas.panY) / canvas.zoom
+ var node = nodeAt(x, y)
+
+ if (mouse.button === Qt.MiddleButton) {
+ canvas.panning = true
+ canvas.lastMouse = Qt.point(mouse.x, mouse.y)
+ } else if (mouse.button === Qt.RightButton && node) {
+ selectedNode = node
+ nodeContextMenu.popup()
+ } else if (mouse.button === Qt.LeftButton) {
+ if (node) {
+ if (connectingFrom) {
+ // Complete connection
+ if (connectingFrom.id !== node.id) {
+ toggleConnection(connectingFrom, node)
+ }
+ connectingFrom = null
+ } else {
+ // Start drag or connection
+ canvas.draggingNode = node
+ canvas.dragOffset = Qt.point(x - node.x, y - node.y)
+ selectedNode = node
+ }
+ } else {
+ // Click on empty space
+ if (connectingFrom) {
+ connectingFrom = null
+ } else {
+ selectedNode = null
+ }
+ }
+ canvas.requestPaint()
+ }
+ }
+
+ onReleased: function(mouse) {
+ if (canvas.panning) {
+ canvas.panning = false
+ }
+ if (canvas.draggingNode) {
+ canvas.draggingNode = null
+ }
+ }
+
+ onPositionChanged: function(mouse) {
+ if (canvas.panning) {
+ canvas.panX += mouse.x - canvas.lastMouse.x
+ canvas.panY += mouse.y - canvas.lastMouse.y
+ canvas.lastMouse = Qt.point(mouse.x, mouse.y)
+ canvas.requestPaint()
+ } else if (canvas.draggingNode) {
+ var x = (mouse.x - canvas.panX) / canvas.zoom
+ var y = (mouse.y - canvas.panY) / canvas.zoom
+ canvas.draggingNode.x = x - canvas.dragOffset.x
+ canvas.draggingNode.y = y - canvas.dragOffset.y
+ canvas.requestPaint()
+ } else if (connectingFrom) {
+ canvas.requestPaint()
+ }
+ }
+
+ onWheel: function(wheel) {
+ var factor = wheel.angleDelta.y > 0 ? 1.1 : 0.9
+ var newZoom = Math.max(0.3, Math.min(3.0, canvas.zoom * factor))
+
+ // Zoom toward mouse position
+ var mx = wheel.x
+ var my = wheel.y
+ canvas.panX = mx - (mx - canvas.panX) * (newZoom / canvas.zoom)
+ canvas.panY = my - (my - canvas.panY) * (newZoom / canvas.zoom)
+ canvas.zoom = newZoom
+ canvas.requestPaint()
+ }
+
+ onDoubleClicked: function(mouse) {
+ var x = (mouse.x - canvas.panX) / canvas.zoom
+ var y = (mouse.y - canvas.panY) / canvas.zoom
+ var node = nodeAt(x, y)
+ if (node) {
+ // Start connecting from this node
+ connectingFrom = node
+ canvas.requestPaint()
+ }
+ }
+ }
+
+ Menu {
+ id: nodeContextMenu
+
+ MenuItem {
+ text: "Connect to..."
+ enabled: selectedNode !== null
+ onTriggered: {
+ connectingFrom = selectedNode
+ canvas.requestPaint()
+ }
+ }
+
+ MenuItem {
+ text: "Disconnect all"
+ enabled: selectedNode !== null && selectedNode.peers.length > 0
+ onTriggered: {
+ if (selectedNode) {
+ for (var i = 0; i < selectedNode.peers.length; i++) {
+ ollie.removePeer(currentSessionId, selectedNode.id, selectedNode.peers[i])
+ }
+ refresh()
+ }
+ }
+ }
+
+ MenuSeparator {}
+
+ MenuItem {
+ text: "Switch to agent"
+ enabled: selectedNode !== null
+ onTriggered: {
+ if (selectedNode) {
+ ollie.switchAgent(currentSessionId, selectedNode.id)
+ }
+ }
+ }
+ }
+ }
+
+ // Zoom controls
+ RowLayout {
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+ anchors.margins: 8
+ spacing: 4
+
+ Button {
+ text: "−"
+ implicitWidth: 30
+ implicitHeight: 30
+ onClicked: {
+ canvas.zoom = Math.max(0.3, canvas.zoom * 0.8)
+ canvas.requestPaint()
+ }
+ }
+
+ Label {
+ text: Math.round(canvas.zoom * 100) + "%"
+ Layout.preferredWidth: 45
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ Button {
+ text: "+"
+ implicitWidth: 30
+ implicitHeight: 30
+ onClicked: {
+ canvas.zoom = Math.min(3.0, canvas.zoom * 1.25)
+ canvas.requestPaint()
+ }
+ }
+
+ Button {
+ text: "Reset"
+ implicitWidth: 50
+ implicitHeight: 30
+ onClicked: {
+ canvas.zoom = 1.0
+ canvas.panX = 0
+ canvas.panY = 0
+ refresh() // Re-layout nodes
+ }
+ }
+ }
+ }
+
+ // Action buttons
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ Button {
+ text: "Refresh"
+ icon.name: "view-refresh"
+ onClicked: refresh()
+ }
+
+ Button {
+ text: "Auto Layout"
+ icon.name: "view-grid"
+ onClicked: {
+ // Re-position all nodes in a circle
+ var radius = Math.min(canvas.width, canvas.height) * 0.35 / canvas.zoom
+ var centerX = (canvas.width / 2 - canvas.panX) / canvas.zoom
+ var centerY = (canvas.height / 2 - canvas.panY) / canvas.zoom
+
+ for (var i = 0; i < agents.length; i++) {
+ var angle = (2 * Math.PI * i) / agents.length - Math.PI / 2
+ agents[i].x = centerX + radius * Math.cos(angle)
+ agents[i].y = centerY + radius * Math.sin(angle)
+ }
+ canvas.requestPaint()
+ }
+ }
+
+ Item { Layout.fillWidth: true }
+
+ Label {
+ visible: connectingFrom !== null
+ text: "Click another node to connect, or empty space to cancel"
+ color: palette.highlight
+ }
+
+ Item { Layout.fillWidth: true }
+
+ Button {
+ text: "Close"
+ icon.name: "window-close"
+ onClicked: peerWindow.close()
+ }
+ }
+ }
+
+ function toggleConnection(a, b) {
+ // Check if already connected
+ var connected = false
+ for (var i = 0; i < a.peers.length; i++) {
+ if (a.peers[i] === b.name) {
+ connected = true
+ break
+ }
+ }
+
+ if (connected) {
+ ollie.removePeer(currentSessionId, a.id, b.name)
+ } else {
+ ollie.addPeer(currentSessionId, a.id, b.name)
+ }
+ refresh()
+ }
+}
diff --git a/kde/gui/main.qml b/kde/gui/main.qml
index f6a8341..5286bb7 100644
--- a/kde/gui/main.qml
+++ b/kde/gui/main.qml
@@ -197,6 +197,12 @@ ApplicationWindow {
visible: false
}
+ // --- Peer Manager Window ---
+ PeerManagerWindow {
+ id: peerManagerWindow
+ visible: false
+ }
+
SplitView {
anchors.fill: parent
diff --git a/kde/gui/ollie9pclient.cpp b/kde/gui/ollie9pclient.cpp
index 6558347..7fd589a 100644
--- a/kde/gui/ollie9pclient.cpp
+++ b/kde/gui/ollie9pclient.cpp
@@ -971,6 +971,75 @@ void Ollie9pClient::openDirectory(const QString &path)
QDesktopServices::openUrl(QUrl::fromLocalFile(path));
}
+// --- Peer management ---
+
+QStringList Ollie9pClient::listPeers(const QString &sessionId, const QString &agentId)
+{
+ QStringList result;
+ if (sessionId.isEmpty() || agentId.isEmpty()) return result;
+ if (!m_9p || !m_9p->isConnected()) return result;
+
+ QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
+ QByteArray out = m_9p->rdwr(path, "peers");
+ QString raw = QString::fromUtf8(out).trimmed();
+
+ if (raw.isEmpty() || raw == "(no peers)") return result;
+
+ for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
+ result.append(line.trimmed());
+ }
+ return result;
+}
+
+bool Ollie9pClient::addPeer(const QString &sessionId, const QString &agentId, const QString &peerName)
+{
+ if (sessionId.isEmpty() || agentId.isEmpty() || peerName.isEmpty()) return false;
+ if (!m_9p || !m_9p->isConnected()) return false;
+
+ QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
+ QString data = "peeradd " + peerName;
+ QByteArray out = m_9p->rdwr(path, data.toUtf8());
+ return out.trimmed() == "ok";
+}
+
+bool Ollie9pClient::removePeer(const QString &sessionId, const QString &agentId, const QString &peerName)
+{
+ if (sessionId.isEmpty() || agentId.isEmpty() || peerName.isEmpty()) return false;
+ if (!m_9p || !m_9p->isConnected()) return false;
+
+ QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
+ QString data = "peerdel " + peerName;
+ QByteArray out = m_9p->rdwr(path, data.toUtf8());
+ return out.trimmed() == "ok";
+}
+
+QVariantList Ollie9pClient::listAgentsInSession(const QString &sessionId)
+{
+ QVariantList result;
+ if (sessionId.isEmpty()) return result;
+ if (!m_9p || !m_9p->isConnected()) return result;
+
+ QString path = "session/" + sessionId + "/agent/idx";
+ QByteArray out = m_9p->read(path);
+ QString raw = QString::fromUtf8(out);
+
+ if (raw.isEmpty()) return result;
+
+ for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
+ QStringList parts = line.split('\t');
+ if (parts.size() < 3) continue;
+
+ QVariantMap agent;
+ agent["id"] = parts.value(1);
+ agent["name"] = parts.value(2);
+ agent["parentAgentId"] = parts.value(3);
+ agent["depth"] = parts.value(4).toInt();
+ agent["state"] = parts.value(5);
+ result.append(agent);
+ }
+ return result;
+}
+
// --- Root data loading (lazy) ---
void Ollie9pClient::ensureRootDataLoaded()
diff --git a/kde/gui/ollie9pclient.h b/kde/gui/ollie9pclient.h
index 88021cb..31151bd 100644
--- a/kde/gui/ollie9pclient.h
+++ b/kde/gui/ollie9pclient.h
@@ -102,6 +102,12 @@ public:
Q_INVOKABLE QString procOutput(const QString &sessionId, const QString &agentId, int pid);
Q_INVOKABLE void openDirectory(const QString &path);
Q_INVOKABLE QString lastError() const { return m_9p ? m_9p->lastError() : QString(); }
+
+ // Peer management
+ Q_INVOKABLE QStringList listPeers(const QString &sessionId, const QString &agentId);
+ Q_INVOKABLE bool addPeer(const QString &sessionId, const QString &agentId, const QString &peerName);
+ Q_INVOKABLE bool removePeer(const QString &sessionId, const QString &agentId, const QString &peerName);
+ Q_INVOKABLE QVariantList listAgentsInSession(const QString &sessionId);
signals:
void sessionsChanged();
diff --git a/kde/gui/qml.qrc b/kde/gui/qml.qrc
index 804498d..812a62e 100644
--- a/kde/gui/qml.qrc
+++ b/kde/gui/qml.qrc
@@ -6,6 +6,7 @@
NewAgentDialog.qml
GoalDialog.qml
ProcManagerWindow.qml
+ PeerManagerWindow.qml
ChatBlockDelegate.qml
SessionTree.qml
ChatPane.qml