import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import QtQuick.Window 2.15 import "emojis.js" as Emojis ApplicationWindow { id: root title: dbus.activeCwd ? "Ollie \u2014 " + dbus.activeCwd : "Ollie" width: 900 height: 600 visible: true Component.onCompleted: activator.windowOpened() Component.onDestruction: { activator.windowClosed() if (activator.windowCount === 0 && !(theme.closeToTray && systemTrayAvailable)) { Qt.quit() } } onClosing: { if (theme.closeToTray && systemTrayAvailable) { close.accepted = false root.hide() } } color: palette.window font.family: theme.fontFamily || Qt.application.font.family font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize palette { window: theme.windowColor windowText: theme.windowTextColor base: theme.baseColor alternateBase: theme.alternateBaseColor text: theme.textColor button: theme.buttonColor buttonText: theme.buttonTextColor highlight: theme.highlightColor highlightedText: theme.highlightedTextColor toolTipBase: theme.tooltipBaseColor toolTipText: theme.tooltipTextColor link: theme.linkColor mid: theme.midColor dark: theme.darkColor light: theme.lightColor shadow: theme.shadowColor } // --- Settings Dialog --- Dialog { id: settingsDialog title: "Settings" anchors.centerIn: parent width: 380 modal: true standardButtons: Dialog.Close contentItem: ColumnLayout { spacing: 16 Layout.margins: 16 Label { text: "Color Scheme" font.bold: true } ComboBox { id: schemeCombo Layout.fillWidth: true model: ["System"].concat(theme.availableSchemes) currentIndex: { if (theme.mode === "system") return 0 var idx = theme.availableSchemes.indexOf(theme.mode) return idx >= 0 ? idx + 1 : 0 } onActivated: { if (currentIndex === 0) { theme.mode = "system" } else { theme.mode = theme.availableSchemes[currentIndex - 1] } } } Label { text: theme.isDark ? "Current: dark" : "Current: light" opacity: 0.5 font.pixelSize: Qt.application.font.pixelSize * 0.85 } // --- Font section --- Rectangle { Layout.fillWidth: true height: 1 color: palette.mid Layout.topMargin: 8 } Label { text: "Font" font.bold: true } GridLayout { columns: 2 columnSpacing: 12 rowSpacing: 8 Layout.fillWidth: true Label { text: "Family:" } ComboBox { id: fontFamilyCombo Layout.fillWidth: true model: theme.availableFamilies editable: true currentIndex: { var fam = theme.fontFamily || Qt.application.font.family var idx = model.indexOf(fam) return idx >= 0 ? idx : 0 } onActivated: theme.fontFamily = currentText onAccepted: theme.fontFamily = editText } Label { text: "Style:" } ComboBox { id: fontStyleCombo Layout.fillWidth: true model: theme.availableStyles currentIndex: { var st = theme.fontStyle var idx = model.indexOf(st) return idx >= 0 ? idx : 0 } onActivated: theme.fontStyle = currentText } Label { text: "Size:" } SpinBox { id: fontSizeSpin from: 6 to: 72 value: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize onValueModified: theme.fontSize = value } } Button { text: "Reset to system font" onClicked: theme.resetFont() } // --- Close to tray section --- Rectangle { Layout.fillWidth: true height: 1 color: palette.mid Layout.topMargin: 8 } Label { text: "System Tray" font.bold: true } CheckBox { text: "Close to tray instead of quitting" checked: theme.closeToTray onToggled: theme.closeToTray = checked enabled: systemTrayAvailable } Label { text: systemTrayAvailable ? "Window will hide to the system tray when closed." : "System tray is not available." opacity: 0.5 font.pixelSize: Qt.application.font.pixelSize * 0.85 wrapMode: Text.Wrap Layout.fillWidth: true visible: theme.closeToTray || !systemTrayAvailable } } } function openNewSessionDialog() { nameInput.text = "" newSessionDialog.backendModel = dbus.listBackends() newSessionDialog.agentModel = dbus.listAgents() newSessionDialog.cwdField.text = dbus.activeCwd || "" newSessionDialog.backendCombo.currentIndex = 0 newSessionDialog.agentCombo.currentIndex = 0 newSessionDialog.modelCombo.editText = "" newSessionDialog.open() } Dialog { id: newSessionDialog title: "New Session" modal: true anchors.centerIn: parent width: 450 standardButtons: Dialog.Ok | Dialog.Cancel property alias cwdField: cwdInput property alias backendCombo: backendSelect property alias agentCombo: agentSelect property alias modelCombo: modelSelect property var backendModel: [] property var agentModel: [] onAccepted: { var cwd = cwdInput.text.trim() if (cwd === "") cwd = dbus.activeCwd || "" var backend = backendSelect.currentText var model = modelSelect.editText.trim() || modelSelect.currentText var agent = agentSelect.currentText var name = nameInput.text.trim() dbus.createSession(cwd, backend, model, name, agent) } GridLayout { columns: 2 width: parent.width columnSpacing: 10 rowSpacing: 8 Label { text: "Name:" } TextField { id: nameInput Layout.fillWidth: true placeholderText: "(auto-generated)" } Label { text: "Working directory:" } RowLayout { Layout.fillWidth: true TextField { id: cwdInput Layout.fillWidth: true placeholderText: "/home/user/project" } Button { text: "Browse" onClicked: { var path = theme.chooseFolder("Choose working directory", cwdInput.text) if (path !== "") cwdInput.text = path } } } Label { text: "Backend:" } ComboBox { id: backendSelect Layout.fillWidth: true model: newSessionDialog.backendModel onCurrentTextChanged: { if (currentText) modelSelect.model = dbus.listModelsByBackend(currentText) } } Label { text: "Agent:" } ComboBox { id: agentSelect Layout.fillWidth: true model: newSessionDialog.agentModel currentIndex: 0 } Label { text: "Model:" } ComboBox { id: modelSelect Layout.fillWidth: true editable: true editText: "" } } } function openCwdDialog() { var path = theme.chooseFolder("Change working directory", dbus.activeCwd || "") if (path !== "") dbus.setConfig(dbus.activeSessionId, "cwd", path) } // --- Session Graph Window --- Window { id: graphWindow title: "Ollie \u2014 Session Topology" width: 700 height: 500 visible: false color: palette.window Item { id: graphRoot anchors.fill: parent clip: true property var nodePositions: ({}) property real panX: 0 property real panY: 0 property real zoom: 1.0 property real minZoom: 0.3 property real maxZoom: 3.0 function stateColor(state) { if (state === "idle") return "#4caf50" if (state && state.startsWith("calling")) return "#ff9800" if (state === "thinking") return "#2196f3" return "#2196f3" } function layoutNodes() { var positions = {} var count = dbus.sessions.length if (count === 0) { nodePositions = positions; graphCanvas.requestPaint(); return } var cx = width / 2 var cy = height / 2 var radius = Math.min(width, height) * 0.35 if (count === 1) { positions[dbus.sessions[0].id] = {x: cx, y: cy} } else { for (var i = 0; i < count; i++) { var angle = (2 * Math.PI * i / count) - Math.PI / 2 positions[dbus.sessions[i].id] = { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) } } } nodePositions = positions graphCanvas.requestPaint() } Connections { target: dbus function onSessionsChanged() { graphRoot.layoutNodes() } } onWidthChanged: layoutNodes() onHeightChanged: layoutNodes() Component.onCompleted: layoutNodes() MouseArea { anchors.fill: parent property real lastX: 0 property real lastY: 0 onPressed: { lastX = mouse.x; lastY = mouse.y } onPositionChanged: { if (pressed) { graphRoot.panX += (mouse.x - lastX) graphRoot.panY += (mouse.y - lastY) lastX = mouse.x lastY = mouse.y } } onWheel: { var factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87 var newZoom = Math.max(graphRoot.minZoom, Math.min(graphRoot.maxZoom, graphRoot.zoom * factor)) // Zoom toward mouse position var mx = wheel.x - graphRoot.panX var my = wheel.y - graphRoot.panY graphRoot.panX -= mx * (newZoom / graphRoot.zoom - 1) graphRoot.panY -= my * (newZoom / graphRoot.zoom - 1) graphRoot.zoom = newZoom } } Item { x: graphRoot.panX y: graphRoot.panY width: parent.width height: parent.height scale: graphRoot.zoom transformOrigin: Item.TopLeft Canvas { id: graphCanvas anchors.fill: parent onPaint: { var ctx = getContext("2d") ctx.clearRect(0, 0, width, height) ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.4) ctx.lineWidth = 2 for (var i = 0; i < dbus.sessions.length; i++) { var sid = dbus.sessions[i].id var peers = dbus.peerList(sid) var fromPos = graphRoot.nodePositions[sid] if (!fromPos) continue for (var j = 0; j < peers.length; j++) { var toPos = graphRoot.nodePositions[peers[j]] if (!toPos) continue ctx.beginPath() ctx.moveTo(fromPos.x, fromPos.y) ctx.lineTo(toPos.x, toPos.y) ctx.stroke() var dx = toPos.x - fromPos.x var dy = toPos.y - fromPos.y var len = Math.sqrt(dx * dx + dy * dy) if (len < 1) continue var ux = dx / len var uy = dy / len var ax = toPos.x - ux * 30 var ay = toPos.y - uy * 30 var arrowSize = 8 ctx.beginPath() ctx.moveTo(ax, ay) ctx.lineTo(ax - arrowSize * ux + arrowSize * 0.5 * uy, ay - arrowSize * uy - arrowSize * 0.5 * ux) ctx.lineTo(ax - arrowSize * ux - arrowSize * 0.5 * uy, ay - arrowSize * uy + arrowSize * 0.5 * ux) ctx.closePath() ctx.fillStyle = Qt.rgba(0.5, 0.5, 0.5, 0.6) ctx.fill() } } } } Repeater { model: dbus.sessions Rectangle { id: graphNode width: 120 height: 50 radius: 8 color: palette.base border.color: graphRoot.stateColor(modelData.state) border.width: modelData.id === dbus.activeSessionId ? 3 : 2 x: { var pos = graphRoot.nodePositions[modelData.id] return pos ? pos.x - width / 2 : 0 } y: { var pos = graphRoot.nodePositions[modelData.id] return pos ? pos.y - height / 2 : 0 } SequentialAnimation on opacity { running: modelData.state !== "idle" loops: Animation.Infinite NumberAnimation { to: 0.7; duration: 600; easing.type: Easing.InOutSine } NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutSine } } Column { anchors.centerIn: parent spacing: 2 Label { text: modelData.id font.bold: true font.pixelSize: 11 anchors.horizontalCenter: parent.horizontalCenter elide: Text.ElideRight width: graphNode.width - 12 horizontalAlignment: Text.AlignHCenter } Label { text: modelData.state || "idle" font.pixelSize: 9 opacity: 0.7 anchors.horizontalCenter: parent.horizontalCenter color: graphRoot.stateColor(modelData.state) } } MouseArea { anchors.fill: parent onClicked: dbus.activeSessionId = modelData.id cursorShape: Qt.PointingHandCursor } } } } Label { anchors.centerIn: parent text: "No active sessions" opacity: 0.5 visible: dbus.sessions.length === 0 } } } SplitView { anchors.fill: parent orientation: Qt.Horizontal // --- Session sidebar --- Pane { SplitView.preferredWidth: 220 SplitView.minimumWidth: 160 padding: 0 ColumnLayout { anchors.fill: parent spacing: 0 // Toolbar RowLayout { Layout.fillWidth: true Layout.margins: 6 Label { text: "Sessions" font.bold: true Layout.fillWidth: true } ToolButton { icon.name: "list-add" onClicked: root.openNewSessionDialog() ToolTip.text: "New session" ToolTip.visible: hovered } ToolButton { icon.name: "configure" onClicked: settingsDialog.open() ToolTip.text: "Settings" ToolTip.visible: hovered } ToolButton { icon.name: "network-workgroup" onClicked: graphWindow.visible = !graphWindow.visible ToolTip.text: "Session topology" ToolTip.visible: hovered } } // Session list ListView { id: sessionList Layout.fillWidth: true Layout.fillHeight: true clip: true model: dbus.sessions delegate: ItemDelegate { id: sessionDelegate width: sessionList.width highlighted: modelData.id === dbus.activeSessionId property bool renaming: false property string sessionState: modelData.state Connections { target: dbus function onSessionStateUpdated(sessionId, newState) { if (sessionId === modelData.id) sessionDelegate.sessionState = newState } } background: Rectangle { color: sessionDelegate.highlighted ? palette.highlight : "transparent" radius: 4 } contentItem: RowLayout { spacing: 6 Rectangle { width: 8 height: 8 radius: 4 color: { var state = sessionDelegate.sessionState if (state === "idle") return "#4caf50" if (state.startsWith("calling")) return "#ff9800" return "#2196f3" } } Label { id: sessionLabel text: modelData.id Layout.fillWidth: true elide: Text.ElideRight font.family: "monospace" color: sessionDelegate.highlighted ? palette.highlightedText : palette.text visible: !sessionDelegate.renaming } TextField { id: renameField Layout.fillWidth: true font.family: "monospace" visible: sessionDelegate.renaming onAccepted: { var newName = text.trim() if (newName !== "" && newName !== modelData.id) { dbus.renameSession(modelData.id, newName) } sessionDelegate.renaming = false } Keys.onEscapePressed: sessionDelegate.renaming = false onVisibleChanged: { if (visible) { text = modelData.id selectAll() forceActiveFocus() } } onActiveFocusChanged: { if (!activeFocus && sessionDelegate.renaming) { sessionDelegate.renaming = false } } } Label { text: modelData.agent opacity: 0.6 font.pixelSize: Qt.application.font.pixelSize * 0.85 color: sessionDelegate.highlighted ? palette.highlightedText : palette.text visible: !sessionDelegate.renaming } ToolButton { icon.name: "process-stop" implicitWidth: 24 implicitHeight: 24 visible: !sessionDelegate.renaming onClicked: dbus.killSession(modelData.id) ToolTip.text: "Kill session" ToolTip.visible: hovered } } onClicked: dbus.activeSessionId = modelData.id onDoubleClicked: sessionDelegate.renaming = true } } } } // --- Main content: tiling pane area --- Pane { id: mainContent SplitView.fillWidth: true padding: 0 property int focusedPaneIndex: 0 property var panes: [] property int paneCount: 0 // BSP tree: each node is {pane: item} or {split: 'h'|'v', left: node, right: node} property var bspTree: null property real gap: 2 function layoutTree(node, x, y, w, h) { if (!node) return if (node.pane) { node.pane.x = x node.pane.y = y node.pane.width = w node.pane.height = h } else if (node.split) { if (node.split === 'h') { var lw = (w - gap) / 2 layoutTree(node.left, x, y, lw, h) layoutTree(node.right, x + lw + gap, y, w - lw - gap, h) } else { var th = (h - gap) / 2 layoutTree(node.left, x, y, w, th) layoutTree(node.right, x, y + th + gap, w, h - th - gap) } } } function relayout() { if (bspTree) layoutTree(bspTree, 0, 0, width, height) } function splitNode(tree, targetPane, newPane, direction) { if (!tree) return null if (tree.pane === targetPane) { return {split: direction, left: {pane: targetPane}, right: {pane: newPane}} } if (tree.split) { var newLeft = splitNode(tree.left, targetPane, newPane, direction) if (newLeft) { tree.left = newLeft; return tree } var newRight = splitNode(tree.right, targetPane, newPane, direction) if (newRight) { tree.right = newRight; return tree } } return null } function removeNode(tree, targetPane) { if (!tree) return null if (tree.pane === targetPane) return null if (tree.split) { if (tree.left && tree.left.pane === targetPane) return tree.right if (tree.right && tree.right.pane === targetPane) return tree.left var newLeft = removeNode(tree.left, targetPane) if (newLeft !== tree.left) { tree.left = newLeft; return tree } var newRight = removeNode(tree.right, targetPane) if (newRight !== tree.right) { tree.right = newRight; return tree } } return tree } onWidthChanged: relayout() onHeightChanged: relayout() onPaneCountChanged: relayout() // Container for panes (absolute positioning) Item { id: paneContainer anchors.fill: parent } Component.onCompleted: { var pane = paneCmp.createObject(paneContainer, {sessionId: dbus.activeSessionId}) panes = [pane] paneCount = 1 bspTree = {pane: pane} relayout() } } } // --- Window shortcuts --- Shortcut { sequence: "Ctrl+Q" onActivated: Qt.quit() } Shortcut { sequence: "Ctrl+N" onActivated: activator.NewWindow() } // --- Split pane shortcuts --- Shortcut { sequence: "Ctrl+\\" onActivated: { var focusedPane = mainContent.panes[mainContent.focusedPaneIndex] if (!focusedPane) return var pane = paneCmp.createObject(paneContainer, { sessionId: dbus.activeSessionId, isFocused: false }) if (!pane) return var dir = focusedPane.width >= focusedPane.height ? 'h' : 'v' mainContent.bspTree = mainContent.splitNode(mainContent.bspTree, focusedPane, pane, dir) var panes = mainContent.panes var insertAt = mainContent.focusedPaneIndex + 1 panes.splice(insertAt, 0, pane) mainContent.panes = panes mainContent.paneCount = panes.length mainContent.focusedPaneIndex = insertAt mainContent.relayout() } } Shortcut { sequence: "Ctrl+W" onActivated: { if (mainContent.panes.length <= 1) return var idx = mainContent.focusedPaneIndex if (idx < 0 || idx >= mainContent.panes.length) idx = mainContent.panes.length - 1 var pane = mainContent.panes[idx] mainContent.bspTree = mainContent.removeNode(mainContent.bspTree, pane) var panes = mainContent.panes panes.splice(idx, 1) mainContent.panes = panes mainContent.paneCount = panes.length pane.destroy() mainContent.focusedPaneIndex = Math.min(idx, panes.length - 1) mainContent.relayout() } } Shortcut { sequence: "Ctrl+F" onActivated: { var pane = mainContent.panes[mainContent.focusedPaneIndex] if (!pane) return var fb = pane.children[0] // ColumnLayout // Toggle via the filterBar id inside the pane // Since filterBar is inside the Component, access via pane property pane.toggleFilter() } } // Handle pane swap: find target pane at drop coordinates function handlePaneSwap(sourcePane, rootX, rootY) { for (var i = 0; i < mainContent.panes.length; i++) { var p = mainContent.panes[i] if (p === sourcePane) continue var localPt = p.mapFromItem(null, rootX, rootY) if (localPt.x >= 0 && localPt.x <= p.width && localPt.y >= 0 && localPt.y <= p.height) { var tmp = sourcePane.sessionId sourcePane.sessionId = p.sessionId p.sessionId = tmp return } } } // Sidebar click updates focused pane's session Connections { target: dbus function onActiveSessionIdChanged() { var idx = mainContent.focusedPaneIndex if (idx >= 0 && idx < mainContent.panes.length) { mainContent.panes[idx].sessionId = dbus.activeSessionId } } function onErrorOccurred(message) { errorDialog.text = message errorDialog.open() } } Dialog { id: errorDialog title: "Error" modal: true anchors.centerIn: parent standardButtons: Dialog.Ok property alias text: errorLabel.text Label { id: errorLabel wrapMode: Text.Wrap width: parent.width } } // --- Inline ChatPane Component --- Component { id: paneCmp Item { id: paneRoot property string sessionId: "" property bool isFocused: false signal startedDrag() signal endedDrag(real globalX, real globalY) // Pane-local chat model property var paneModel: chatModel // fallback; replaced on creation property string paneState: "idle" property string paneCwd: "" property int chatOffset: 0 // Per-session draft text storage property var _drafts: ({}) property string _prevSessionId: "" Component.onCompleted: { // Create a dedicated ChatBlockModel for this pane paneModel = Qt.createQmlObject( 'import org.ollie.gui 1.0; ChatBlockModel {}', paneRoot, "paneModel") if (sessionId !== "") refreshPaneChat() } function toggleFilter() { filterBar.visible = !filterBar.visible if (filterBar.visible) { filterInput.forceActiveFocus() filterInput.selectAll() } else { paneModel.filterText = "" filterInput.text = "" } } function refreshPaneChat() { paneModel.rawText = "" chatOffset = 0 paneState = "idle" paneCwd = "" if (sessionId !== "") { var chat = dbus.getChat(sessionId, 0) if (chat) { paneModel.rawText = chat.text chatOffset = chat.offset paneModel.setReactions(dbus.getReactions(sessionId)) } var cfg = dbus.getConfig(sessionId) if (cfg && cfg.cwd) paneCwd = cfg.cwd var st = dbus.getState(sessionId) if (st) paneState = st } } onSessionIdChanged: { // Save draft from previous session if (_prevSessionId !== "") { _drafts[_prevSessionId] = panePrompt.text } refreshPaneChat() // Restore draft for new session (or empty) if (sessionId !== "") { panePrompt.text = _drafts[sessionId] || "" } else { panePrompt.text = "" } _prevSessionId = sessionId } Connections { target: dbus function onChatAppended(sid, offset, text) { if (sid !== paneRoot.sessionId) return if (offset === paneRoot.chatOffset) { paneRoot.paneModel.rawText = paneRoot.paneModel.rawText + text paneRoot.chatOffset += text.length paneRoot.paneModel.setReactions(dbus.getReactions(paneRoot.sessionId)) } else { var chat = dbus.getChat(paneRoot.sessionId, 0) if (chat) { paneRoot.paneModel.rawText = chat.text paneRoot.chatOffset = chat.offset paneRoot.paneModel.setReactions(dbus.getReactions(paneRoot.sessionId)) } } } function onSessionStateUpdated(sid, newState) { if (sid === paneRoot.sessionId) paneRoot.paneState = newState } } // Visual focus indicator Rectangle { anchors.fill: parent color: "transparent" border.color: paneRoot.isFocused ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.4) : "transparent" border.width: 1 z: 100 } ColumnLayout { anchors.fill: parent anchors.margins: 2 spacing: 0 // Header (drag handle) Rectangle { id: paneHeader Layout.fillWidth: true implicitHeight: headerRow.implicitHeight + 12 color: "transparent" MouseArea { id: paneHeaderDrag anchors.fill: parent property bool dragging: false cursorShape: dragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor onPositionChanged: { if (pressed && !dragging) { dragging = true paneRoot.startedDrag() } } onReleased: { if (dragging) { var rootPt = paneHeaderDrag.mapToItem(null, mouse.x, mouse.y) paneRoot.endedDrag(rootPt.x, rootPt.y) } dragging = false } } RowLayout { id: headerRow anchors.fill: parent anchors.margins: 6 Rectangle { width: 8; height: 8; radius: 4 color: { if (paneRoot.paneState === "idle") return "#4caf50" if (paneRoot.paneState.startsWith("calling")) return "#ff9800" return "#2196f3" } } Label { text: paneRoot.paneCwd || paneRoot.sessionId || "No session" Layout.fillWidth: true elide: Text.ElideMiddle font.bold: true font.pixelSize: Qt.application.font.pixelSize * 0.9 } Label { text: paneRoot.paneState opacity: 0.6 font.pixelSize: Qt.application.font.pixelSize * 0.8 } } } Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } // Filter bar (Ctrl+F) RowLayout { id: filterBar Layout.fillWidth: true Layout.margins: 4 spacing: 4 visible: false TextField { id: filterInput Layout.fillWidth: true placeholderText: "Filter messages..." onTextChanged: paneRoot.paneModel.filterText = text Keys.onEscapePressed: { filterBar.visible = false paneRoot.paneModel.filterText = "" text = "" } } Label { text: paneRoot.paneModel.filtering ? paneRoot.paneModel.rowCount() + " matches" : "" opacity: 0.6 font.pixelSize: Qt.application.font.pixelSize * 0.85 } ToolButton { icon.name: "window-close" onClicked: { filterBar.visible = false paneRoot.paneModel.filterText = "" filterInput.text = "" } } } // Chat view Item { Layout.fillWidth: true Layout.fillHeight: true property bool atBottom: true Flickable { id: paneFlick anchors.fill: parent anchors.margins: 4 clip: true contentWidth: width contentHeight: paneColumn.implicitHeight boundsBehavior: Flickable.StopAtBounds property bool _settingY: false onContentYChanged: { if (_settingY) return var bottom = contentHeight - height parent.atBottom = (bottom <= 0 || contentY >= bottom - 4) // Lazy-load earlier blocks when near top if (contentY < 80 && contentY >= 0 && !_settingY) { var oldHeight = contentHeight if (paneRoot.paneModel.loadEarlier()) { _settingY = true contentY += (contentHeight - oldHeight) _settingY = false } } } onContentHeightChanged: { if (parent.atBottom) { _settingY = true contentY = Math.max(0, contentHeight - height) _settingY = false } } ScrollBar.vertical: ScrollBar { id: chatScrollBar policy: ScrollBar.AsNeeded contentItem: Rectangle { implicitWidth: 6 radius: 3 color: chatScrollBar.pressed ? palette.highlight : chatScrollBar.hovered ? palette.mid : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, chatScrollBar.active ? 0.6 : 0.0) Behavior on color { ColorAnimation { duration: 150 } } } } MouseArea { anchors.fill: parent acceptedButtons: Qt.NoButton onWheel: { paneFlick._settingY = true paneFlick.contentY = Math.max(0, Math.min(paneFlick.contentY - wheel.angleDelta.y, paneFlick.contentHeight - paneFlick.height)) paneFlick._settingY = false var bottom = paneFlick.contentHeight - paneFlick.height paneFlick.parent.atBottom = (bottom <= 0 || paneFlick.contentY >= bottom - 4) } } Column { id: paneColumn width: paneFlick.width - 12 // leave room for scrollbar spacing: 8 Label { visible: paneChatRepeater.count === 0 text: paneRoot.sessionId ? "(waiting...)" : "No session" color: theme.placeholderTextColor } Repeater { id: paneChatRepeater model: paneRoot.paneModel delegate: Loader { width: paneColumn.width property var block: model property string blockReaction: model.reaction || "" sourceComponent: { switch (blockType) { case "user": return userDelegate case "assistant": return assistantDelegate case "reasoning": return reasoningDelegate case "call": return callDelegate case "tool": return toolDelegate case "error": return errorDelegate case "info": return infoDelegate default: return assistantDelegate } } } } } } ToolButton { anchors.right: parent.right anchors.bottom: parent.bottom anchors.margins: 6 icon.name: "go-down" visible: !parent.atBottom onClicked: { paneFlick.contentY = Math.max(0, paneFlick.contentHeight - paneFlick.height) parent.atBottom = true } ToolTip.text: "Scroll to bottom" ToolTip.visible: hovered } } Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } // Prompt input RowLayout { Layout.fillWidth: true Layout.margins: 6 spacing: 4 ScrollView { Layout.fillWidth: true Layout.maximumHeight: 100 TextArea { id: panePrompt placeholderText: paneRoot.sessionId ? "Message..." : "No session" enabled: paneRoot.sessionId !== "" wrapMode: TextEdit.Wrap property var quickEmojis: ({ ";)": "\uD83D\uDE09", ":)": "\uD83D\uDE42", ":D": "\uD83D\uDE03", ":(": "\uD83D\uDE1E", ":P": "\uD83D\uDE1B", ":p": "\uD83D\uDE1B", "<3": "\u2764\uFE0F", ":O": "\uD83D\uDE2E", ":o": "\uD83D\uDE2E", "B)": "\uD83D\uDE0E", ":*": "\uD83D\uDE18", "XD": "\uD83D\uDE06", "xD": "\uD83D\uDE06", ":|": "\uD83D\uDE10", ">:(": "\uD83D\uDE20", ":'(": "\uD83D\uDE22" }) property bool _replacing: false function emojiQuery() { var pos = cursorPosition var t = text for (var i = pos - 1; i >= Math.max(0, pos - 20); i--) { if (t[i] === ':') { var query = t.substring(i + 1, pos) if (/^[a-zA-Z0-9_]+$/.test(query) && query.length >= 2) return {start: i, query: query} return null } if (!/[a-zA-Z0-9_]/.test(t[i])) return null } return null } function applyEmoji(emoji, code) { var q = emojiQuery() if (!q) return _replacing = true var before = text.substring(0, q.start) var after = text.substring(cursorPosition) text = before + emoji + after cursorPosition = before.length + emoji.length _replacing = false emojiPopup.visible = false } onTextChanged: { if (_replacing) return var pos = cursorPosition // Quick emoticons for (var len = 2; len <= Math.min(4, pos); len++) { var candidate = text.substring(pos - len, pos) if (quickEmojis[candidate] !== undefined) { _replacing = true var before = text.substring(0, pos - len) var after = text.substring(pos) var emoji = quickEmojis[candidate] text = before + emoji + after cursorPosition = before.length + emoji.length _replacing = false return } } // :shortcode: completion popup var q = emojiQuery() if (q) { var matches = Emojis.emojiList.filter(function(e) { return e.code.indexOf(q.query) === 0 }).slice(0, 8) if (matches.length > 0) { emojiModel.clear() for (var mi = 0; mi < matches.length; mi++) emojiModel.append({code: matches[mi].code, emoji: matches[mi].emoji}) emojiPopup.visible = true emojiList.currentIndex = 0 } else { emojiPopup.visible = false } } else { emojiPopup.visible = false } } Keys.onReturnPressed: { if (emojiPopup.visible && emojiModel.count > 0) { var item = emojiModel.get(emojiList.currentIndex) applyEmoji(item.emoji, item.code) event.accepted = true return } if (event.modifiers & (Qt.ShiftModifier | Qt.ControlModifier)) { panePrompt.insert(panePrompt.cursorPosition, "\n") } else { if (text.trim() !== "" && paneRoot.sessionId !== "") { dbus.submit(paneRoot.sessionId, text.trim()) text = "" } } event.accepted = true } Keys.onPressed: { if (event.key === Qt.Key_J && (event.modifiers & Qt.ControlModifier)) { panePrompt.insert(panePrompt.cursorPosition, "\n") event.accepted = true } else if (emojiPopup.visible) { if (event.key === Qt.Key_Down) { emojiList.currentIndex = Math.min(emojiList.currentIndex + 1, emojiModel.count - 1) event.accepted = true } else if (event.key === Qt.Key_Up) { emojiList.currentIndex = Math.max(emojiList.currentIndex - 1, 0) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Tab) { if (emojiModel.count > 0) { var eItem = emojiModel.get(emojiList.currentIndex) applyEmoji(eItem.emoji, eItem.code) event.accepted = true } } else if (event.key === Qt.Key_Escape) { emojiPopup.visible = false event.accepted = true } } } } } // Emoji popup ListModel { id: emojiModel } Popup { id: emojiPopup y: -height - 4 x: 8 width: 260 height: Math.min(emojiList.contentHeight + 12, 200) padding: 6 visible: false closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside background: Rectangle { color: palette.base border.color: palette.mid border.width: 1 radius: 6 } ListView { id: emojiList anchors.fill: parent model: emojiModel clip: true currentIndex: 0 delegate: ItemDelegate { width: emojiList.width height: 28 highlighted: index === emojiList.currentIndex contentItem: RowLayout { spacing: 8 Label { text: model.emoji; font.pixelSize: 16 } Label { text: ":" + model.code + ":"; Layout.fillWidth: true; font.family: "monospace"; opacity: 0.8 } } onClicked: panePrompt.applyEmoji(model.emoji, model.code) } } } ToolButton { icon.name: "media-playback-stop" enabled: paneRoot.paneState !== "idle" && paneRoot.sessionId !== "" onClicked: dbus.interrupt(paneRoot.sessionId) ToolTip.text: "Interrupt" ToolTip.visible: hovered } ToolButton { icon.name: "process-stop" visible: paneRoot.paneState.startsWith("calling:") onClicked: dbus.detachProcess(paneRoot.sessionId) ToolTip.text: "Detach process" ToolTip.visible: hovered } // Detached process badge ToolButton { id: detachBadge visible: detachBadge.count > 0 property int count: 0 text: "⚙ " + count font.pixelSize: Qt.application.font.pixelSize * 0.85 onClicked: processManagerWindow.show() ToolTip.text: count + " detached process" + (count !== 1 ? "es" : "") ToolTip.visible: hovered Timer { interval: 2000 running: paneRoot.sessionId !== "" repeat: true triggeredOnStart: true onTriggered: { var procs = dbus.listDetached(paneRoot.sessionId) detachBadge.count = procs.length } } } } // Status Label { Layout.fillWidth: true Layout.leftMargin: 6 Layout.bottomMargin: 2 font.pixelSize: Qt.application.font.pixelSize * 0.75 opacity: 0.4 text: paneRoot.sessionId || "no session" } } // Click-to-focus: update focused pane index on click MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton propagateComposedEvents: true onPressed: { mouse.accepted = false for (var i = 0; i < mainContent.panes.length; i++) { if (mainContent.panes[i] === paneRoot) { mainContent.focusedPaneIndex = i break } } } } // Ctrl+L focuses the prompt input (like browser URL bar) Shortcut { sequence: "Ctrl+L" onActivated: panePrompt.forceActiveFocus() } } } // --- Chat block delegates --- Component { id: userDelegate Item { implicitHeight: userBubble.implicitHeight + userLabel.implicitHeight + 4 Rectangle { id: userBubble anchors.top: parent.top anchors.right: parent.right width: Math.max(160, Math.min(userContent.implicitWidth + 24, parent.width * 0.85)) implicitHeight: userContent.implicitHeight + 16 color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.15) radius: 12 HoverHandler { id: userHover } ToolButton { anchors.top: parent.top anchors.left: parent.left anchors.margins: 4 z: 10 icon.name: "edit-copy" implicitWidth: 32 implicitHeight: 32 opacity: userHover.hovered ? 0.8 : 0 onClicked: chatModel.copyToClipboard(block.blockContent) ToolTip.text: "Copy" ToolTip.visible: hovered Behavior on opacity { NumberAnimation { duration: 150 } } } ColumnLayout { id: userContent anchors.fill: parent anchors.margins: 12 spacing: 4 Repeater { model: block ? block.subBlocks : [] delegate: Loader { Layout.fillWidth: true property var subBlock: modelData sourceComponent: subBlock.type === "code" ? codeSubDelegate : subBlock.type === "diagram" ? diagramSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate : subBlock.type === "video" ? videoSubDelegate : proseSubDelegate } } } } Label { id: userLabel text: "You" font.pixelSize: Qt.application.font.pixelSize * 0.75 opacity: 0.5 anchors.right: parent.right anchors.top: userBubble.bottom anchors.topMargin: 2 } } } Component { id: assistantDelegate Item { implicitHeight: assistantBubble.implicitHeight + assistantLabel.implicitHeight + 4 Rectangle { id: assistantBubble anchors.top: parent.top anchors.left: parent.left width: parent.width * 0.95 implicitHeight: assistantContent.implicitHeight + 16 color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.06) radius: 12 HoverHandler { id: assistantHover } ToolButton { anchors.top: parent.top anchors.right: parent.right anchors.margins: 4 z: 10 icon.name: "edit-copy" implicitWidth: 32 implicitHeight: 32 opacity: assistantHover.hovered ? 0.8 : 0 onClicked: chatModel.copyToClipboard(block.blockContent) ToolTip.text: "Copy" ToolTip.visible: hovered Behavior on opacity { NumberAnimation { duration: 150 } } } // Reaction badge (pinned when reacted) Label { anchors.bottom: parent.bottom anchors.right: parent.right anchors.margins: 4 z: 10 text: blockReaction font.pixelSize: 18 visible: blockReaction !== "" } // Reaction buttons (hover only, hidden when already reacted) Row { anchors.bottom: parent.bottom anchors.right: parent.right anchors.rightMargin: blockReaction !== "" ? 36 : 4 anchors.margins: 4 z: 10 spacing: 2 opacity: (assistantHover.hovered && blockReaction === "") ? 0.9 : 0 Behavior on opacity { NumberAnimation { duration: 150 } } Repeater { model: [ { emoji: "\uD83D\uDC4D", tip: "Good" }, { emoji: "\uD83D\uDC4E", tip: "Bad" }, { emoji: "\uD83D\uDE80", tip: "Excellent" }, { emoji: "\uD83D\uDCA9", tip: "Very bad" }, { emoji: "\uD83E\uDD14", tip: "Confused" } ] ToolButton { text: modelData.emoji font.pixelSize: 14 implicitWidth: 28 implicitHeight: 28 onClicked: { dbus.reactTo(paneRoot.sessionId, block.responseId, modelData.emoji); paneRoot.paneModel.setReactions(dbus.getReactions(paneRoot.sessionId)) } ToolTip.text: modelData.tip ToolTip.visible: hovered } } } ColumnLayout { id: assistantContent anchors.fill: parent anchors.margins: 12 spacing: 4 Repeater { model: block ? block.subBlocks : [] delegate: Loader { Layout.fillWidth: true property var subBlock: modelData sourceComponent: subBlock.type === "code" ? codeSubDelegate : subBlock.type === "diagram" ? diagramSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate : subBlock.type === "video" ? videoSubDelegate : proseSubDelegate } } } } Label { id: assistantLabel text: "Ollie" font.pixelSize: Qt.application.font.pixelSize * 0.75 opacity: 0.5 anchors.left: parent.left anchors.top: assistantBubble.bottom anchors.topMargin: 2 } } } Component { id: reasoningDelegate ColumnLayout { spacing: 2 Label { text: (reasoningText.visible ? "\u25BC" : "\u25B6") + " reasoning" font.pixelSize: Qt.application.font.pixelSize * 0.8 opacity: 0.5 MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: reasoningText.visible = !reasoningText.visible } } Label { id: reasoningText Layout.fillWidth: true text: (block ? block.blockContent : "") wrapMode: Text.Wrap font.family: "monospace" font.pixelSize: Qt.application.font.pixelSize * 0.85 opacity: 0.5 visible: false } } } Component { id: callDelegate Item { implicitHeight: callLayout.implicitHeight + 12 property bool expanded: false HoverHandler { id: callHover } Rectangle { anchors.fill: parent color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.03) Rectangle { width: 3 height: parent.height color: "#ff9800" } } ToolButton { anchors.top: parent.top anchors.right: parent.right anchors.margins: 4 z: 10 icon.name: "edit-copy" implicitWidth: 24 implicitHeight: 24 opacity: callHover.hovered ? 0.8 : 0 onClicked: chatModel.copyToClipboard(block.blockContent) ToolTip.text: "Copy" ToolTip.visible: hovered Behavior on opacity { NumberAnimation { duration: 150 } } } ColumnLayout { id: callLayout x: 10 width: parent.width - 20 y: 6 spacing: 4 Label { text: (expanded ? "\u25BC" : "\u25B6") + " \u26A1 call: " + (block ? (block.blockName || "") : "") font.pixelSize: Qt.application.font.pixelSize * 0.8 color: "#ff9800" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: expanded = !expanded } } Repeater { model: expanded && block ? block.subBlocks : [] delegate: Loader { Layout.fillWidth: true property var subBlock: modelData sourceComponent: subBlock.type === "code" ? codeSubDelegate : subBlock.type === "diagram" ? diagramSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate : subBlock.type === "video" ? videoSubDelegate : plainSubDelegate } } } } } Component { id: toolDelegate Item { implicitHeight: toolLayout.implicitHeight + 12 HoverHandler { id: toolHover } Rectangle { anchors.fill: parent color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.03) Rectangle { width: 3 height: parent.height color: palette.mid } } ToolButton { anchors.top: parent.top anchors.right: parent.right anchors.margins: 4 z: 10 icon.name: "edit-copy" implicitWidth: 24 implicitHeight: 24 opacity: toolHover.hovered ? 0.8 : 0 onClicked: chatModel.copyToClipboard(block.blockContent) ToolTip.text: "Copy" ToolTip.visible: hovered Behavior on opacity { NumberAnimation { duration: 150 } } } ColumnLayout { id: toolLayout x: 10 width: parent.width - 20 y: 6 spacing: 4 Label { text: "\u25C6 tool: " + (block ? (block.blockName || "") : "") font.pixelSize: Qt.application.font.pixelSize * 0.8 opacity: 0.6 } Repeater { model: block ? block.subBlocks : [] delegate: Loader { Layout.fillWidth: true property var subBlock: modelData sourceComponent: subBlock.type === "code" ? codeSubDelegate : subBlock.type === "diagram" ? diagramSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate : subBlock.type === "video" ? videoSubDelegate : plainSubDelegate } } } } } Component { id: errorDelegate Rectangle { implicitHeight: errorLabel.implicitHeight + 12 color: Qt.rgba(1, 0, 0, 0.08) radius: 4 Label { id: errorLabel anchors.fill: parent anchors.margins: 6 text: (block ? block.blockContent : "") wrapMode: Text.Wrap color: "#e53935" font.family: "monospace" } } } Component { id: infoDelegate Label { text: (block ? block.blockContent : "") wrapMode: Text.Wrap font.pixelSize: Qt.application.font.pixelSize * 0.85 opacity: 0.6 font.italic: true } } // --- Sub-block delegates --- Component { id: proseSubDelegate TextEdit { width: parent ? parent.width : 100 text: chatModel.linkifyFilePaths(subBlock.content || "") wrapMode: TextEdit.Wrap textFormat: TextEdit.MarkdownText color: palette.text selectionColor: palette.highlight selectedTextColor: palette.highlightedText readOnly: true selectByMouse: true font.pointSize: root.font.pointSize font.family: root.font.family onLinkActivated: chatModel.openLink(link) } } Component { id: plainSubDelegate TextEdit { width: parent ? parent.width : 100 text: subBlock.content || "" wrapMode: TextEdit.Wrap textFormat: TextEdit.PlainText color: palette.text selectionColor: palette.highlight selectedTextColor: palette.highlightedText readOnly: true selectByMouse: true font.family: "monospace" font.pixelSize: Qt.application.font.pixelSize * 0.9 } } Component { id: interruptSubDelegate Rectangle { width: parent ? parent.width : 200 height: interruptCol.implicitHeight + 16 color: theme.isDark ? "#3d2e00" : "#fff8e1" radius: 4 Rectangle { width: 3 height: parent.height color: "#ffc107" radius: 2 } ColumnLayout { id: interruptCol x: 10 y: 8 width: parent.width - 20 spacing: 4 Label { text: "\u26A0 user interrupt" font.pixelSize: Qt.application.font.pixelSize * 0.8 font.bold: true color: "#ffc107" } Text { Layout.fillWidth: true text: subBlock.content || "" wrapMode: Text.Wrap textFormat: Text.MarkdownText color: palette.text font.pointSize: root.font.pointSize font.family: root.font.family lineHeight: 1.3 } } } } Component { id: videoSubDelegate Item { id: videoItem width: parent ? parent.width : 400 height: Math.max(200, Math.min(width * 9/16, 360)) property string videoUrl: subBlock.content || "" property bool isDirectMedia: /\.(mp4|webm|ogv|m3u8)(\?|$)/i.test(videoUrl) property string youtubeId: { var m = videoUrl.match(/[?&]v=([^&]+)/) || videoUrl.match(/youtu\.be\/([^?]+)/) return m ? m[1] : "" } // Non-direct: thumbnail card that opens in browser Rectangle { anchors.fill: parent color: "#181818" radius: 8 clip: true // YouTube thumbnail Image { anchors.fill: parent source: videoItem.youtubeId !== "" ? "https://img.youtube.com/vi/" + videoItem.youtubeId + "/hqdefault.jpg" : "" fillMode: Image.PreserveAspectCrop visible: videoItem.youtubeId !== "" } // Play button overlay Rectangle { anchors.centerIn: parent width: 72; height: 72 radius: 36 color: "#aa000000" Text { anchors.centerIn: parent text: "\u25B6" font.pixelSize: 36 color: "white" } } // URL label at bottom Rectangle { anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right height: 28 color: "#cc000000" radius: 4 Text { anchors.centerIn: parent width: parent.width - 16 text: videoItem.videoUrl color: "#ccc" font.pixelSize: 11 elide: Text.ElideMiddle horizontalAlignment: Text.AlignHCenter } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Qt.openUrlExternally(videoItem.videoUrl) } } } } Component { id: codeSubDelegate Rectangle { implicitHeight: codeCol.implicitHeight implicitWidth: Math.max(280, codeCol.implicitWidth) color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6) radius: 4 border.width: 1 border.color: palette.mid ColumnLayout { id: codeCol anchors.fill: parent spacing: 0 RowLayout { Layout.fillWidth: true Layout.margins: 4 Label { text: subBlock.language || "code" font.pixelSize: Qt.application.font.pixelSize * 0.8 opacity: 0.6 Layout.fillWidth: true } ToolButton { icon.name: "media-playback-start" implicitWidth: 32 implicitHeight: 32 visible: { var lang = (subBlock.language || "").toLowerCase() return ["bash", "sh", "zsh", "fish", "python", "python3", "py", "perl", "ruby", "rb", "lua", "node", "javascript", "js", "php"].indexOf(lang) >= 0 } onClicked: chatModel.executeInTerminal(subBlock.content, subBlock.language, dbus.activeCwd) ToolTip.text: "Execute in terminal" ToolTip.visible: hovered } ToolButton { icon.name: "edit-copy" implicitWidth: 32 implicitHeight: 32 onClicked: chatModel.copyToClipboard(subBlock.content) ToolTip.text: "Copy" ToolTip.visible: hovered } } TextEdit { id: codeText Layout.fillWidth: true Layout.margins: 8 text: subBlock.content textFormat: TextEdit.PlainText wrapMode: TextEdit.Wrap font.family: "monospace" font.pixelSize: Qt.application.font.pixelSize * 0.9 color: palette.text selectionColor: palette.highlight selectedTextColor: palette.highlightedText readOnly: true selectByMouse: true property bool highlighted: false Timer { interval: 150 running: true onTriggered: { codeText.text = chatModel.highlightedHtml(subBlock.content, subBlock.language) codeText.textFormat = TextEdit.RichText codeText.highlighted = true } } } } } } Component { id: diffSubDelegate Rectangle { implicitHeight: diffCol.implicitHeight color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6) radius: 4 border.width: 1 border.color: palette.mid ColumnLayout { id: diffCol anchors.fill: parent spacing: 0 RowLayout { Layout.fillWidth: true Layout.margins: 4 Label { text: "diff" font.pixelSize: Qt.application.font.pixelSize * 0.8 opacity: 0.6 Layout.fillWidth: true } ToolButton { icon.name: "edit-copy" implicitWidth: 32 implicitHeight: 32 onClicked: chatModel.copyToClipboard(subBlock.content) ToolTip.text: "Copy" ToolTip.visible: hovered } } Column { Layout.fillWidth: true Layout.margins: 8 spacing: 0 Repeater { model: subBlock.content.split("\n") Rectangle { width: parent.width implicitHeight: diffLineText.implicitHeight color: { if (modelData.startsWith("+")) return Qt.rgba(0.3, 0.8, 0.3, 0.08) if (modelData.startsWith("-")) return Qt.rgba(0.8, 0.3, 0.3, 0.08) return "transparent" } Text { id: diffLineText width: parent.width text: modelData font.family: "monospace" font.pixelSize: Qt.application.font.pixelSize * 0.9 wrapMode: Text.Wrap color: { if (modelData.startsWith("+")) return "#4caf50" if (modelData.startsWith("-")) return "#f44336" if (modelData.startsWith("@@")) return "#2196f3" return palette.text } } } } } } } } Component { id: diagramSubDelegate Rectangle { implicitHeight: diagramCol.implicitHeight color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6) radius: 4 border.width: 1 border.color: palette.mid ColumnLayout { id: diagramCol anchors.left: parent.left anchors.right: parent.right spacing: 0 // Rendered diagram image (white background for dark theme readability) Rectangle { Layout.fillWidth: true Layout.margins: 8 Layout.maximumHeight: 600 implicitHeight: diagramImg.implicitHeight > 0 ? Math.min(diagramImg.implicitHeight, 600) : 100 color: "#ffffff" radius: 4 Image { id: diagramImg anchors.fill: parent anchors.margins: 4 fillMode: Image.PreserveAspectFit source: chatModel.diagramUrl(subBlock.content || "", subBlock.language || "") asynchronous: true cache: false BusyIndicator { anchors.centerIn: parent running: diagramImg.status === Image.Loading visible: running } Label { anchors.centerIn: parent text: "\u26A0 Failed to load diagram" visible: diagramImg.status === Image.Error color: "#666" } MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.MiddleButton cursorShape: Qt.PointingHandCursor onClicked: function(mouse) { if (mouse.button === Qt.MiddleButton) { chatModel.invalidateDiagram(subBlock.content || "", subBlock.language || "") diagramImg.source = "" diagramImg.source = chatModel.diagramUrl(subBlock.content || "", subBlock.language || "") } else { diagramFullscreen.diagramSource = diagramImg.source diagramFullscreen.visible = true } } } } } // Collapsible source toggle RowLayout { Layout.fillWidth: true Layout.margins: 4 spacing: 4 Label { id: diagramToggle property bool expanded: false text: (expanded ? "\u25BC" : "\u25B6") + " " + (subBlock.language || "source") font.pixelSize: Qt.application.font.pixelSize * 0.8 opacity: 0.6 MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: diagramToggle.expanded = !diagramToggle.expanded } } Item { Layout.fillWidth: true } ToolButton { icon.name: "edit-copy" implicitWidth: 28 implicitHeight: 28 onClicked: chatModel.copyToClipboard(subBlock.content) ToolTip.text: "Copy source" ToolTip.visible: hovered } } // Collapsed source code TextEdit { Layout.fillWidth: true Layout.margins: 8 visible: diagramToggle.expanded text: subBlock.content || "" wrapMode: TextEdit.Wrap textFormat: TextEdit.PlainText font.family: "monospace" font.pixelSize: Qt.application.font.pixelSize * 0.85 color: palette.text selectionColor: palette.highlight selectedTextColor: palette.highlightedText readOnly: true selectByMouse: true } } } } // Fullscreen diagram viewer (pannable + zoomable) Popup { id: diagramFullscreen property string diagramSource: "" parent: Overlay.overlay anchors.centerIn: parent width: parent ? parent.width * 0.95 : 800 height: parent ? parent.height * 0.95 : 600 modal: true dim: true padding: 0 background: Rectangle { color: "#ffffff" radius: 8 border.color: palette.mid border.width: 1 } Shortcut { sequence: "Escape" enabled: diagramFullscreen.visible onActivated: diagramFullscreen.close() } ToolButton { anchors.top: parent.top anchors.right: parent.right anchors.margins: 8 z: 10 icon.name: "window-close" onClicked: diagramFullscreen.close() } ToolButton { anchors.top: parent.top anchors.left: parent.left anchors.margins: 8 z: 10 text: Math.round(diagramZoomImg.zoomFactor * 100) + "%" font.pixelSize: Qt.application.font.pixelSize * 0.8 onClicked: { diagramZoomImg.zoomFactor = 1.0 diagramFlick.contentX = 0 diagramFlick.contentY = 0 } ToolTip.text: "Reset zoom" ToolTip.visible: hovered } Flickable { id: diagramFlick anchors.fill: parent anchors.margins: 8 clip: true contentWidth: diagramZoomImg.width contentHeight: diagramZoomImg.height boundsBehavior: Flickable.StopAtBounds Image { id: diagramZoomImg source: diagramFullscreen.diagramSource fillMode: Image.PreserveAspectFit cache: false property real zoomFactor: 1.0 width: Math.max(diagramFlick.width, diagramFlick.width * zoomFactor) height: Math.max(diagramFlick.height, diagramFlick.height * zoomFactor) } } // Overlay for wheel zoom and right-click close (outside Flickable to avoid drag interference) MouseArea { anchors.fill: diagramFlick acceptedButtons: Qt.RightButton propagateComposedEvents: true onClicked: function(mouse) { if (mouse.button === Qt.RightButton) diagramFullscreen.close() } onWheel: function(wheel) { var factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87 var newZoom = Math.max(0.25, Math.min(10.0, diagramZoomImg.zoomFactor * factor)) diagramZoomImg.zoomFactor = newZoom } } onAboutToShow: { diagramZoomImg.zoomFactor = 1.0 diagramFlick.contentX = 0 diagramFlick.contentY = 0 } } // --- Process Manager Window --- Window { id: processManagerWindow title: "Ollie \u2014 Detached Processes" width: 600 height: 400 color: palette.window property var processes: [] property int selectedPid: -1 property string selectedOutput: "" Timer { id: procRefreshTimer interval: 1500 running: processManagerWindow.visible repeat: true triggeredOnStart: true onTriggered: { if (dbus.activeSessionId === "") return processManagerWindow.processes = dbus.listDetached(dbus.activeSessionId) if (processManagerWindow.selectedPid > 0) { processManagerWindow.selectedOutput = dbus.getDetachedOutput(dbus.activeSessionId, processManagerWindow.selectedPid) } } } ColumnLayout { anchors.fill: parent anchors.margins: 8 spacing: 8 Label { text: "Session: " + (dbus.activeSessionId || "none") font.bold: true } ListView { id: procList Layout.fillWidth: true Layout.preferredHeight: 140 clip: true model: processManagerWindow.processes delegate: ItemDelegate { width: procList.width highlighted: modelData.pid === processManagerWindow.selectedPid contentItem: RowLayout { spacing: 8 Rectangle { width: 8; height: 8; radius: 4 color: modelData.status === "running" ? "#4caf50" : "#9e9e9e" } Label { text: modelData.command Layout.fillWidth: true elide: Text.ElideRight font.family: "monospace" } Label { text: "pid " + modelData.pid opacity: 0.6 font.pixelSize: Qt.application.font.pixelSize * 0.85 } Label { text: modelData.status opacity: 0.6 font.pixelSize: Qt.application.font.pixelSize * 0.85 } Button { text: "Term" visible: modelData.status === "running" onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "TERM") } Button { text: "Kill" visible: modelData.status === "running" onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "KILL") } Button { text: "Dismiss" visible: modelData.status !== "running" onClicked: dbus.dismissDetached(dbus.activeSessionId, modelData.pid) } } onClicked: { processManagerWindow.selectedPid = modelData.pid processManagerWindow.selectedOutput = dbus.getDetachedOutput(dbus.activeSessionId, modelData.pid) } } } Rectangle { Layout.fillWidth: true height: 1 color: palette.mid } RowLayout { spacing: 8 Label { text: processManagerWindow.selectedPid > 0 ? "\u2014\u2014\u2014 Output: pid " + processManagerWindow.selectedPid + " \u2014\u2014\u2014" : "Select a process to view output" opacity: 0.6 font.pixelSize: Qt.application.font.pixelSize * 0.85 Layout.fillWidth: true } Button { text: "Copy" visible: processManagerWindow.selectedPid > 0 onClicked: { outputArea.selectAll() outputArea.copy() outputArea.deselect() } } } Flickable { id: outputFlick Layout.fillWidth: true Layout.fillHeight: true clip: true contentWidth: width contentHeight: outputArea.implicitHeight boundsBehavior: Flickable.StopAtBounds property bool atBottom: true onContentYChanged: { var bottom = contentHeight - height atBottom = (bottom <= 0 || contentY >= bottom - 4) } onContentHeightChanged: { if (atBottom) { contentY = Math.max(0, contentHeight - height) } } TextArea { id: outputArea width: outputFlick.width readOnly: true font.family: "monospace" font.pixelSize: Qt.application.font.pixelSize * 0.9 text: processManagerWindow.selectedOutput wrapMode: TextEdit.Wrap background: null } } } } }