import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtMultimedia import org.ollie.gui 1.0 import "emojis.js" as Emojis // Self-contained chat pane with own session binding Item { id: paneRoot property string sessionId: "" property bool isFocused: false signal startedDrag() signal endedDrag(real globalX, real globalY) // Pane-local chat model ChatBlockModel { id: paneModel } // Track chat state for this pane's session property string paneState: "idle" property string paneCwd: "" property int chatOffset: 0 // Per-session draft text storage property var _drafts: ({}) property string _prevSessionId: "" // Fetch chat when session changes onSessionIdChanged: { // Save draft from previous session if (_prevSessionId !== "") { _drafts[_prevSessionId] = panePrompt.text } paneModel.rawText = "" chatOffset = 0 paneState = "idle" paneCwd = "" if (sessionId !== "") { // Restore draft for new session (or empty) panePrompt.text = _drafts[sessionId] || "" // Fetch initial chat and config let chat = dbus.getChat(sessionId, 0) if (chat) { paneModel.rawText = chat.text chatOffset = chat.offset paneModel.setReactions(dbus.getReactions(sessionId)) } let cfg = dbus.getConfig(sessionId) if (cfg && cfg.cwd) paneCwd = cfg.cwd let st = dbus.getState(sessionId) if (st) paneState = st } else { panePrompt.text = "" } _prevSessionId = sessionId } // Listen for D-Bus signals filtered to our session Connections { target: dbus function onChatAppended(sid, offset, text) { if (sid !== paneRoot.sessionId) return if (offset === paneRoot.chatOffset) { paneModel.rawText = paneModel.rawText + text paneRoot.chatOffset += text.length paneModel.setReactions(dbus.getReactions(paneRoot.sessionId)) } else { // Offset mismatch - refetch let chat = dbus.getChat(paneRoot.sessionId, 0) if (chat) { paneModel.rawText = chat.text paneRoot.chatOffset = chat.offset 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 for pane swapping) Rectangle { id: paneHeader Layout.fillWidth: true implicitHeight: headerRow.implicitHeight + 12 color: paneHeaderDrag.active ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.1) : "transparent" property int paneIndex: -1 // set externally by main.qml // Pane swap: press header, drag to another pane, release to swap MouseArea { id: paneHeaderDrag anchors.fill: parent property bool dragging: false cursorShape: dragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor onPressed: function(mouse) { dragging = false } onPositionChanged: function(mouse) { if (pressed && !dragging) { dragging = true paneRoot.startedDrag() } } onReleased: function(mouse) { if (dragging) { // Map release point to root window coordinates let rootPt = paneHeaderDrag.mapToItem(null, mouse.x, mouse.y) paneRoot.endedDrag(rootPt.x, rootPt.y) } dragging = false paneHeader.color = "transparent" } } 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: paneModel.filterText = text Keys.onEscapePressed: { filterBar.visible = false paneModel.filterText = "" text = "" } } Label { text: paneModel.filtering ? paneModel.rowCount() + " matches" : "" opacity: 0.6 font.pixelSize: Qt.application.font.pixelSize * 0.85 } ToolButton { icon.name: "window-close" onClicked: { filterBar.visible = false 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 let bottom = contentHeight - height parent.atBottom = (bottom <= 0 || contentY >= bottom - 4) // Lazy-load earlier blocks when near top if (contentY < 80 && contentY >= 0 && !_settingY) { let oldHeight = contentHeight if (paneModel.loadEarlier()) { // Preserve scroll position _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: function(wheel) { paneFlick._settingY = true paneFlick.contentY = Math.max(0, Math.min(paneFlick.contentY - wheel.angleDelta.y, paneFlick.contentHeight - paneFlick.height)) paneFlick._settingY = false let bottom = paneFlick.contentHeight - paneFlick.height paneFlick.parent.atBottom = (bottom <= 0 || paneFlick.contentY >= bottom - 4) wheel.accepted = true } } 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: palette.placeholderText } Repeater { id: paneChatRepeater model: paneModel delegate: Loader { width: paneColumn.width property var block: model 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 } } } Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } // Prompt 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", "<3": "\u2764\uFE0F", "B)": "\uD83D\uDE0E", "XD": "\uD83D\uDE06", ":|": "\uD83D\uDE10" }) property bool _replacing: false onTextChanged: { if (_replacing) return let pos = cursorPosition for (let len = 2; len <= Math.min(4, pos); len++) { let candidate = text.substring(pos - len, pos) if (quickEmojis[candidate] !== undefined) { _replacing = true let before = text.substring(0, pos - len) let after = text.substring(pos) let emoji = quickEmojis[candidate] text = before + emoji + after cursorPosition = before.length + emoji.length _replacing = false return } } // :shortcode: completion if (pos >= 3 && text[pos-1] === ':') { let start = text.lastIndexOf(':', pos - 2) if (start >= 0 && start < pos - 2) { let code = text.substring(start + 1, pos - 1) let match = Emojis.emojiList.find(e => e.code === code) if (match) { _replacing = true let before = text.substring(0, start) let after = text.substring(pos) text = before + match.emoji + after cursorPosition = before.length + match.emoji.length _replacing = false } } } } function emojiQuery() { let pos = cursorPosition let t = text for (let i = pos - 1; i >= Math.max(0, pos - 20); i--) { if (t[i] === ':') { let 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) { let q = emojiQuery() if (!q) return _replacing = true let before = text.substring(0, q.start) let after = text.substring(cursorPosition) text = before + emoji + after cursorPosition = before.length + emoji.length _replacing = false emojiPopup.visible = false } Keys.onReturnPressed: function(event) { if (emojiPopup.visible && emojiModel.count > 0) { let 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: function(event) { 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_Tab) { if (emojiModel.count > 0) { let item = emojiModel.get(emojiList.currentIndex) applyEmoji(item.emoji, item.code) event.accepted = true } } else if (event.key === Qt.Key_Escape) { emojiPopup.visible = false event.accepted = true } } } // Emoji completion popup trigger (in onTextChanged, after shortcode replacement) Component.onCompleted: { // Hook into text changes for popup textChanged.connect(function() { if (_replacing) return let q = emojiQuery() if (q) { let matches = Emojis.emojiList.filter(function(e) { return e.code.indexOf(q.query) === 0 }).slice(0, 8) if (matches.length > 0) { emojiModel.clear() for (let m of matches) emojiModel.append({code: m.code, emoji: m.emoji}) emojiPopup.visible = true emojiList.currentIndex = 0 } else { emojiPopup.visible = false } } else { emojiPopup.visible = false } }) } } } // 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: { let 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" } } // Filter shortcut Shortcut { sequence: "Ctrl+F" onActivated: { filterBar.visible = !filterBar.visible if (filterBar.visible) { filterInput.forceActiveFocus() filterInput.selectAll() } else { paneModel.filterText = "" filterInput.text = "" } } } // Ctrl+L focuses the prompt input Shortcut { sequence: "Ctrl+L" onActivated: panePrompt.forceActiveFocus() } 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: paneModel.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: paneModel.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: block ? (block.reaction || "") : "" font.pixelSize: 18 visible: block ? block.reaction !== "" : false } // Reaction buttons (hover only, hidden when already reacted) Row { anchors.bottom: parent.bottom anchors.right: parent.right anchors.rightMargin: block && block.reaction !== "" ? 36 : 4 anchors.margins: 4 z: 10 spacing: 2 opacity: (assistantHover.hovered && (block ? block.reaction : "") === "") ? 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); 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: paneModel.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: paneModel.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 { id: proseText width: parent ? parent.width : 100 text: paneModel.linkifyFilePaths(subBlock.content || "") wrapMode: TextEdit.Wrap textFormat: TextEdit.MarkdownText color: palette.text selectionColor: palette.highlight selectedTextColor: palette.highlightedText readOnly: true selectByMouse: true // Qt 6 renders Markdown text noticeably smaller than Controls text. font.pointSize: root.font.pointSize + 2 font.family: root.font.family onLinkActivated: function(link) { paneModel.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: 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: { let m = videoUrl.match(/[?&]v=([^&]+)/) || videoUrl.match(/youtu\.be\/([^?]+)/) return m ? m[1] : "" } // Direct media: inline player Video { id: videoPlayer anchors.fill: parent source: videoItem.isDirectMedia ? videoItem.videoUrl : "" visible: videoItem.isDirectMedia fillMode: VideoOutput.PreserveAspectFit MouseArea { anchors.fill: parent onClicked: { if (videoPlayer.playbackState === MediaPlayer.PlayingState) videoPlayer.pause() else videoPlayer.play() } } } // Non-direct: thumbnail card that opens in browser Rectangle { anchors.fill: parent visible: !videoItem.isDirectMedia 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: 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: 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 // Header with language + copy/exec buttons 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: { let lang = (subBlock.language || "").toLowerCase() return ["bash", "sh", "zsh", "fish", "python", "python3", "py", "perl", "ruby", "rb", "lua", "node", "javascript", "js", "php"].indexOf(lang) >= 0 } onClicked: paneModel.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: { paneModel.copyToClipboard(subBlock.content) } ToolTip.text: "Copy" ToolTip.visible: hovered } } // Code content — show plain first, highlight lazily 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 = paneModel.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: { paneModel.copyToClipboard(subBlock.content) } ToolTip.text: "Copy" ToolTip.visible: hovered } } // Diff lines with coloring 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: paneModel.diagramUrl(subBlock.content || "", subBlock.language || "") asynchronous: true cache: false // we manage caching ourselves // Loading indicator BusyIndicator { anchors.centerIn: parent running: diagramImg.status === Image.Loading visible: running } // Error fallback 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) { // Middle-click: invalidate cache and re-render paneModel.invalidateDiagram(subBlock.content || "", subBlock.language || "") diagramImg.source = "" diagramImg.source = paneModel.diagramUrl(subBlock.content || "", subBlock.language || "") } else { // Left-click: open fullscreen view 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: paneModel.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 } // Close on Escape Shortcut { sequence: "Escape" enabled: diagramFullscreen.visible onActivated: diagramFullscreen.close() } // Close button ToolButton { anchors.top: parent.top anchors.right: parent.right anchors.margins: 8 z: 10 icon.name: "window-close" onClicked: diagramFullscreen.close() } // Zoom reset button 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) { let factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87 let 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 } } }