ollie/kde/gui/ChatBlockDelegate.qml

704 lines
26 KiB
QML

// 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
anchors.fill: parent
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) {
// Try bracket-pair selection; fall through to word select if no bracket
if (selectBracketPair(mainTextEdit, mouse.x, mouse.y)) {
mouse.accepted = true
} else {
mouse.accepted = false // let TextEdit do word select
}
}
}
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())
}
}
// 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
}
}
// 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 matching brackets on double-click
// Returns true if a bracket pair was found and 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
// Check char to the right of cursor
var rightCh = cursorPos < text.length ? text.charAt(cursorPos) : ''
var rightMatch = matchingBracket(rightCh)
// Check char to the left of cursor
var leftCh = cursorPos > 0 ? text.charAt(cursorPos - 1) : ''
var leftMatch = matchingBracket(leftCh)
var innerStart = -1
var innerEnd = -1
// Prefer opener to the right, then closer to the left
if (rightMatch && rightMatch.isOpener) {
// Opener to right: find closer
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
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 backwards
var openerPos = findOpener(text, cursorPos - 1, rightMatch.match, rightCh)
if (openerPos >= 0) {
innerStart = openerPos + 1
innerEnd = cursorPos
}
} else if (leftMatch && leftMatch.isOpener) {
// Opener to left: find closer
var closerPos = findCloser(text, cursorPos, leftCh, leftMatch.match)
if (closerPos >= 0) {
innerStart = cursorPos
innerEnd = closerPos
}
}
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)) {
mouse.accepted = true
} else {
mouse.accepted = false
}
}
}
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
}
}
}
}
}
}