This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/gui/ChatPane.qml

1501 lines
55 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtMultimedia
import org.ollie.gui 1.0
import "emojis.js" as Emojis
// Self-contained chat pane with own session binding
Item {
id: paneRoot
property string sessionId: ""
property bool isFocused: false
signal startedDrag()
signal endedDrag(real globalX, real globalY)
// Pane-local chat model
ChatBlockModel { id: paneModel }
// Track chat state for this pane's session
property string paneState: "idle"
property string paneCwd: ""
property int chatOffset: 0
// Per-session draft text storage
property var _drafts: ({})
property string _prevSessionId: ""
// Fetch chat when session changes
onSessionIdChanged: {
// Save draft from previous session
if (_prevSessionId !== "") {
_drafts[_prevSessionId] = panePrompt.text
}
paneModel.rawText = ""
chatOffset = 0
paneState = "idle"
paneCwd = ""
if (sessionId !== "") {
// Restore draft for new session (or empty)
panePrompt.text = _drafts[sessionId] || ""
// Fetch initial chat and config
let chat = dbus.getChat(sessionId, 0)
if (chat) {
paneModel.rawText = chat.text
chatOffset = chat.offset
paneModel.setReactions(dbus.getReactions(sessionId))
}
let cfg = dbus.getConfig(sessionId)
if (cfg && cfg.cwd) paneCwd = cfg.cwd
let st = dbus.getState(sessionId)
if (st) paneState = st
} else {
panePrompt.text = ""
}
_prevSessionId = sessionId
}
// Listen for D-Bus signals filtered to our session
Connections {
target: dbus
function onChatAppended(sid, offset, text) {
if (sid !== paneRoot.sessionId) return
if (offset === paneRoot.chatOffset) {
paneModel.rawText = paneModel.rawText + text
paneRoot.chatOffset += text.length
paneModel.setReactions(dbus.getReactions(paneRoot.sessionId))
} else {
// Offset mismatch - refetch
let chat = dbus.getChat(paneRoot.sessionId, 0)
if (chat) {
paneModel.rawText = chat.text
paneRoot.chatOffset = chat.offset
paneModel.setReactions(dbus.getReactions(paneRoot.sessionId))
}
}
}
function onSessionStateUpdated(sid, newState) {
if (sid === paneRoot.sessionId)
paneRoot.paneState = newState
}
}
// Visual focus indicator
Rectangle {
anchors.fill: parent
color: "transparent"
border.color: paneRoot.isFocused ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.4) : "transparent"
border.width: 1
z: 100
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 2
spacing: 0
// Header (drag handle for pane swapping)
Rectangle {
id: paneHeader
Layout.fillWidth: true
implicitHeight: headerRow.implicitHeight + 12
color: paneHeaderDrag.active ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.1) : "transparent"
property int paneIndex: -1 // set externally by main.qml
// Pane swap: press header, drag to another pane, release to swap
MouseArea {
id: paneHeaderDrag
anchors.fill: parent
property bool dragging: false
cursorShape: dragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor
onPressed: function(mouse) {
dragging = false
}
onPositionChanged: function(mouse) {
if (pressed && !dragging) {
dragging = true
paneRoot.startedDrag()
}
}
onReleased: function(mouse) {
if (dragging) {
// Map release point to root window coordinates
let rootPt = paneHeaderDrag.mapToItem(null, mouse.x, mouse.y)
paneRoot.endedDrag(rootPt.x, rootPt.y)
}
dragging = false
paneHeader.color = "transparent"
}
}
RowLayout {
id: headerRow
anchors.fill: parent
anchors.margins: 6
Rectangle {
width: 8; height: 8; radius: 4
color: {
if (paneRoot.paneState === "idle") return "#4caf50"
if (paneRoot.paneState.startsWith("calling")) return "#ff9800"
return "#2196f3"
}
}
Label {
text: paneRoot.paneCwd || paneRoot.sessionId || "No session"
Layout.fillWidth: true
elide: Text.ElideMiddle
font.bold: true
font.pixelSize: Qt.application.font.pixelSize * 0.9
}
Label {
text: paneRoot.paneState
opacity: 0.6
font.pixelSize: Qt.application.font.pixelSize * 0.8
}
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid }
// Filter bar (Ctrl+F)
RowLayout {
id: filterBar
Layout.fillWidth: true
Layout.margins: 4
spacing: 4
visible: false
TextField {
id: filterInput
Layout.fillWidth: true
placeholderText: "Filter messages..."
onTextChanged: paneModel.filterText = text
Keys.onEscapePressed: {
filterBar.visible = false
paneModel.filterText = ""
text = ""
}
}
Label {
text: paneModel.filtering ? paneModel.rowCount() + " matches" : ""
opacity: 0.6
font.pixelSize: Qt.application.font.pixelSize * 0.85
}
ToolButton {
icon.name: "window-close"
onClicked: {
filterBar.visible = false
paneModel.filterText = ""
filterInput.text = ""
}
}
}
// Chat view
Item {
Layout.fillWidth: true
Layout.fillHeight: true
property bool atBottom: true
Flickable {
id: paneFlick
anchors.fill: parent
anchors.margins: 4
clip: true
contentWidth: width
contentHeight: paneColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
property bool _settingY: false
onContentYChanged: {
if (_settingY) return
let bottom = contentHeight - height
parent.atBottom = (bottom <= 0 || contentY >= bottom - 4)
// Lazy-load earlier blocks when near top
if (contentY < 80 && contentY >= 0 && !_settingY) {
let oldHeight = contentHeight
if (paneModel.loadEarlier()) {
// Preserve scroll position
_settingY = true
contentY += (contentHeight - oldHeight)
_settingY = false
}
}
}
onContentHeightChanged: {
if (parent.atBottom) {
_settingY = true
contentY = Math.max(0, contentHeight - height)
_settingY = false
}
}
ScrollBar.vertical: ScrollBar {
id: chatScrollBar
policy: ScrollBar.AsNeeded
contentItem: Rectangle {
implicitWidth: 6
radius: 3
color: chatScrollBar.pressed ? palette.highlight
: chatScrollBar.hovered ? palette.mid
: Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, chatScrollBar.active ? 0.6 : 0.0)
Behavior on color { ColorAnimation { duration: 150 } }
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: function(wheel) {
paneFlick._settingY = true
paneFlick.contentY = Math.max(0,
Math.min(paneFlick.contentY - wheel.angleDelta.y,
paneFlick.contentHeight - paneFlick.height))
paneFlick._settingY = false
let bottom = paneFlick.contentHeight - paneFlick.height
paneFlick.parent.atBottom = (bottom <= 0 || paneFlick.contentY >= bottom - 4)
wheel.accepted = true
}
}
Column {
id: paneColumn
width: paneFlick.width - 12 // leave room for scrollbar
spacing: 8
Label {
visible: paneChatRepeater.count === 0
text: paneRoot.sessionId ? "(waiting...)" : "No session"
color: palette.placeholderText
}
Repeater {
id: paneChatRepeater
model: paneModel
delegate: Loader {
width: paneColumn.width
property var block: model
sourceComponent: {
switch (blockType) {
case "user": return userDelegate
case "assistant": return assistantDelegate
case "reasoning": return reasoningDelegate
case "call": return callDelegate
case "tool": return toolDelegate
case "error": return errorDelegate
case "info": return infoDelegate
default: return assistantDelegate
}
}
}
}
}
}
ToolButton {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 6
icon.name: "go-down"
visible: !parent.atBottom
onClicked: {
paneFlick.contentY = Math.max(0, paneFlick.contentHeight - paneFlick.height)
parent.atBottom = true
}
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid }
// Prompt
RowLayout {
Layout.fillWidth: true
Layout.margins: 6
spacing: 4
ScrollView {
Layout.fillWidth: true
Layout.maximumHeight: 100
TextArea {
id: panePrompt
placeholderText: paneRoot.sessionId ? "Message..." : "No session"
enabled: paneRoot.sessionId !== ""
wrapMode: TextEdit.Wrap
property var quickEmojis: ({
";)": "\uD83D\uDE09", ":)": "\uD83D\uDE42",
":D": "\uD83D\uDE03", ":(": "\uD83D\uDE1E",
":P": "\uD83D\uDE1B", "<3": "\u2764\uFE0F",
"B)": "\uD83D\uDE0E", "XD": "\uD83D\uDE06",
":|": "\uD83D\uDE10"
})
property bool _replacing: false
onTextChanged: {
if (_replacing) return
let pos = cursorPosition
for (let len = 2; len <= Math.min(4, pos); len++) {
let candidate = text.substring(pos - len, pos)
if (quickEmojis[candidate] !== undefined) {
_replacing = true
let before = text.substring(0, pos - len)
let after = text.substring(pos)
let emoji = quickEmojis[candidate]
text = before + emoji + after
cursorPosition = before.length + emoji.length
_replacing = false
return
}
}
// :shortcode: completion
if (pos >= 3 && text[pos-1] === ':') {
let start = text.lastIndexOf(':', pos - 2)
if (start >= 0 && start < pos - 2) {
let code = text.substring(start + 1, pos - 1)
let match = Emojis.emojiList.find(e => e.code === code)
if (match) {
_replacing = true
let before = text.substring(0, start)
let after = text.substring(pos)
text = before + match.emoji + after
cursorPosition = before.length + match.emoji.length
_replacing = false
}
}
}
}
function emojiQuery() {
let pos = cursorPosition
let t = text
for (let i = pos - 1; i >= Math.max(0, pos - 20); i--) {
if (t[i] === ':') {
let query = t.substring(i + 1, pos)
if (/^[a-zA-Z0-9_]+$/.test(query) && query.length >= 2)
return {start: i, query: query}
return null
}
if (!/[a-zA-Z0-9_]/.test(t[i])) return null
}
return null
}
function applyEmoji(emoji, code) {
let q = emojiQuery()
if (!q) return
_replacing = true
let before = text.substring(0, q.start)
let after = text.substring(cursorPosition)
text = before + emoji + after
cursorPosition = before.length + emoji.length
_replacing = false
emojiPopup.visible = false
}
Keys.onReturnPressed: function(event) {
if (emojiPopup.visible && emojiModel.count > 0) {
let item = emojiModel.get(emojiList.currentIndex)
applyEmoji(item.emoji, item.code)
event.accepted = true
return
}
if (event.modifiers & (Qt.ShiftModifier | Qt.ControlModifier)) {
panePrompt.insert(panePrompt.cursorPosition, "\n")
} else {
if (text.trim() !== "" && paneRoot.sessionId !== "") {
dbus.submit(paneRoot.sessionId, text.trim())
text = ""
}
}
event.accepted = true
}
Keys.onPressed: function(event) {
if (emojiPopup.visible) {
if (event.key === Qt.Key_Down) {
emojiList.currentIndex = Math.min(emojiList.currentIndex + 1, emojiModel.count - 1)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
emojiList.currentIndex = Math.max(emojiList.currentIndex - 1, 0)
event.accepted = true
} else if (event.key === Qt.Key_Tab) {
if (emojiModel.count > 0) {
let item = emojiModel.get(emojiList.currentIndex)
applyEmoji(item.emoji, item.code)
event.accepted = true
}
} else if (event.key === Qt.Key_Escape) {
emojiPopup.visible = false
event.accepted = true
}
}
}
// Emoji completion popup trigger (in onTextChanged, after shortcode replacement)
Component.onCompleted: {
// Hook into text changes for popup
textChanged.connect(function() {
if (_replacing) return
let q = emojiQuery()
if (q) {
let matches = Emojis.emojiList.filter(function(e) {
return e.code.indexOf(q.query) === 0
}).slice(0, 8)
if (matches.length > 0) {
emojiModel.clear()
for (let m of matches)
emojiModel.append({code: m.code, emoji: m.emoji})
emojiPopup.visible = true
emojiList.currentIndex = 0
} else {
emojiPopup.visible = false
}
} else {
emojiPopup.visible = false
}
})
}
}
}
// Emoji popup
ListModel { id: emojiModel }
Popup {
id: emojiPopup
y: -height - 4
x: 8
width: 260
height: Math.min(emojiList.contentHeight + 12, 200)
padding: 6
visible: false
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: palette.base
border.color: palette.mid
border.width: 1
radius: 6
}
ListView {
id: emojiList
anchors.fill: parent
model: emojiModel
clip: true
currentIndex: 0
delegate: ItemDelegate {
width: emojiList.width
height: 28
highlighted: index === emojiList.currentIndex
contentItem: RowLayout {
spacing: 8
Label { text: model.emoji; font.pixelSize: 16 }
Label { text: ":" + model.code + ":"; Layout.fillWidth: true; font.family: "monospace"; opacity: 0.8 }
}
onClicked: panePrompt.applyEmoji(model.emoji, model.code)
}
}
}
ToolButton {
icon.name: "media-playback-stop"
enabled: paneRoot.paneState !== "idle" && paneRoot.sessionId !== ""
onClicked: dbus.interrupt(paneRoot.sessionId)
ToolTip.text: "Interrupt"
ToolTip.visible: hovered
}
ToolButton {
icon.name: "process-stop"
visible: paneRoot.paneState.startsWith("calling:")
onClicked: dbus.detachProcess(paneRoot.sessionId)
ToolTip.text: "Detach process"
ToolTip.visible: hovered
}
// Detached process badge
ToolButton {
id: detachBadge
visible: detachBadge.count > 0
property int count: 0
text: "⚙ " + count
font.pixelSize: Qt.application.font.pixelSize * 0.85
onClicked: processManagerWindow.show()
ToolTip.text: count + " detached process" + (count !== 1 ? "es" : "")
ToolTip.visible: hovered
Timer {
interval: 2000
running: paneRoot.sessionId !== ""
repeat: true
triggeredOnStart: true
onTriggered: {
let procs = dbus.listDetached(paneRoot.sessionId)
detachBadge.count = procs.length
}
}
}
}
// Status
Label {
Layout.fillWidth: true
Layout.leftMargin: 6
Layout.bottomMargin: 2
font.pixelSize: Qt.application.font.pixelSize * 0.75
opacity: 0.4
text: paneRoot.sessionId || "no session"
}
}
// Filter shortcut
Shortcut {
sequence: "Ctrl+F"
onActivated: {
filterBar.visible = !filterBar.visible
if (filterBar.visible) {
filterInput.forceActiveFocus()
filterInput.selectAll()
} else {
paneModel.filterText = ""
filterInput.text = ""
}
}
}
// Ctrl+L focuses the prompt input
Shortcut {
sequence: "Ctrl+L"
onActivated: panePrompt.forceActiveFocus()
}
Component {
id: userDelegate
Item {
implicitHeight: userBubble.implicitHeight + userLabel.implicitHeight + 4
Rectangle {
id: userBubble
anchors.top: parent.top
anchors.right: parent.right
width: Math.max(160, Math.min(userContent.implicitWidth + 24, parent.width * 0.85))
implicitHeight: userContent.implicitHeight + 16
color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.15)
radius: 12
HoverHandler { id: userHover }
ToolButton {
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: 4
z: 10
icon.name: "edit-copy"
implicitWidth: 32
implicitHeight: 32
opacity: userHover.hovered ? 0.8 : 0
onClicked: paneModel.copyToClipboard(block.blockContent)
ToolTip.text: "Copy"
ToolTip.visible: hovered
Behavior on opacity { NumberAnimation { duration: 150 } }
}
ColumnLayout {
id: userContent
anchors.fill: parent
anchors.margins: 12
spacing: 4
Repeater {
model: block ? block.subBlocks : []
delegate: Loader {
Layout.fillWidth: true
property var subBlock: modelData
sourceComponent: subBlock.type === "code" ? codeSubDelegate
: subBlock.type === "diagram" ? diagramSubDelegate
: subBlock.type === "diff" ? diffSubDelegate
: subBlock.type === "interrupt" ? interruptSubDelegate
: subBlock.type === "video" ? videoSubDelegate
: proseSubDelegate
}
}
}
}
Label {
id: userLabel
text: "You"
font.pixelSize: Qt.application.font.pixelSize * 0.75
opacity: 0.5
anchors.right: parent.right
anchors.top: userBubble.bottom
anchors.topMargin: 2
}
}
}
Component {
id: assistantDelegate
Item {
implicitHeight: assistantBubble.implicitHeight + assistantLabel.implicitHeight + 4
Rectangle {
id: assistantBubble
anchors.top: parent.top
anchors.left: parent.left
width: parent.width * 0.95
implicitHeight: assistantContent.implicitHeight + 16
color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.06)
radius: 12
HoverHandler { id: assistantHover }
ToolButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 4
z: 10
icon.name: "edit-copy"
implicitWidth: 32
implicitHeight: 32
opacity: assistantHover.hovered ? 0.8 : 0
onClicked: paneModel.copyToClipboard(block.blockContent)
ToolTip.text: "Copy"
ToolTip.visible: hovered
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// Reaction badge (pinned when reacted)
Label {
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 4
z: 10
text: block ? (block.reaction || "") : ""
font.pixelSize: 18
visible: block ? block.reaction !== "" : false
}
// Reaction buttons (hover only, hidden when already reacted)
Row {
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.rightMargin: block && block.reaction !== "" ? 36 : 4
anchors.margins: 4
z: 10
spacing: 2
opacity: (assistantHover.hovered && (block ? block.reaction : "") === "") ? 0.9 : 0
Behavior on opacity { NumberAnimation { duration: 150 } }
Repeater {
model: [
{ emoji: "\uD83D\uDC4D", tip: "Good" },
{ emoji: "\uD83D\uDC4E", tip: "Bad" },
{ emoji: "\uD83D\uDE80", tip: "Excellent" },
{ emoji: "\uD83D\uDCA9", tip: "Very bad" },
{ emoji: "\uD83E\uDD14", tip: "Confused" }
]
ToolButton {
text: modelData.emoji
font.pixelSize: 14
implicitWidth: 28
implicitHeight: 28
onClicked: { dbus.reactTo(paneRoot.sessionId, block.responseId, modelData.emoji); paneModel.setReactions(dbus.getReactions(paneRoot.sessionId)) }
ToolTip.text: modelData.tip
ToolTip.visible: hovered
}
}
}
ColumnLayout {
id: assistantContent
anchors.fill: parent
anchors.margins: 12
spacing: 4
Repeater {
model: block ? block.subBlocks : []
delegate: Loader {
Layout.fillWidth: true
property var subBlock: modelData
sourceComponent: subBlock.type === "code" ? codeSubDelegate
: subBlock.type === "diagram" ? diagramSubDelegate
: subBlock.type === "diff" ? diffSubDelegate
: subBlock.type === "interrupt" ? interruptSubDelegate
: subBlock.type === "video" ? videoSubDelegate
: proseSubDelegate
}
}
}
}
Label {
id: assistantLabel
text: "Ollie"
font.pixelSize: Qt.application.font.pixelSize * 0.75
opacity: 0.5
anchors.left: parent.left
anchors.top: assistantBubble.bottom
anchors.topMargin: 2
}
}
}
Component {
id: reasoningDelegate
ColumnLayout {
spacing: 2
Label {
text: (reasoningText.visible ? "\u25BC" : "\u25B6") + " reasoning"
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.5
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: reasoningText.visible = !reasoningText.visible
}
}
Label {
id: reasoningText
Layout.fillWidth: true
text: (block ? block.blockContent : "")
wrapMode: Text.Wrap
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.85
opacity: 0.5
visible: false
}
}
}
Component {
id: callDelegate
Item {
implicitHeight: callLayout.implicitHeight + 12
HoverHandler { id: callHover }
Rectangle {
anchors.fill: parent
color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.03)
Rectangle {
width: 3
height: parent.height
color: "#ff9800"
}
}
ToolButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 4
z: 10
icon.name: "edit-copy"
implicitWidth: 24
implicitHeight: 24
opacity: callHover.hovered ? 0.8 : 0
onClicked: paneModel.copyToClipboard(block.blockContent)
ToolTip.text: "Copy"
ToolTip.visible: hovered
Behavior on opacity { NumberAnimation { duration: 150 } }
}
ColumnLayout {
id: callLayout
x: 10
width: parent.width - 20
y: 6
spacing: 4
Label {
text: "\u26A1 call: " + (block ? (block.blockName || "") : "")
font.pixelSize: Qt.application.font.pixelSize * 0.8
color: "#ff9800"
}
Repeater {
model: block ? block.subBlocks : []
delegate: Loader {
Layout.fillWidth: true
property var subBlock: modelData
sourceComponent: subBlock.type === "code" ? codeSubDelegate
: subBlock.type === "diagram" ? diagramSubDelegate
: subBlock.type === "diff" ? diffSubDelegate
: subBlock.type === "interrupt" ? interruptSubDelegate
: subBlock.type === "video" ? videoSubDelegate
: plainSubDelegate
}
}
}
}
}
Component {
id: toolDelegate
Item {
implicitHeight: toolLayout.implicitHeight + 12
HoverHandler { id: toolHover }
Rectangle {
anchors.fill: parent
color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.03)
Rectangle {
width: 3
height: parent.height
color: palette.mid
}
}
ToolButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 4
z: 10
icon.name: "edit-copy"
implicitWidth: 24
implicitHeight: 24
opacity: toolHover.hovered ? 0.8 : 0
onClicked: paneModel.copyToClipboard(block.blockContent)
ToolTip.text: "Copy"
ToolTip.visible: hovered
Behavior on opacity { NumberAnimation { duration: 150 } }
}
ColumnLayout {
id: toolLayout
x: 10
width: parent.width - 20
y: 6
spacing: 4
Label {
text: "\u25C6 tool: " + (block ? (block.blockName || "") : "")
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.6
}
Repeater {
model: block ? block.subBlocks : []
delegate: Loader {
Layout.fillWidth: true
property var subBlock: modelData
sourceComponent: subBlock.type === "code" ? codeSubDelegate
: subBlock.type === "diagram" ? diagramSubDelegate
: subBlock.type === "diff" ? diffSubDelegate
: subBlock.type === "interrupt" ? interruptSubDelegate
: subBlock.type === "video" ? videoSubDelegate
: plainSubDelegate
}
}
}
}
}
Component {
id: errorDelegate
Rectangle {
implicitHeight: errorLabel.implicitHeight + 12
color: Qt.rgba(1, 0, 0, 0.08)
radius: 4
Label {
id: errorLabel
anchors.fill: parent
anchors.margins: 6
text: (block ? block.blockContent : "")
wrapMode: Text.Wrap
color: "#e53935"
font.family: "monospace"
}
}
}
Component {
id: infoDelegate
Label {
text: (block ? block.blockContent : "")
wrapMode: Text.Wrap
font.pixelSize: Qt.application.font.pixelSize * 0.85
opacity: 0.6
font.italic: true
}
}
// --- Sub-block delegates ---
Component {
id: proseSubDelegate
TextEdit {
id: proseText
width: parent ? parent.width : 100
text: paneModel.linkifyFilePaths(subBlock.content || "")
wrapMode: TextEdit.Wrap
textFormat: TextEdit.MarkdownText
color: palette.text
selectionColor: palette.highlight
selectedTextColor: palette.highlightedText
readOnly: true
selectByMouse: true
// Qt 6 renders Markdown text noticeably smaller than Controls text.
font.pointSize: root.font.pointSize + 2
font.family: root.font.family
onLinkActivated: function(link) {
paneModel.openLink(link)
}
}
}
Component {
id: plainSubDelegate
TextEdit {
width: parent ? parent.width : 100
text: subBlock.content || ""
wrapMode: TextEdit.Wrap
textFormat: TextEdit.PlainText
color: palette.text
selectionColor: palette.highlight
selectedTextColor: palette.highlightedText
readOnly: true
selectByMouse: true
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.9
}
}
Component {
id: videoSubDelegate
Item {
id: videoItem
width: parent ? parent.width : 400
height: Math.max(200, Math.min(width * 9/16, 360))
property string videoUrl: subBlock.content || ""
property bool isDirectMedia: /\.(mp4|webm|ogv|m3u8)(\?|$)/i.test(videoUrl)
property string youtubeId: {
let m = videoUrl.match(/[?&]v=([^&]+)/) || videoUrl.match(/youtu\.be\/([^?]+)/)
return m ? m[1] : ""
}
// Direct media: inline player
Video {
id: videoPlayer
anchors.fill: parent
source: videoItem.isDirectMedia ? videoItem.videoUrl : ""
visible: videoItem.isDirectMedia
fillMode: VideoOutput.PreserveAspectFit
MouseArea {
anchors.fill: parent
onClicked: {
if (videoPlayer.playbackState === MediaPlayer.PlayingState)
videoPlayer.pause()
else
videoPlayer.play()
}
}
}
// Non-direct: thumbnail card that opens in browser
Rectangle {
anchors.fill: parent
visible: !videoItem.isDirectMedia
color: "#181818"
radius: 8
clip: true
// YouTube thumbnail
Image {
anchors.fill: parent
source: videoItem.youtubeId !== "" ?
"https://img.youtube.com/vi/" + videoItem.youtubeId + "/hqdefault.jpg" : ""
fillMode: Image.PreserveAspectCrop
visible: videoItem.youtubeId !== ""
}
// Play button overlay
Rectangle {
anchors.centerIn: parent
width: 72; height: 72
radius: 36
color: "#aa000000"
Text {
anchors.centerIn: parent
text: "\u25B6"
font.pixelSize: 36
color: "white"
}
}
// URL label at bottom
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 28
color: "#cc000000"
radius: 4
Text {
anchors.centerIn: parent
width: parent.width - 16
text: videoItem.videoUrl
color: "#ccc"
font.pixelSize: 11
elide: Text.ElideMiddle
horizontalAlignment: Text.AlignHCenter
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: Qt.openUrlExternally(videoItem.videoUrl)
}
}
}
}
Component {
id: interruptSubDelegate
Rectangle {
width: parent ? parent.width : 200
height: interruptCol.implicitHeight + 16
color: theme.isDark ? "#3d2e00" : "#fff8e1"
radius: 4
Rectangle {
width: 3
height: parent.height
color: "#ffc107"
radius: 2
}
ColumnLayout {
id: interruptCol
x: 10
y: 8
width: parent.width - 20
spacing: 4
Label {
text: "\u26A0 user interrupt"
font.pixelSize: Qt.application.font.pixelSize * 0.8
font.bold: true
color: "#ffc107"
}
Text {
Layout.fillWidth: true
text: subBlock.content || ""
wrapMode: Text.Wrap
textFormat: Text.MarkdownText
color: palette.text
font.pointSize: root.font.pointSize
font.family: root.font.family
lineHeight: 1.3
}
}
}
}
Component {
id: codeSubDelegate
Rectangle {
implicitHeight: codeCol.implicitHeight
implicitWidth: Math.max(280, codeCol.implicitWidth)
color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6)
radius: 4
border.width: 1
border.color: palette.mid
ColumnLayout {
id: codeCol
anchors.fill: parent
spacing: 0
// Header with language + copy/exec buttons
RowLayout {
Layout.fillWidth: true
Layout.margins: 4
Label {
text: subBlock.language || "code"
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.6
Layout.fillWidth: true
}
ToolButton {
icon.name: "media-playback-start"
implicitWidth: 32
implicitHeight: 32
visible: {
let lang = (subBlock.language || "").toLowerCase()
return ["bash", "sh", "zsh", "fish", "python", "python3",
"py", "perl", "ruby", "rb", "lua", "node",
"javascript", "js", "php"].indexOf(lang) >= 0
}
onClicked: paneModel.executeInTerminal(subBlock.content, subBlock.language,
dbus.activeCwd)
ToolTip.text: "Execute in terminal"
ToolTip.visible: hovered
}
ToolButton {
icon.name: "edit-copy"
implicitWidth: 32
implicitHeight: 32
onClicked: {
paneModel.copyToClipboard(subBlock.content)
}
ToolTip.text: "Copy"
ToolTip.visible: hovered
}
}
// Code content — show plain first, highlight lazily
TextEdit {
id: codeText
Layout.fillWidth: true
Layout.margins: 8
text: subBlock.content
textFormat: TextEdit.PlainText
wrapMode: TextEdit.Wrap
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.9
color: palette.text
selectionColor: palette.highlight
selectedTextColor: palette.highlightedText
readOnly: true
selectByMouse: true
property bool highlighted: false
Timer {
interval: 150
running: true
onTriggered: {
codeText.text = paneModel.highlightedHtml(subBlock.content, subBlock.language)
codeText.textFormat = TextEdit.RichText
codeText.highlighted = true
}
}
}
}
}
}
Component {
id: diffSubDelegate
Rectangle {
implicitHeight: diffCol.implicitHeight
color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6)
radius: 4
border.width: 1
border.color: palette.mid
ColumnLayout {
id: diffCol
anchors.fill: parent
spacing: 0
RowLayout {
Layout.fillWidth: true
Layout.margins: 4
Label {
text: "diff"
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.6
Layout.fillWidth: true
}
ToolButton {
icon.name: "edit-copy"
implicitWidth: 32
implicitHeight: 32
onClicked: {
paneModel.copyToClipboard(subBlock.content)
}
ToolTip.text: "Copy"
ToolTip.visible: hovered
}
}
// Diff lines with coloring
Column {
Layout.fillWidth: true
Layout.margins: 8
spacing: 0
Repeater {
model: subBlock.content.split("\n")
Rectangle {
width: parent.width
implicitHeight: diffLineText.implicitHeight
color: {
if (modelData.startsWith("+")) return Qt.rgba(0.3, 0.8, 0.3, 0.08)
if (modelData.startsWith("-")) return Qt.rgba(0.8, 0.3, 0.3, 0.08)
return "transparent"
}
Text {
id: diffLineText
width: parent.width
text: modelData
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.9
wrapMode: Text.Wrap
color: {
if (modelData.startsWith("+")) return "#4caf50"
if (modelData.startsWith("-")) return "#f44336"
if (modelData.startsWith("@@")) return "#2196f3"
return palette.text
}
}
}
}
}
}
}
}
Component {
id: diagramSubDelegate
Rectangle {
implicitHeight: diagramCol.implicitHeight
color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6)
radius: 4
border.width: 1
border.color: palette.mid
ColumnLayout {
id: diagramCol
anchors.left: parent.left
anchors.right: parent.right
spacing: 0
// Rendered diagram image (white background for dark theme readability)
Rectangle {
Layout.fillWidth: true
Layout.margins: 8
Layout.maximumHeight: 600
implicitHeight: diagramImg.implicitHeight > 0 ? Math.min(diagramImg.implicitHeight, 600) : 100
color: "#ffffff"
radius: 4
Image {
id: diagramImg
anchors.fill: parent
anchors.margins: 4
fillMode: Image.PreserveAspectFit
source: paneModel.diagramUrl(subBlock.content || "", subBlock.language || "")
asynchronous: true
cache: false // we manage caching ourselves
// Loading indicator
BusyIndicator {
anchors.centerIn: parent
running: diagramImg.status === Image.Loading
visible: running
}
// Error fallback
Label {
anchors.centerIn: parent
text: "\u26A0 Failed to load diagram"
visible: diagramImg.status === Image.Error
color: "#666"
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
cursorShape: Qt.PointingHandCursor
onClicked: function(mouse) {
if (mouse.button === Qt.MiddleButton) {
// Middle-click: invalidate cache and re-render
paneModel.invalidateDiagram(subBlock.content || "", subBlock.language || "")
diagramImg.source = ""
diagramImg.source = paneModel.diagramUrl(subBlock.content || "", subBlock.language || "")
} else {
// Left-click: open fullscreen view
diagramFullscreen.diagramSource = diagramImg.source
diagramFullscreen.visible = true
}
}
}
}
}
// Collapsible source toggle
RowLayout {
Layout.fillWidth: true
Layout.margins: 4
spacing: 4
Label {
id: diagramToggle
property bool expanded: false
text: (expanded ? "\u25BC" : "\u25B6") + " " + (subBlock.language || "source")
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.6
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: diagramToggle.expanded = !diagramToggle.expanded
}
}
Item { Layout.fillWidth: true }
ToolButton {
icon.name: "edit-copy"
implicitWidth: 28
implicitHeight: 28
onClicked: paneModel.copyToClipboard(subBlock.content)
ToolTip.text: "Copy source"
ToolTip.visible: hovered
}
}
// Collapsed source code
TextEdit {
Layout.fillWidth: true
Layout.margins: 8
visible: diagramToggle.expanded
text: subBlock.content || ""
wrapMode: TextEdit.Wrap
textFormat: TextEdit.PlainText
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.85
color: palette.text
selectionColor: palette.highlight
selectedTextColor: palette.highlightedText
readOnly: true
selectByMouse: true
}
}
}
}
// Fullscreen diagram viewer (pannable + zoomable)
Popup {
id: diagramFullscreen
property string diagramSource: ""
parent: Overlay.overlay
anchors.centerIn: parent
width: parent ? parent.width * 0.95 : 800
height: parent ? parent.height * 0.95 : 600
modal: true
dim: true
padding: 0
background: Rectangle {
color: "#ffffff"
radius: 8
border.color: palette.mid
border.width: 1
}
// Close on Escape
Shortcut {
sequence: "Escape"
enabled: diagramFullscreen.visible
onActivated: diagramFullscreen.close()
}
// Close button
ToolButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 8
z: 10
icon.name: "window-close"
onClicked: diagramFullscreen.close()
}
// Zoom reset button
ToolButton {
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: 8
z: 10
text: Math.round(diagramZoomImg.zoomFactor * 100) + "%"
font.pixelSize: Qt.application.font.pixelSize * 0.8
onClicked: {
diagramZoomImg.zoomFactor = 1.0
diagramFlick.contentX = 0
diagramFlick.contentY = 0
}
ToolTip.text: "Reset zoom"
ToolTip.visible: hovered
}
Flickable {
id: diagramFlick
anchors.fill: parent
anchors.margins: 8
clip: true
contentWidth: diagramZoomImg.width
contentHeight: diagramZoomImg.height
boundsBehavior: Flickable.StopAtBounds
Image {
id: diagramZoomImg
source: diagramFullscreen.diagramSource
fillMode: Image.PreserveAspectFit
cache: false
property real zoomFactor: 1.0
// sourceSize gives us intrinsic dimensions; scale from Flickable size
width: Math.max(diagramFlick.width, diagramFlick.width * zoomFactor)
height: Math.max(diagramFlick.height, diagramFlick.height * zoomFactor)
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton)
diagramFullscreen.close()
}
onDoubleClicked: diagramFullscreen.close()
onWheel: function(wheel) {
let factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87
let newZoom = Math.max(0.25, Math.min(10.0, diagramZoomImg.zoomFactor * factor))
diagramZoomImg.zoomFactor = newZoom
}
}
}
onAboutToShow: {
diagramZoomImg.zoomFactor = 1.0
diagramFlick.contentX = 0
diagramFlick.contentY = 0
}
}
}