560 lines
21 KiB
QML
560 lines
21 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.
|
|
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
|
|
|
|
onPressed: function(mouse) {
|
|
// Check if this is a chord (B1 already held)
|
|
if (mouse.buttons & Qt.LeftButton) {
|
|
if (mouse.button === Qt.MiddleButton) {
|
|
// B1+B2 = cut (snarf)
|
|
mainTextEdit.cut()
|
|
} else if (mouse.button === Qt.RightButton) {
|
|
// B1+B3 = paste
|
|
mainTextEdit.paste()
|
|
}
|
|
mouse.accepted = true
|
|
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 or word under cursor
|
|
if (plumber.available()) {
|
|
var sel = mainTextEdit.selectedText
|
|
if (sel && sel.trim() !== "") {
|
|
plumber.plumb(sel.trim(), sessionCwd())
|
|
} else {
|
|
// No selection - expand word at click position (acme-style)
|
|
var pos = mainTextEdit.positionAt(mouse.x, mouse.y)
|
|
var word = wordAtCursor(mainTextEdit, pos)
|
|
if (word) plumber.plumb(word, sessionCwd())
|
|
}
|
|
}
|
|
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 + . - + / : @
|
|
function wordAtCursor(textEdit, cursorPos) {
|
|
var text = textEdit.getText(0, textEdit.length)
|
|
if (cursorPos < 0 || cursorPos > text.length) return ""
|
|
|
|
// isfilec from acme: alphanumeric + . - + / : @
|
|
function isfilec(ch) {
|
|
return /[a-zA-Z0-9.\-+\/:@]/.test(ch)
|
|
}
|
|
|
|
// Find start of word (acme also allows isaddrc chars going left)
|
|
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 ""
|
|
return text.substring(start, end)
|
|
}
|
|
|
|
// 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
|
|
|
|
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) {
|
|
// B3: Plumb selection or word under cursor
|
|
if (plumber.available()) {
|
|
var sel = textEdit.selectedText
|
|
if (sel && sel.trim() !== "") {
|
|
plumber.plumb(sel.trim(), sessionCwd())
|
|
} else {
|
|
var pos = textEdit.positionAt(mouse.x, mouse.y)
|
|
var word = wordAtCursor(textEdit, pos)
|
|
if (word) plumber.plumb(word, sessionCwd())
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|