// 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. Item { width: root.width height: mainTextEdit.height visible: !hasDiagrams() TextEdit { id: mainTextEdit width: parent.width 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 // B1+B2=cut, B1+B3=paste (chord detection) // Double-click on bracket: select text between matching pair MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton z: 1 onDoubleClicked: function(mouse) { if (mouse.button === Qt.LeftButton) { // Delimiter under cursor: select the pair. // Otherwise select the word ourselves (the overlay // swallows the event, so TextEdit won't do it). if (!selectBracketPair(mainTextEdit, mouse.x, mouse.y)) { selectWordAt(mainTextEdit, mouse.x, mouse.y) } mouse.accepted = true } } onPressed: function(mouse) { // Left button single clicks pass through to TextEdit for selection if (mouse.button === Qt.LeftButton) { mouse.accepted = false return } 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 { // No selection - expand word and highlight it selectAndPlumb(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)} } // Select word at cursor and plumb it (with visual highlight) function selectAndPlumb(textEdit, mouseX, mouseY) { var pos = textEdit.positionAt(mouseX, mouseY) var result = wordAtCursor(textEdit, pos) if (result) { // Select the word to show what's being plumbed textEdit.select(result.start, result.end) plumber.plumb(result.word, sessionCwd()) } } // Select the plain word under the mouse (double-click word select). // Uses a word charset, not acme's broader isfilec, so a double-click // behaves like a normal editor word select. function selectWordAt(textEdit, mouseX, mouseY) { var cursorPos = textEdit.positionAt(mouseX, mouseY) var text = textEdit.getText(0, textEdit.length) if (cursorPos < 0 || cursorPos > text.length) return function isword(ch) { return /[A-Za-z0-9_]/.test(ch) } var start = cursorPos while (start > 0 && isword(text.charAt(start - 1))) start-- var end = cursorPos while (end < text.length && isword(text.charAt(end))) end++ if (start === end) return textEdit.select(start, end) } // Bracket pairs for double-click selection function matchingBracket(ch) { switch (ch) { case '(': return {match: ')', isOpener: true} case '[': return {match: ']', isOpener: true} case '{': return {match: '}', isOpener: true} case ')': return {match: '(', isOpener: false} case ']': return {match: '[', isOpener: false} case '}': return {match: '{', isOpener: false} default: return null } } function isQuote(ch) { return ch === "'" || ch === '"' || ch === '`' } // A quote at position qPos opens a pair when an even number of identical // quote chars precede it; odd means it closes one. Quotes do not nest, so // parity decides direction. function quoteIsOpener(text, qPos, quote) { var count = 0 for (var c = 0; c < qPos; c++) { if (text.charAt(c) === quote) count++ } return (count % 2) === 0 } // Next identical quote char forward from qPos. -1 if none. function findQuoteForward(text, qPos, quote) { for (var c = qPos + 1; c < text.length; c++) { if (text.charAt(c) === quote) return c } return -1 } // Previous identical quote char backward from qPos. -1 if none. function findQuoteBackward(text, qPos, quote) { for (var c = qPos - 1; c >= 0; c--) { if (text.charAt(c) === quote) return c } return -1 } // Find matching closer, handling nesting function findCloser(text, startPos, opener, closer) { var depth = 1 var pos = startPos while (pos < text.length) { var ch = text.charAt(pos) if (ch === opener) depth++ else if (ch === closer) { depth-- if (depth === 0) return pos } pos++ } return -1 // unbalanced } // Find matching opener, handling nesting (scan backwards) function findOpener(text, startPos, opener, closer) { var depth = 1 var pos = startPos while (pos >= 0) { var ch = text.charAt(pos) if (ch === closer) depth++ else if (ch === opener) { depth-- if (depth === 0) return pos } pos-- } return -1 // unbalanced } // Select text between a matching delimiter pair on double-click. // Quotes ('' "" ``) are resolved first (symmetric, non-nesting, line-scoped, // direction by parity); then brackets (nesting, document-wide). The adjacent // delimiter can be on either side of the cursor and determines the search // direction and the matching end delimiter. // Returns true if a pair was found and the inner content selected. function selectBracketPair(textEdit, mouseX, mouseY) { var cursorPos = textEdit.positionAt(mouseX, mouseY) var text = textEdit.getText(0, textEdit.length) if (cursorPos < 0 || cursorPos > text.length) return false // Char to the right and left of the click position. var rightCh = cursorPos < text.length ? text.charAt(cursorPos) : '' var leftCh = cursorPos > 0 ? text.charAt(cursorPos - 1) : '' // Quote pairs first. Pick the adjacent quote (right preferred), decide // opener/closer by parity, then scan that direction for the matching // quote. Content between may span multiple lines; all of it is selected. var q = '' var qPos = -1 if (isQuote(rightCh)) { q = rightCh qPos = cursorPos } else if (isQuote(leftCh)) { q = leftCh qPos = cursorPos - 1 } if (q !== '') { var qOpen = -1 var qClose = -1 if (quoteIsOpener(text, qPos, q)) { qOpen = qPos qClose = findQuoteForward(text, qPos, q) } else { qClose = qPos qOpen = findQuoteBackward(text, qPos, q) } if (qOpen < 0 || qClose < 0) return false // unbalanced textEdit.select(qOpen + 1, qClose) return true } var rightMatch = matchingBracket(rightCh) var leftMatch = matchingBracket(leftCh) var innerStart = -1 var innerEnd = -1 // Prefer opener to the right, then closer to the left, then the // remaining adjacency cases. Inner range excludes the delimiters. if (rightMatch && rightMatch.isOpener) { // Opener to right: find closer forward. var closerPos = findCloser(text, cursorPos + 1, rightCh, rightMatch.match) if (closerPos >= 0) { innerStart = cursorPos + 1 innerEnd = closerPos } } else if (leftMatch && !leftMatch.isOpener) { // Closer to left: find opener backward. var openerPos = findOpener(text, cursorPos - 2, leftMatch.match, leftCh) if (openerPos >= 0) { innerStart = openerPos + 1 innerEnd = cursorPos - 1 } } else if (rightMatch && !rightMatch.isOpener) { // Closer to right: find opener backward from just before it. var openerPos2 = findOpener(text, cursorPos - 1, rightMatch.match, rightCh) if (openerPos2 >= 0) { innerStart = openerPos2 + 1 innerEnd = cursorPos } } else if (leftMatch && leftMatch.isOpener) { // Opener to left: find closer forward from the click position. var closerPos2 = findCloser(text, cursorPos, leftCh, leftMatch.match) if (closerPos2 >= 0) { innerStart = cursorPos innerEnd = closerPos2 } } if (innerStart >= 0 && innerEnd >= innerStart) { textEdit.select(innerStart, innerEnd) return true } return false } // 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) // Double-click on bracket: select text between matching pair MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton cursorShape: Qt.IBeamCursor onDoubleClicked: function(mouse) { if (mouse.button === Qt.LeftButton) { if (!selectBracketPair(textEdit, mouse.x, mouse.y)) { selectWordAt(textEdit, mouse.x, mouse.y) } mouse.accepted = true } } onPressed: function(mouse) { if (mouse.button === Qt.LeftButton) { mouse.accepted = false return } 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) { // B3: Plumb selection or word under cursor if (plumber.available()) { var sel = textEdit.selectedText if (sel && sel.trim() !== "") { plumber.plumb(sel.trim(), sessionCwd()) } else { selectAndPlumb(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 } } } } } }