gui: add Peers button and peer management window

Canvas-based graph visualization of agents in current session:
- Nodes represent agents, colored by state
- Edges represent peer connections
- Drag nodes to reposition
- Click two nodes to toggle connection
- Double-click to start connecting
- Right-click for context menu
- Pan with middle mouse, zoom with scroll wheel
- Auto-layout arranges nodes in circle

9P API methods: listPeers, addPeer, removePeer, listAgentsInSession
This commit is contained in:
Levi Neely 2026-10-09 12:19:42 +02:00
parent 05dd33c1cf
commit 56f6d3c655
6 changed files with 583 additions and 0 deletions

View File

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

View File

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

View File

@ -197,6 +197,12 @@ ApplicationWindow {
visible: false
}
// --- Peer Manager Window ---
PeerManagerWindow {
id: peerManagerWindow
visible: false
}
SplitView {
anchors.fill: parent

View File

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

View File

@ -103,6 +103,12 @@ public:
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();
void activeSessionIdChanged();

View File

@ -6,6 +6,7 @@
<file>NewAgentDialog.qml</file>
<file>GoalDialog.qml</file>
<file>ProcManagerWindow.qml</file>
<file>PeerManagerWindow.qml</file>
<file>ChatBlockDelegate.qml</file>
<file>SessionTree.qml</file>
<file>ChatPane.qml</file>