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