// ChatBlockDelegate.qml — Delegate for rendering chat blocks in the ListView // Acme-style mouse actions: // B1 = select (default) // B2 (middle-click) = execute selection as prompt // B3 (right-click) = plumb to plan9port / context menu import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Column { id: root spacing: 2 // Required properties from model required property string blockType required property string blockName required property var blockSegments required property string blockRenderedHtml // Expand state based on block type property bool blockExpanded: { switch (blockType) { case "user": case "assistant": case "tool": case "info": case "retry": case "interrupt": return true default: return false } } // Show action buttons on hover property bool hovered: headerArea.containsMouse // Header color based on block type and theme function headerColor() { var bg = theme.baseColor if (theme.isDark) { switch (blockType) { case "user": return Qt.lighter(bg, 1.8) case "assistant": return Qt.lighter(bg, 1.5) case "reasoning": return Qt.lighter(bg, 1.3) case "call": return Qt.lighter(bg, 1.7) case "tool": return Qt.lighter(bg, 1.2) case "error": return Qt.rgba(0.6, 0.15, 0.15, 0.7) case "info": return Qt.lighter(bg, 1.4) case "retry": return Qt.rgba(0.6, 0.5, 0.0, 0.5) case "interrupt": return "#3d2e00" default: return bg } } else { switch (blockType) { case "user": return Qt.darker(bg, 1.12) case "assistant": return Qt.darker(bg, 1.06) case "reasoning": return Qt.darker(bg, 1.03) case "call": return Qt.darker(bg, 1.1) case "tool": return Qt.darker(bg, 1.04) case "error": return Qt.rgba(1.0, 0.8, 0.8, 0.7) case "info": return Qt.darker(bg, 1.02) case "retry": return Qt.rgba(1.0, 0.9, 0.6, 0.5) case "interrupt": return "#fff8e1" default: return bg } } } // Header label text based on block type function headerText() { switch (blockType) { case "user": return "\uD83E\uDDD1 You" case "assistant": return "\uD83E\uDD16 Ollie" case "reasoning": return "Reasoning" case "call": return "\u25B6\uFE0F " + blockName case "tool": return "\uD83D\uDDA5\uFE0F " + blockName case "error": return "\u274C Error" case "info": return "\u2139\uFE0F Info" case "retry": return "Retry" case "interrupt": return "\u26A0\uFE0F User Interrupt" default: return "" } } // Clickable header with action buttons Rectangle { width: root.width height: 28 radius: 3 color: root.headerColor() MouseArea { id: headerArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.blockExpanded = !root.blockExpanded } RowLayout { anchors.fill: parent anchors.leftMargin: 6 anchors.rightMargin: 6 spacing: 4 Label { text: root.blockExpanded ? "▼" : "▶" font.pixelSize: 8 opacity: 0.6 } Label { Layout.fillWidth: true text: root.headerText() font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11 font.bold: true color: theme.textColor opacity: 0.9 } // Action buttons (visible on hover) — Acme-style tag line Row { spacing: 2 visible: root.hovered opacity: 0.8 // Copy block content ToolButton { text: "📋" font.pixelSize: 12 implicitWidth: 24 implicitHeight: 22 ToolTip.visible: hovered ToolTip.text: "Copy" onClicked: { var content = getBlockPlainText() if (content) clipboard.copyText(content) } } // Retry (assistant blocks only) ToolButton { text: "🔄" font.pixelSize: 12 implicitWidth: 24 implicitHeight: 22 visible: blockType === "assistant" || blockType === "error" ToolTip.visible: hovered ToolTip.text: "Retry" onClicked: ollie.submit("retry") } // Continue (assistant blocks only) ToolButton { text: "➡️" font.pixelSize: 12 implicitWidth: 24 implicitHeight: 22 visible: blockType === "assistant" ToolTip.visible: hovered ToolTip.text: "Continue" onClicked: ollie.submit("continue") } // Edit & Resend (user blocks only) ToolButton { text: "✏️" font.pixelSize: 12 implicitWidth: 24 implicitHeight: 22 visible: blockType === "user" ToolTip.visible: hovered ToolTip.text: "Copy to input for editing" onClicked: { var content = getBlockPlainText() // Copy to clipboard - user can paste into input if (content) clipboard.copyText(content) } } } } } // Block content rendered as segments Column { id: contentColumn width: root.width visible: root.blockExpanded spacing: 4 // Fast path: single TextEdit for the concatenated HTML. // Diagrams are handled separately via the segments list. // Editable for scratchpad use - changes are local only, not persisted. TextEdit { id: mainTextEdit width: root.width visible: !hasDiagrams() text: root.blockRenderedHtml textFormat: Text.RichText selectByMouse: true persistentSelection: true wrapMode: TextEdit.Wrap font.family: theme.fontFamily || Qt.application.font.family font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 selectionColor: theme.highlightColor selectedTextColor: theme.highlightedTextColor // Acme-style mouse handling: B2=execute, B3=plumb/menu // B1+B2=cut, B1+B3=paste (chord detection) MouseArea { anchors.fill: parent acceptedButtons: Qt.MiddleButton | Qt.RightButton cursorShape: Qt.IBeamCursor propagateComposedEvents: true property bool leftHeld: false onPressed: function(mouse) { // Check if this is a chord (B1 already held) if (mouse.buttons & Qt.LeftButton) { leftHeld = true if (mouse.button === Qt.MiddleButton) { // B1+B2 = cut (snarf) mainTextEdit.cut() mouse.accepted = true return } else if (mouse.button === Qt.RightButton) { // B1+B3 = paste mainTextEdit.paste() mouse.accepted = true return } } leftHeld = false mouse.accepted = false } onClicked: function(mouse) { if (leftHeld) { leftHeld = false return } if (mouse.button === Qt.MiddleButton) { // B2: Execute selection as prompt var sel = mainTextEdit.selectedText if (sel && sel.trim() !== "") { executeText(sel) } mouse.accepted = true } else if (mouse.button === Qt.RightButton) { // B3: Plumb selection (no context menu) var sel = mainTextEdit.selectedText if (sel && sel.trim() !== "" && plumber.available()) { plumber.plumb(sel.trim(), sessionCwd()) } mouse.accepted = true } } onReleased: { leftHeld = false } } } // Diagram path: use Repeater for blocks that contain diagrams Repeater { model: hasDiagrams() ? root.blockSegments : [] delegate: Loader { width: root.width active: modelData !== null && modelData !== undefined sourceComponent: (modelData && modelData.type === "diagram") ? diagramComponent : textComponent property var segmentData: modelData || {} } } } function hasDiagrams() { if (!root.blockSegments) return false for (var i = 0; i < root.blockSegments.length; i++) { if (root.blockSegments[i] && root.blockSegments[i].type === "diagram") return true } return false } // Get plain text content of this block function getBlockPlainText() { // Use the mainTextEdit's text property stripped of HTML if (mainTextEdit.visible) { return mainTextEdit.getText(0, mainTextEdit.length) } // For diagram blocks, concatenate segment text var text = "" if (root.blockSegments) { for (var i = 0; i < root.blockSegments.length; i++) { var seg = root.blockSegments[i] if (seg && seg.source) text += seg.source + "\n" } } return text.trim() } // Execute text: send as prompt or wrap if it looks like code function executeText(text) { if (!text || text.trim() === "") return var trimmed = text.trim() // If multi-line or looks like code, wrap in "run this" if (trimmed.indexOf('\n') >= 0 || looksLikeCommand(trimmed)) { ollie.submit("Run this:\n```\n" + trimmed + "\n```") } else { ollie.submit(trimmed) } } // Detect if text looks like an executable command function looksLikeCommand(text) { var cmdPrefixes = [ "ls", "cd", "cat", "grep", "find", "make", "go ", "npm", "yarn", "git ", "docker", "kubectl", "curl", "wget", "ssh", "scp", "python", "node", "ruby", "cargo", "rustc", "gcc", "clang", "./", "sudo" ] var lower = text.toLowerCase().trim() for (var i = 0; i < cmdPrefixes.length; i++) { if (lower.startsWith(cmdPrefixes[i])) return true } return false } // Get current session's working directory for plumb basedir function sessionCwd() { if (!ollie.activeSessionId) return "" var cfg = ollie.readSessionConfig(ollie.activeSessionId) return cfg ? (cfg.cwd || "") : "" } // Text segment component Component { id: textComponent TextEdit { id: textEdit width: root.width text: segmentData.html || "" textFormat: Text.RichText selectByMouse: true persistentSelection: true wrapMode: TextEdit.Wrap font.family: theme.fontFamily || Qt.application.font.family font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 selectionColor: theme.highlightColor selectedTextColor: theme.highlightedTextColor // Acme-style mouse handling with chord support // B1+B2=cut, B1+B3=paste (chord detection) MouseArea { anchors.fill: parent acceptedButtons: Qt.MiddleButton | Qt.RightButton cursorShape: Qt.IBeamCursor propagateComposedEvents: true property bool leftHeld: false onPressed: function(mouse) { if (mouse.buttons & Qt.LeftButton) { leftHeld = true if (mouse.button === Qt.MiddleButton) { textEdit.cut() mouse.accepted = true return } else if (mouse.button === Qt.RightButton) { textEdit.paste() mouse.accepted = true return } } leftHeld = false mouse.accepted = false } onClicked: function(mouse) { if (leftHeld) { leftHeld = false return } if (mouse.button === Qt.MiddleButton) { var sel = textEdit.selectedText if (sel && sel.trim() !== "") { executeText(sel) } mouse.accepted = true } else if (mouse.button === Qt.RightButton) { // B3: Plumb selection (no context menu) var sel = textEdit.selectedText if (sel && sel.trim() !== "" && plumber.available()) { plumber.plumb(sel.trim(), sessionCwd()) } mouse.accepted = true } } onReleased: { leftHeld = false } } } } // Diagram segment component Component { id: diagramComponent Rectangle { id: diagramRect width: root.width height: diagramContent.height + diagramHeader.height + 8 color: "transparent" border.color: theme.midColor border.width: 1 radius: 4 property bool showSource: false RowLayout { id: diagramHeader anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.margins: 4 height: 24 Label { text: segmentData.lang || "diagram" font.pointSize: theme.fontSize > 0 ? theme.fontSize - 1 : 11 color: theme.textColor opacity: 0.7 } Item { Layout.fillWidth: true } Button { text: diagramRect.showSource ? "📊" : "" font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10 implicitWidth: 32 implicitHeight: 20 ToolTip.visible: hovered ToolTip.text: diagramRect.showSource ? "Show diagram" : "Show source" onClicked: diagramRect.showSource = !diagramRect.showSource } Button { text: "📋" font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10 implicitWidth: 32 implicitHeight: 20 ToolTip.visible: hovered ToolTip.text: diagramRect.showSource ? "Copy source" : "Copy image" onClicked: { if (diagramRect.showSource) { clipboard.copyText(segmentData.source) } else if (segmentData.svg) { clipboard.copyImage(segmentData.svg) } } } } Item { id: diagramContent anchors.top: diagramHeader.bottom anchors.left: parent.left anchors.right: parent.right anchors.margins: 4 height: diagramRect.showSource ? sourceEdit.height : imageColumn.height TextEdit { id: sourceEdit width: parent.width visible: diagramRect.showSource text: segmentData.html || "" textFormat: Text.RichText readOnly: true selectByMouse: true persistentSelection: true wrapMode: TextEdit.Wrap font.family: theme.fontFamily || Qt.application.font.family font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 selectionColor: theme.highlightColor selectedTextColor: theme.highlightedTextColor MouseArea { anchors.fill: parent acceptedButtons: Qt.MiddleButton | Qt.RightButton cursorShape: Qt.IBeamCursor propagateComposedEvents: true onClicked: function(mouse) { if (mouse.button === Qt.MiddleButton) { var sel = sourceEdit.selectedText if (sel && sel.trim() !== "") executeText(sel) mouse.accepted = true } else if (mouse.button === Qt.RightButton) { // B3: Plumb selection (no context menu) var sel = sourceEdit.selectedText if (sel && sel.trim() !== "" && plumber.available()) { plumber.plumb(sel.trim(), sessionCwd()) } mouse.accepted = true } } } } Column { id: imageColumn width: parent.width visible: !diagramRect.showSource Rectangle { visible: segmentData.svg && !segmentData.loading color: "white" width: diagramImg.width height: diagramImg.height Image { id: diagramImg source: segmentData.svg || "" fillMode: Image.PreserveAspectFit width: Math.min(implicitWidth, imageColumn.width) } } Label { visible: segmentData.loading text: "Loading " + (segmentData.lang || "diagram") + "..." font.italic: true color: theme.textColor opacity: 0.7 } } } } } }