// 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 import "TextSelection.js" as TextSelection Column { id: root spacing: 2 // Required properties from model required property string blockType required property var blockSegments required property string blockRenderedHtml required property int index required property string blockId // Bookmark functions (connected from parent) property var addBookmark: function(sid, sn, aid, an, bid, bt, p) { return false } property var removeBookmark: function(sid, aid, bid) { return false } property var checkBookmarked: function(sid, aid, bid) { return false } property bool isBookmarked: checkBookmarked(ollie.activeSessionId, ollie.activeAgentId, blockId) // 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 Call" case "tool": return "\uD83D\uDDA5\uFE0F Tool" 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 Row { spacing: 2 // Copy block content ToolButton { text: "📋" font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 implicitWidth: theme.fontSize > 0 ? theme.fontSize * 2 : 28 implicitHeight: theme.fontSize > 0 ? theme.fontSize * 1.8 : 24 ToolTip.visible: hovered ToolTip.text: "Copy" onClicked: { var content = getBlockPlainText() if (content) clipboard.copyText(content) } } // Retry (assistant blocks only) ToolButton { text: "🔄" font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 implicitWidth: theme.fontSize > 0 ? theme.fontSize * 2 : 28 implicitHeight: theme.fontSize > 0 ? theme.fontSize * 1.8 : 24 visible: blockType === "assistant" || blockType === "error" ToolTip.visible: hovered ToolTip.text: "Retry" onClicked: ollie.submit("retry") } // Continue (assistant blocks only) ToolButton { text: "➡️" font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 implicitWidth: theme.fontSize > 0 ? theme.fontSize * 2 : 28 implicitHeight: theme.fontSize > 0 ? theme.fontSize * 1.8 : 24 visible: blockType === "assistant" ToolTip.visible: hovered ToolTip.text: "Continue" onClicked: ollie.submit("continue") } // Edit & Resend (user blocks only) ToolButton { text: "✏️" font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 implicitWidth: theme.fontSize > 0 ? theme.fontSize * 2 : 28 implicitHeight: theme.fontSize > 0 ? theme.fontSize * 1.8 : 24 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) } } // Bookmark ToolButton { text: root.isBookmarked ? "🔖" : "📌" font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 implicitWidth: theme.fontSize > 0 ? theme.fontSize * 2 : 28 implicitHeight: theme.fontSize > 0 ? theme.fontSize * 1.8 : 24 ToolTip.visible: hovered ToolTip.text: root.isBookmarked ? "Remove bookmark" : "Bookmark" onClicked: { if (root.isBookmarked) { root.removeBookmark(ollie.activeSessionId, ollie.activeAgentId, root.blockId) } else { var sessionName = ollie.activeSessionName || ollie.activeSessionId var agentName = ollie.activeAgentName || ollie.activeAgentId var preview = getBlockPlainText() || blockType root.addBookmark( ollie.activeSessionId, sessionName, ollie.activeAgentId, agentName, root.blockId, blockType, preview ) } } } } } } // 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 // Double-click: bracket/quote pair or word select TapHandler { acceptedButtons: Qt.LeftButton onDoubleTapped: function(eventPoint, button) { var pos = eventPoint.position if (!TextSelection.selectBracketPair(mainTextEdit, pos.x, pos.y)) { TextSelection.selectWordAt(mainTextEdit, pos.x, pos.y) } } } // Acme-style mouse handling: B2=execute, B3=plumb // B1+B2=cut, B1+B3=paste (chord detection) MouseArea { anchors.fill: parent acceptedButtons: Qt.MiddleButton | Qt.RightButton cursorShape: Qt.IBeamCursor onPressed: function(mouse) { if (mouse.buttons & Qt.LeftButton) { if (mouse.button === Qt.MiddleButton) { mainTextEdit.cut() } else if (mouse.button === Qt.RightButton) { mainTextEdit.paste() } mouse.accepted = true return } if (mouse.button === Qt.MiddleButton) { var sel = mainTextEdit.selectedText if (sel && sel.trim() !== "") { executeText(sel) } mouse.accepted = true } else if (mouse.button === Qt.RightButton) { if (plumber.available()) { var sel = mainTextEdit.selectedText if (sel && sel.trim() !== "") { plumber.plumb(sel.trim(), sessionCwd()) } else { plumbWordAt(mainTextEdit, mouse.x, mouse.y) } } mouse.accepted = true } } } } // 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 || "") : "" } // Expand cursor position to surrounding word/path for plumbing // Matches acme's isfilec: alphanumeric + . - + / : @ // Returns {start, end, word} or null if no word found function wordAtCursor(textEdit, cursorPos) { var text = textEdit.getText(0, textEdit.length) if (cursorPos < 0 || cursorPos > text.length) return null // isfilec from acme: alphanumeric + . - + / : @ _ function isfilec(ch) { return /[a-zA-Z0-9.\-+\/:@_]/.test(ch) } // Find start of word var start = cursorPos while (start > 0 && isfilec(text.charAt(start - 1))) { start-- } // Find end of word var end = cursorPos while (end < text.length && isfilec(text.charAt(end))) { end++ } if (start === end) return null return {start: start, end: end, word: text.substring(start, end)} } // Plumb word at cursor (no selection) function plumbWordAt(textEdit, mouseX, mouseY) { var pos = textEdit.positionAt(mouseX, mouseY) var result = wordAtCursor(textEdit, pos) if (result) { plumber.plumb(result.word, sessionCwd()) } } // 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 // Double-click: bracket/quote pair or word select TapHandler { acceptedButtons: Qt.LeftButton onDoubleTapped: function(eventPoint, button) { var pos = eventPoint.position if (!TextSelection.selectBracketPair(textEdit, pos.x, pos.y)) { TextSelection.selectWordAt(textEdit, pos.x, pos.y) } } } // Acme-style mouse handling: B2=execute, B3=plumb // B1+B2=cut, B1+B3=paste (chord detection) MouseArea { anchors.fill: parent acceptedButtons: Qt.MiddleButton | Qt.RightButton cursorShape: Qt.IBeamCursor onPressed: function(mouse) { if (mouse.buttons & Qt.LeftButton) { if (mouse.button === Qt.MiddleButton) { textEdit.cut() } else if (mouse.button === Qt.RightButton) { textEdit.paste() } mouse.accepted = true 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) { if (plumber.available()) { var sel = textEdit.selectedText if (sel && sel.trim() !== "") { plumber.plumb(sel.trim(), sessionCwd()) } else { plumbWordAt(textEdit, mouse.x, mouse.y) } } mouse.accepted = true } } } } } // 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 or word under cursor if (!plumber.available()) { mouse.accepted = true return } var sel = sourceEdit.selectedText if (sel && sel.trim() !== "") { plumber.plumb(sel.trim(), sessionCwd()) } else { var pos = sourceEdit.positionAt(mouse.x, mouse.y) var word = wordAtCursor(sourceEdit, pos) if (word) plumber.plumb(word, 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 } } } } } }