2453 lines
92 KiB
QML
2453 lines
92 KiB
QML
import QtQuick 2.15
|
|
import QtQuick.Controls 2.15
|
|
import QtQuick.Layouts 1.15
|
|
import QtQuick.Window 2.15
|
|
import "emojis.js" as Emojis
|
|
|
|
ApplicationWindow {
|
|
id: root
|
|
title: dbus.activeCwd ? "Ollie \u2014 " + dbus.activeCwd : "Ollie"
|
|
width: 900
|
|
height: 600
|
|
visible: true
|
|
|
|
Component.onCompleted: activator.windowOpened()
|
|
|
|
Component.onDestruction: {
|
|
activator.windowClosed()
|
|
if (activator.windowCount === 0
|
|
&& !(theme.closeToTray && systemTrayAvailable)) {
|
|
Qt.quit()
|
|
}
|
|
}
|
|
|
|
onClosing: {
|
|
if (theme.closeToTray && systemTrayAvailable) {
|
|
close.accepted = false
|
|
root.hide()
|
|
}
|
|
}
|
|
|
|
color: palette.window
|
|
font.family: theme.fontFamily || Qt.application.font.family
|
|
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
|
|
|
palette {
|
|
window: theme.windowColor
|
|
windowText: theme.windowTextColor
|
|
base: theme.baseColor
|
|
alternateBase: theme.alternateBaseColor
|
|
text: theme.textColor
|
|
button: theme.buttonColor
|
|
buttonText: theme.buttonTextColor
|
|
highlight: theme.highlightColor
|
|
highlightedText: theme.highlightedTextColor
|
|
toolTipBase: theme.tooltipBaseColor
|
|
toolTipText: theme.tooltipTextColor
|
|
link: theme.linkColor
|
|
mid: theme.midColor
|
|
dark: theme.darkColor
|
|
light: theme.lightColor
|
|
shadow: theme.shadowColor
|
|
}
|
|
|
|
// --- Settings Dialog ---
|
|
Dialog {
|
|
id: settingsDialog
|
|
title: "Settings"
|
|
anchors.centerIn: parent
|
|
width: 380
|
|
modal: true
|
|
standardButtons: Dialog.Close
|
|
|
|
contentItem: ColumnLayout {
|
|
spacing: 16
|
|
Layout.margins: 16
|
|
|
|
Label {
|
|
text: "Color Scheme"
|
|
font.bold: true
|
|
}
|
|
|
|
ComboBox {
|
|
id: schemeCombo
|
|
Layout.fillWidth: true
|
|
model: ["System"].concat(theme.availableSchemes)
|
|
currentIndex: {
|
|
if (theme.mode === "system") return 0
|
|
var idx = theme.availableSchemes.indexOf(theme.mode)
|
|
return idx >= 0 ? idx + 1 : 0
|
|
}
|
|
onActivated: {
|
|
if (currentIndex === 0) {
|
|
theme.mode = "system"
|
|
} else {
|
|
theme.mode = theme.availableSchemes[currentIndex - 1]
|
|
}
|
|
}
|
|
}
|
|
|
|
Label {
|
|
text: theme.isDark ? "Current: dark" : "Current: light"
|
|
opacity: 0.5
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
|
}
|
|
|
|
// --- Font section ---
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: palette.mid
|
|
Layout.topMargin: 8
|
|
}
|
|
|
|
Label {
|
|
text: "Font"
|
|
font.bold: true
|
|
}
|
|
|
|
GridLayout {
|
|
columns: 2
|
|
columnSpacing: 12
|
|
rowSpacing: 8
|
|
Layout.fillWidth: true
|
|
|
|
Label { text: "Family:" }
|
|
ComboBox {
|
|
id: fontFamilyCombo
|
|
Layout.fillWidth: true
|
|
model: theme.availableFamilies
|
|
editable: true
|
|
currentIndex: {
|
|
var fam = theme.fontFamily || Qt.application.font.family
|
|
var idx = model.indexOf(fam)
|
|
return idx >= 0 ? idx : 0
|
|
}
|
|
onActivated: theme.fontFamily = currentText
|
|
onAccepted: theme.fontFamily = editText
|
|
}
|
|
|
|
Label { text: "Style:" }
|
|
ComboBox {
|
|
id: fontStyleCombo
|
|
Layout.fillWidth: true
|
|
model: theme.availableStyles
|
|
currentIndex: {
|
|
var st = theme.fontStyle
|
|
var idx = model.indexOf(st)
|
|
return idx >= 0 ? idx : 0
|
|
}
|
|
onActivated: theme.fontStyle = currentText
|
|
}
|
|
|
|
Label { text: "Size:" }
|
|
SpinBox {
|
|
id: fontSizeSpin
|
|
from: 6
|
|
to: 72
|
|
value: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
|
onValueModified: theme.fontSize = value
|
|
}
|
|
}
|
|
|
|
Button {
|
|
text: "Reset to system font"
|
|
onClicked: theme.resetFont()
|
|
}
|
|
|
|
// --- Close to tray section ---
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: palette.mid
|
|
Layout.topMargin: 8
|
|
}
|
|
|
|
Label {
|
|
text: "System Tray"
|
|
font.bold: true
|
|
}
|
|
|
|
CheckBox {
|
|
text: "Close to tray instead of quitting"
|
|
checked: theme.closeToTray
|
|
onToggled: theme.closeToTray = checked
|
|
enabled: systemTrayAvailable
|
|
}
|
|
|
|
Label {
|
|
text: systemTrayAvailable
|
|
? "Window will hide to the system tray when closed."
|
|
: "System tray is not available."
|
|
opacity: 0.5
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
|
wrapMode: Text.Wrap
|
|
Layout.fillWidth: true
|
|
visible: theme.closeToTray || !systemTrayAvailable
|
|
}
|
|
}
|
|
}
|
|
|
|
function openNewSessionDialog() {
|
|
nameInput.text = ""
|
|
remoteInput.text = ""
|
|
newSessionDialog.backendModel = dbus.listBackends()
|
|
newSessionDialog.agentModel = dbus.listAgents()
|
|
newSessionDialog.cwdField.text = dbus.activeCwd || ""
|
|
newSessionDialog.backendCombo.currentIndex = 0
|
|
newSessionDialog.agentCombo.currentIndex = 0
|
|
newSessionDialog.modelCombo.editText = ""
|
|
// Populate model list for the default (first) backend
|
|
if (newSessionDialog.backendCombo.currentText)
|
|
newSessionDialog.modelCombo.model = dbus.listModelsByBackend(newSessionDialog.backendCombo.currentText)
|
|
newSessionDialog.open()
|
|
}
|
|
|
|
Dialog {
|
|
id: newSessionDialog
|
|
title: "New Session"
|
|
modal: true
|
|
anchors.centerIn: parent
|
|
width: 450
|
|
standardButtons: Dialog.Ok | Dialog.Cancel
|
|
|
|
property alias cwdField: cwdInput
|
|
property alias backendCombo: backendSelect
|
|
property alias agentCombo: agentSelect
|
|
property alias modelCombo: modelSelect
|
|
property var backendModel: []
|
|
property var agentModel: []
|
|
|
|
onAccepted: {
|
|
var cwd = cwdInput.text.trim()
|
|
if (cwd === "") cwd = dbus.activeCwd || ""
|
|
var backend = backendSelect.currentText
|
|
var model = modelSelect.editText.trim() || modelSelect.currentText
|
|
var agent = agentSelect.currentText
|
|
var name = nameInput.text.trim()
|
|
var remote = remoteInput.text.trim()
|
|
dbus.createSession(cwd, backend, model, name, agent, remote)
|
|
}
|
|
|
|
GridLayout {
|
|
columns: 2
|
|
width: parent.width
|
|
columnSpacing: 10
|
|
rowSpacing: 8
|
|
|
|
Label { text: "Name:" }
|
|
TextField {
|
|
id: nameInput
|
|
Layout.fillWidth: true
|
|
placeholderText: "(auto-generated)"
|
|
}
|
|
|
|
Label { text: "Working directory:" }
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
TextField {
|
|
id: cwdInput
|
|
Layout.fillWidth: true
|
|
placeholderText: "/home/user/project"
|
|
}
|
|
Button {
|
|
text: "Browse"
|
|
onClicked: {
|
|
var path = theme.chooseFolder("Choose working directory", cwdInput.text)
|
|
if (path !== "")
|
|
cwdInput.text = path
|
|
}
|
|
}
|
|
}
|
|
|
|
Label { text: "Backend:" }
|
|
ComboBox {
|
|
id: backendSelect
|
|
Layout.fillWidth: true
|
|
model: newSessionDialog.backendModel
|
|
onCurrentTextChanged: {
|
|
if (currentText)
|
|
modelSelect.model = dbus.listModelsByBackend(currentText)
|
|
}
|
|
}
|
|
|
|
Label { text: "Agent:" }
|
|
ComboBox {
|
|
id: agentSelect
|
|
Layout.fillWidth: true
|
|
model: newSessionDialog.agentModel
|
|
currentIndex: 0
|
|
}
|
|
|
|
Label { text: "Model:" }
|
|
ComboBox {
|
|
id: modelSelect
|
|
Layout.fillWidth: true
|
|
editable: true
|
|
editText: ""
|
|
}
|
|
|
|
Label { text: "Remote:" }
|
|
TextField {
|
|
id: remoteInput
|
|
Layout.fillWidth: true
|
|
placeholderText: "user@host (empty = local)"
|
|
}
|
|
}
|
|
}
|
|
|
|
function openCwdDialog() {
|
|
var path = theme.chooseFolder("Change working directory",
|
|
dbus.activeCwd || "")
|
|
if (path !== "")
|
|
dbus.setConfig(dbus.activeSessionId, "cwd", path)
|
|
}
|
|
|
|
// --- Session Graph Window ---
|
|
Window {
|
|
id: graphWindow
|
|
title: "Ollie \u2014 Session Topology"
|
|
width: 700
|
|
height: 500
|
|
visible: false
|
|
color: palette.window
|
|
|
|
Item {
|
|
id: graphRoot
|
|
anchors.fill: parent
|
|
clip: true
|
|
|
|
property var nodePositions: ({})
|
|
property real panX: 0
|
|
property real panY: 0
|
|
property real zoom: 1.0
|
|
property real minZoom: 0.3
|
|
property real maxZoom: 3.0
|
|
|
|
function stateColor(state) {
|
|
if (state === "idle") return "#4caf50"
|
|
if (state && state.startsWith("calling")) return "#ff9800"
|
|
if (state === "thinking") return "#2196f3"
|
|
return "#2196f3"
|
|
}
|
|
|
|
function layoutNodes() {
|
|
var positions = {}
|
|
var count = dbus.sessions.length
|
|
if (count === 0) { nodePositions = positions; graphCanvas.requestPaint(); return }
|
|
|
|
var cx = width / 2
|
|
var cy = height / 2
|
|
var radius = Math.min(width, height) * 0.35
|
|
|
|
if (count === 1) {
|
|
positions[dbus.sessions[0].id] = {x: cx, y: cy}
|
|
} else {
|
|
for (var i = 0; i < count; i++) {
|
|
var angle = (2 * Math.PI * i / count) - Math.PI / 2
|
|
positions[dbus.sessions[i].id] = {
|
|
x: cx + radius * Math.cos(angle),
|
|
y: cy + radius * Math.sin(angle)
|
|
}
|
|
}
|
|
}
|
|
nodePositions = positions
|
|
graphCanvas.requestPaint()
|
|
}
|
|
|
|
Connections {
|
|
target: dbus
|
|
function onSessionsChanged() { graphRoot.layoutNodes() }
|
|
}
|
|
onWidthChanged: layoutNodes()
|
|
onHeightChanged: layoutNodes()
|
|
Component.onCompleted: layoutNodes()
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
property real lastX: 0
|
|
property real lastY: 0
|
|
onPressed: { lastX = mouse.x; lastY = mouse.y }
|
|
onPositionChanged: {
|
|
if (pressed) {
|
|
graphRoot.panX += (mouse.x - lastX)
|
|
graphRoot.panY += (mouse.y - lastY)
|
|
lastX = mouse.x
|
|
lastY = mouse.y
|
|
}
|
|
}
|
|
onWheel: {
|
|
var factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87
|
|
var newZoom = Math.max(graphRoot.minZoom, Math.min(graphRoot.maxZoom, graphRoot.zoom * factor))
|
|
// Zoom toward mouse position
|
|
var mx = wheel.x - graphRoot.panX
|
|
var my = wheel.y - graphRoot.panY
|
|
graphRoot.panX -= mx * (newZoom / graphRoot.zoom - 1)
|
|
graphRoot.panY -= my * (newZoom / graphRoot.zoom - 1)
|
|
graphRoot.zoom = newZoom
|
|
}
|
|
}
|
|
|
|
Item {
|
|
x: graphRoot.panX
|
|
y: graphRoot.panY
|
|
width: parent.width
|
|
height: parent.height
|
|
scale: graphRoot.zoom
|
|
transformOrigin: Item.TopLeft
|
|
|
|
Canvas {
|
|
id: graphCanvas
|
|
anchors.fill: parent
|
|
onPaint: {
|
|
var ctx = getContext("2d")
|
|
ctx.clearRect(0, 0, width, height)
|
|
ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.4)
|
|
ctx.lineWidth = 2
|
|
|
|
for (var i = 0; i < dbus.sessions.length; i++) {
|
|
var sid = dbus.sessions[i].id
|
|
var peers = dbus.peerList(sid)
|
|
var fromPos = graphRoot.nodePositions[sid]
|
|
if (!fromPos) continue
|
|
|
|
for (var j = 0; j < peers.length; j++) {
|
|
var toPos = graphRoot.nodePositions[peers[j]]
|
|
if (!toPos) continue
|
|
|
|
ctx.beginPath()
|
|
ctx.moveTo(fromPos.x, fromPos.y)
|
|
ctx.lineTo(toPos.x, toPos.y)
|
|
ctx.stroke()
|
|
|
|
var dx = toPos.x - fromPos.x
|
|
var dy = toPos.y - fromPos.y
|
|
var len = Math.sqrt(dx * dx + dy * dy)
|
|
if (len < 1) continue
|
|
var ux = dx / len
|
|
var uy = dy / len
|
|
var ax = toPos.x - ux * 30
|
|
var ay = toPos.y - uy * 30
|
|
var arrowSize = 8
|
|
ctx.beginPath()
|
|
ctx.moveTo(ax, ay)
|
|
ctx.lineTo(ax - arrowSize * ux + arrowSize * 0.5 * uy,
|
|
ay - arrowSize * uy - arrowSize * 0.5 * ux)
|
|
ctx.lineTo(ax - arrowSize * ux - arrowSize * 0.5 * uy,
|
|
ay - arrowSize * uy + arrowSize * 0.5 * ux)
|
|
ctx.closePath()
|
|
ctx.fillStyle = Qt.rgba(0.5, 0.5, 0.5, 0.6)
|
|
ctx.fill()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
model: dbus.sessions
|
|
Rectangle {
|
|
id: graphNode
|
|
width: 120
|
|
height: 50
|
|
radius: 8
|
|
color: palette.base
|
|
border.color: graphRoot.stateColor(modelData.state)
|
|
border.width: modelData.id === dbus.activeSessionId ? 3 : 2
|
|
x: {
|
|
var pos = graphRoot.nodePositions[modelData.id]
|
|
return pos ? pos.x - width / 2 : 0
|
|
}
|
|
y: {
|
|
var pos = graphRoot.nodePositions[modelData.id]
|
|
return pos ? pos.y - height / 2 : 0
|
|
}
|
|
|
|
SequentialAnimation on opacity {
|
|
running: modelData.state !== "idle"
|
|
loops: Animation.Infinite
|
|
NumberAnimation { to: 0.7; duration: 600; easing.type: Easing.InOutSine }
|
|
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutSine }
|
|
}
|
|
|
|
Column {
|
|
anchors.centerIn: parent
|
|
spacing: 2
|
|
Label {
|
|
text: modelData.id
|
|
font.bold: true
|
|
font.pixelSize: 11
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
elide: Text.ElideRight
|
|
width: graphNode.width - 12
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
Label {
|
|
text: modelData.state || "idle"
|
|
font.pixelSize: 9
|
|
opacity: 0.7
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
color: graphRoot.stateColor(modelData.state)
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: dbus.activeSessionId = modelData.id
|
|
cursorShape: Qt.PointingHandCursor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Label {
|
|
anchors.centerIn: parent
|
|
text: "No active sessions"
|
|
opacity: 0.5
|
|
visible: dbus.sessions.length === 0
|
|
}
|
|
}
|
|
}
|
|
|
|
SplitView {
|
|
anchors.fill: parent
|
|
orientation: Qt.Horizontal
|
|
|
|
// --- Session sidebar ---
|
|
Pane {
|
|
SplitView.preferredWidth: 220
|
|
SplitView.minimumWidth: 160
|
|
padding: 0
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
// Toolbar
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.margins: 6
|
|
|
|
Label {
|
|
text: "Sessions"
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
ToolButton {
|
|
icon.name: "list-add"
|
|
onClicked: root.openNewSessionDialog()
|
|
ToolTip.text: "New session"
|
|
ToolTip.visible: hovered
|
|
}
|
|
|
|
ToolButton {
|
|
icon.name: "configure"
|
|
onClicked: settingsDialog.open()
|
|
ToolTip.text: "Settings"
|
|
ToolTip.visible: hovered
|
|
}
|
|
|
|
ToolButton {
|
|
icon.name: "network-workgroup"
|
|
onClicked: graphWindow.visible = !graphWindow.visible
|
|
ToolTip.text: "Session topology"
|
|
ToolTip.visible: hovered
|
|
}
|
|
}
|
|
|
|
// Session list
|
|
ListView {
|
|
id: sessionList
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
clip: true
|
|
model: dbus.sessions
|
|
|
|
delegate: ItemDelegate {
|
|
id: sessionDelegate
|
|
width: sessionList.width
|
|
highlighted: modelData.id === dbus.activeSessionId
|
|
|
|
property bool renaming: false
|
|
property string sessionState: modelData.state
|
|
|
|
Connections {
|
|
target: dbus
|
|
function onSessionStateUpdated(sessionId, newState) {
|
|
if (sessionId === modelData.id)
|
|
sessionDelegate.sessionState = newState
|
|
}
|
|
}
|
|
|
|
background: Rectangle {
|
|
color: sessionDelegate.highlighted
|
|
? palette.highlight : "transparent"
|
|
radius: 4
|
|
}
|
|
|
|
contentItem: RowLayout {
|
|
spacing: 6
|
|
|
|
Rectangle {
|
|
width: 8
|
|
height: 8
|
|
radius: 4
|
|
color: {
|
|
var state = sessionDelegate.sessionState
|
|
if (state === "idle") return "#4caf50"
|
|
if (state.startsWith("calling")) return "#ff9800"
|
|
return "#2196f3"
|
|
}
|
|
}
|
|
|
|
Label {
|
|
id: sessionLabel
|
|
text: modelData.id
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
font.family: "monospace"
|
|
color: sessionDelegate.highlighted
|
|
? palette.highlightedText : palette.text
|
|
visible: !sessionDelegate.renaming
|
|
}
|
|
|
|
TextField {
|
|
id: renameField
|
|
Layout.fillWidth: true
|
|
font.family: "monospace"
|
|
visible: sessionDelegate.renaming
|
|
onAccepted: {
|
|
var newName = text.trim()
|
|
if (newName !== "" && newName !== modelData.id) {
|
|
dbus.renameSession(modelData.id, newName)
|
|
}
|
|
sessionDelegate.renaming = false
|
|
}
|
|
Keys.onEscapePressed: sessionDelegate.renaming = false
|
|
onVisibleChanged: {
|
|
if (visible) {
|
|
text = modelData.id
|
|
selectAll()
|
|
forceActiveFocus()
|
|
}
|
|
}
|
|
onActiveFocusChanged: {
|
|
if (!activeFocus && sessionDelegate.renaming) {
|
|
sessionDelegate.renaming = false
|
|
}
|
|
}
|
|
}
|
|
|
|
Label {
|
|
text: modelData.agent
|
|
opacity: 0.6
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
|
color: sessionDelegate.highlighted
|
|
? palette.highlightedText : palette.text
|
|
visible: !sessionDelegate.renaming
|
|
}
|
|
|
|
ToolButton {
|
|
icon.name: "process-stop"
|
|
implicitWidth: 24
|
|
implicitHeight: 24
|
|
visible: !sessionDelegate.renaming
|
|
onClicked: dbus.killSession(modelData.id)
|
|
ToolTip.text: "Kill session"
|
|
ToolTip.visible: hovered
|
|
}
|
|
}
|
|
|
|
onClicked: dbus.activeSessionId = modelData.id
|
|
onDoubleClicked: sessionDelegate.renaming = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Main content: tiling pane area ---
|
|
Pane {
|
|
id: mainContent
|
|
SplitView.fillWidth: true
|
|
padding: 0
|
|
|
|
property int focusedPaneIndex: 0
|
|
property var panes: []
|
|
property int paneCount: 0
|
|
|
|
// BSP tree: each node is {pane: item} or {split: 'h'|'v', left: node, right: node}
|
|
property var bspTree: null
|
|
property real gap: 2
|
|
|
|
function layoutTree(node, x, y, w, h) {
|
|
if (!node) return
|
|
if (node.pane) {
|
|
node.pane.x = x
|
|
node.pane.y = y
|
|
node.pane.width = w
|
|
node.pane.height = h
|
|
} else if (node.split) {
|
|
if (node.split === 'h') {
|
|
var lw = (w - gap) / 2
|
|
layoutTree(node.left, x, y, lw, h)
|
|
layoutTree(node.right, x + lw + gap, y, w - lw - gap, h)
|
|
} else {
|
|
var th = (h - gap) / 2
|
|
layoutTree(node.left, x, y, w, th)
|
|
layoutTree(node.right, x, y + th + gap, w, h - th - gap)
|
|
}
|
|
}
|
|
}
|
|
|
|
function relayout() {
|
|
if (bspTree)
|
|
layoutTree(bspTree, 0, 0, width, height)
|
|
}
|
|
|
|
function splitNode(tree, targetPane, newPane, direction) {
|
|
if (!tree) return null
|
|
if (tree.pane === targetPane) {
|
|
return {split: direction, left: {pane: targetPane}, right: {pane: newPane}}
|
|
}
|
|
if (tree.split) {
|
|
var newLeft = splitNode(tree.left, targetPane, newPane, direction)
|
|
if (newLeft) { tree.left = newLeft; return tree }
|
|
var newRight = splitNode(tree.right, targetPane, newPane, direction)
|
|
if (newRight) { tree.right = newRight; return tree }
|
|
}
|
|
return null
|
|
}
|
|
|
|
function removeNode(tree, targetPane) {
|
|
if (!tree) return null
|
|
if (tree.pane === targetPane) return null
|
|
if (tree.split) {
|
|
if (tree.left && tree.left.pane === targetPane) return tree.right
|
|
if (tree.right && tree.right.pane === targetPane) return tree.left
|
|
var newLeft = removeNode(tree.left, targetPane)
|
|
if (newLeft !== tree.left) { tree.left = newLeft; return tree }
|
|
var newRight = removeNode(tree.right, targetPane)
|
|
if (newRight !== tree.right) { tree.right = newRight; return tree }
|
|
}
|
|
return tree
|
|
}
|
|
|
|
onWidthChanged: relayout()
|
|
onHeightChanged: relayout()
|
|
onPaneCountChanged: relayout()
|
|
|
|
// Container for panes (absolute positioning)
|
|
Item {
|
|
id: paneContainer
|
|
anchors.fill: parent
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
var pane = paneCmp.createObject(paneContainer, {sessionId: dbus.activeSessionId})
|
|
panes = [pane]
|
|
paneCount = 1
|
|
bspTree = {pane: pane}
|
|
relayout()
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Window shortcuts ---
|
|
Shortcut {
|
|
sequence: "Ctrl+Q"
|
|
onActivated: Qt.quit()
|
|
}
|
|
|
|
Shortcut {
|
|
sequence: "Ctrl+N"
|
|
onActivated: activator.NewWindow()
|
|
}
|
|
|
|
// --- Split pane shortcuts ---
|
|
Shortcut {
|
|
sequence: "Ctrl+\\"
|
|
onActivated: {
|
|
var focusedPane = mainContent.panes[mainContent.focusedPaneIndex]
|
|
if (!focusedPane) return
|
|
|
|
var pane = paneCmp.createObject(paneContainer, {
|
|
sessionId: dbus.activeSessionId,
|
|
isFocused: false
|
|
})
|
|
if (!pane) return
|
|
|
|
var dir = focusedPane.width >= focusedPane.height ? 'h' : 'v'
|
|
mainContent.bspTree = mainContent.splitNode(mainContent.bspTree, focusedPane, pane, dir)
|
|
|
|
var panes = mainContent.panes
|
|
var insertAt = mainContent.focusedPaneIndex + 1
|
|
panes.splice(insertAt, 0, pane)
|
|
mainContent.panes = panes
|
|
mainContent.paneCount = panes.length
|
|
mainContent.focusedPaneIndex = insertAt
|
|
mainContent.relayout()
|
|
}
|
|
}
|
|
|
|
Shortcut {
|
|
sequence: "Ctrl+W"
|
|
onActivated: {
|
|
if (mainContent.panes.length <= 1) return
|
|
var idx = mainContent.focusedPaneIndex
|
|
if (idx < 0 || idx >= mainContent.panes.length)
|
|
idx = mainContent.panes.length - 1
|
|
var pane = mainContent.panes[idx]
|
|
|
|
mainContent.bspTree = mainContent.removeNode(mainContent.bspTree, pane)
|
|
|
|
var panes = mainContent.panes
|
|
panes.splice(idx, 1)
|
|
mainContent.panes = panes
|
|
mainContent.paneCount = panes.length
|
|
pane.destroy()
|
|
mainContent.focusedPaneIndex = Math.min(idx, panes.length - 1)
|
|
mainContent.relayout()
|
|
}
|
|
}
|
|
|
|
Shortcut {
|
|
sequence: "Ctrl+F"
|
|
onActivated: {
|
|
var pane = mainContent.panes[mainContent.focusedPaneIndex]
|
|
if (!pane) return
|
|
var fb = pane.children[0] // ColumnLayout
|
|
// Toggle via the filterBar id inside the pane
|
|
// Since filterBar is inside the Component, access via pane property
|
|
pane.toggleFilter()
|
|
}
|
|
}
|
|
|
|
// Handle pane swap: find target pane at drop coordinates
|
|
function handlePaneSwap(sourcePane, rootX, rootY) {
|
|
for (var i = 0; i < mainContent.panes.length; i++) {
|
|
var p = mainContent.panes[i]
|
|
if (p === sourcePane) continue
|
|
var localPt = p.mapFromItem(null, rootX, rootY)
|
|
if (localPt.x >= 0 && localPt.x <= p.width &&
|
|
localPt.y >= 0 && localPt.y <= p.height) {
|
|
var tmp = sourcePane.sessionId
|
|
sourcePane.sessionId = p.sessionId
|
|
p.sessionId = tmp
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sidebar click updates focused pane's session
|
|
Connections {
|
|
target: dbus
|
|
function onActiveSessionIdChanged() {
|
|
var idx = mainContent.focusedPaneIndex
|
|
if (idx >= 0 && idx < mainContent.panes.length) {
|
|
mainContent.panes[idx].sessionId = dbus.activeSessionId
|
|
}
|
|
}
|
|
function onErrorOccurred(message) {
|
|
errorDialog.text = message
|
|
errorDialog.open()
|
|
}
|
|
}
|
|
|
|
Dialog {
|
|
id: errorDialog
|
|
title: "Error"
|
|
modal: true
|
|
anchors.centerIn: parent
|
|
standardButtons: Dialog.Ok
|
|
property alias text: errorLabel.text
|
|
Label {
|
|
id: errorLabel
|
|
wrapMode: Text.Wrap
|
|
width: parent.width
|
|
}
|
|
}
|
|
|
|
// --- Inline ChatPane Component ---
|
|
Component {
|
|
id: paneCmp
|
|
|
|
Item {
|
|
id: paneRoot
|
|
|
|
property string sessionId: ""
|
|
property bool isFocused: false
|
|
|
|
signal startedDrag()
|
|
signal endedDrag(real globalX, real globalY)
|
|
|
|
// Pane-local chat model
|
|
property var paneModel: chatModel // fallback; replaced on creation
|
|
property string paneState: "idle"
|
|
property string paneCwd: ""
|
|
property int chatOffset: 0
|
|
|
|
// Per-session draft text storage
|
|
property var _drafts: ({})
|
|
property string _prevSessionId: ""
|
|
|
|
Component.onCompleted: {
|
|
// Create a dedicated ChatBlockModel for this pane
|
|
paneModel = Qt.createQmlObject(
|
|
'import org.ollie.gui 1.0; ChatBlockModel {}', paneRoot, "paneModel")
|
|
if (sessionId !== "") refreshPaneChat()
|
|
}
|
|
|
|
function toggleFilter() {
|
|
filterBar.visible = !filterBar.visible
|
|
if (filterBar.visible) {
|
|
filterInput.forceActiveFocus()
|
|
filterInput.selectAll()
|
|
} else {
|
|
paneModel.filterText = ""
|
|
filterInput.text = ""
|
|
}
|
|
}
|
|
|
|
function refreshPaneChat() {
|
|
paneModel.rawText = ""
|
|
chatOffset = 0
|
|
paneState = "idle"
|
|
paneCwd = ""
|
|
if (sessionId !== "") {
|
|
var chat = dbus.getChat(sessionId, 0)
|
|
if (chat) {
|
|
paneModel.rawText = chat.text
|
|
chatOffset = chat.offset
|
|
|
|
}
|
|
var cfg = dbus.getConfig(sessionId)
|
|
if (cfg && cfg.cwd) paneCwd = cfg.cwd
|
|
var st = dbus.getState(sessionId)
|
|
if (st) paneState = st
|
|
}
|
|
}
|
|
|
|
onSessionIdChanged: {
|
|
// Save draft from previous session
|
|
if (_prevSessionId !== "") {
|
|
_drafts[_prevSessionId] = panePrompt.text
|
|
}
|
|
refreshPaneChat()
|
|
// Restore draft for new session (or empty)
|
|
if (sessionId !== "") {
|
|
panePrompt.text = _drafts[sessionId] || ""
|
|
} else {
|
|
panePrompt.text = ""
|
|
}
|
|
_prevSessionId = sessionId
|
|
}
|
|
|
|
Connections {
|
|
target: dbus
|
|
function onChatAppended(sid, offset, text) {
|
|
if (sid !== paneRoot.sessionId) return
|
|
if (offset === paneRoot.chatOffset) {
|
|
paneRoot.paneModel.rawText = paneRoot.paneModel.rawText + text
|
|
paneRoot.chatOffset += text.length
|
|
} else {
|
|
var chat = dbus.getChat(paneRoot.sessionId, 0)
|
|
if (chat) {
|
|
paneRoot.paneModel.rawText = chat.text
|
|
paneRoot.chatOffset = chat.offset
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
Rectangle {
|
|
id: paneHeader
|
|
Layout.fillWidth: true
|
|
implicitHeight: headerRow.implicitHeight + 12
|
|
color: "transparent"
|
|
|
|
MouseArea {
|
|
id: paneHeaderDrag
|
|
anchors.fill: parent
|
|
property bool dragging: false
|
|
cursorShape: dragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor
|
|
|
|
onPositionChanged: {
|
|
if (pressed && !dragging) {
|
|
dragging = true
|
|
paneRoot.startedDrag()
|
|
}
|
|
}
|
|
onReleased: {
|
|
if (dragging) {
|
|
var rootPt = paneHeaderDrag.mapToItem(null, mouse.x, mouse.y)
|
|
paneRoot.endedDrag(rootPt.x, rootPt.y)
|
|
}
|
|
dragging = false
|
|
}
|
|
}
|
|
|
|
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: paneRoot.paneModel.filterText = text
|
|
Keys.onEscapePressed: {
|
|
filterBar.visible = false
|
|
paneRoot.paneModel.filterText = ""
|
|
text = ""
|
|
}
|
|
}
|
|
|
|
Label {
|
|
text: paneRoot.paneModel.filtering ? paneRoot.paneModel.rowCount() + " matches" : ""
|
|
opacity: 0.6
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
|
}
|
|
|
|
ToolButton {
|
|
icon.name: "window-close"
|
|
onClicked: {
|
|
filterBar.visible = false
|
|
paneRoot.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
|
|
var bottom = contentHeight - height
|
|
parent.atBottom = (bottom <= 0 || contentY >= bottom - 4)
|
|
|
|
// Lazy-load earlier blocks when near top
|
|
if (contentY < 80 && contentY >= 0 && !_settingY) {
|
|
var oldHeight = contentHeight
|
|
if (paneRoot.paneModel.loadEarlier()) {
|
|
_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: {
|
|
paneFlick._settingY = true
|
|
paneFlick.contentY = Math.max(0,
|
|
Math.min(paneFlick.contentY - wheel.angleDelta.y,
|
|
paneFlick.contentHeight - paneFlick.height))
|
|
paneFlick._settingY = false
|
|
var bottom = paneFlick.contentHeight - paneFlick.height
|
|
paneFlick.parent.atBottom = (bottom <= 0 || paneFlick.contentY >= bottom - 4)
|
|
}
|
|
}
|
|
|
|
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: theme.placeholderTextColor
|
|
}
|
|
|
|
Repeater {
|
|
id: paneChatRepeater
|
|
model: paneRoot.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
|
|
}
|
|
ToolTip.text: "Scroll to bottom"
|
|
ToolTip.visible: hovered
|
|
}
|
|
}
|
|
|
|
Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid }
|
|
|
|
// Prompt input
|
|
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", ":p": "\uD83D\uDE1B",
|
|
"<3": "\u2764\uFE0F", ":O": "\uD83D\uDE2E",
|
|
":o": "\uD83D\uDE2E", "B)": "\uD83D\uDE0E",
|
|
":*": "\uD83D\uDE18", "XD": "\uD83D\uDE06",
|
|
"xD": "\uD83D\uDE06", ":|": "\uD83D\uDE10",
|
|
">:(": "\uD83D\uDE20", ":'(": "\uD83D\uDE22"
|
|
})
|
|
property bool _replacing: false
|
|
|
|
function emojiQuery() {
|
|
var pos = cursorPosition
|
|
var t = text
|
|
for (var i = pos - 1; i >= Math.max(0, pos - 20); i--) {
|
|
if (t[i] === ':') {
|
|
var 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) {
|
|
var q = emojiQuery()
|
|
if (!q) return
|
|
_replacing = true
|
|
var before = text.substring(0, q.start)
|
|
var after = text.substring(cursorPosition)
|
|
text = before + emoji + after
|
|
cursorPosition = before.length + emoji.length
|
|
_replacing = false
|
|
emojiPopup.visible = false
|
|
}
|
|
|
|
onTextChanged: {
|
|
if (_replacing) return
|
|
var pos = cursorPosition
|
|
|
|
// Quick emoticons
|
|
for (var len = 2; len <= Math.min(4, pos); len++) {
|
|
var candidate = text.substring(pos - len, pos)
|
|
if (quickEmojis[candidate] !== undefined) {
|
|
_replacing = true
|
|
var before = text.substring(0, pos - len)
|
|
var after = text.substring(pos)
|
|
var emoji = quickEmojis[candidate]
|
|
text = before + emoji + after
|
|
cursorPosition = before.length + emoji.length
|
|
_replacing = false
|
|
return
|
|
}
|
|
}
|
|
|
|
// :shortcode: completion popup
|
|
var q = emojiQuery()
|
|
if (q) {
|
|
var matches = Emojis.emojiList.filter(function(e) {
|
|
return e.code.indexOf(q.query) === 0
|
|
}).slice(0, 8)
|
|
if (matches.length > 0) {
|
|
emojiModel.clear()
|
|
for (var mi = 0; mi < matches.length; mi++)
|
|
emojiModel.append({code: matches[mi].code, emoji: matches[mi].emoji})
|
|
emojiPopup.visible = true
|
|
emojiList.currentIndex = 0
|
|
} else {
|
|
emojiPopup.visible = false
|
|
}
|
|
} else {
|
|
emojiPopup.visible = false
|
|
}
|
|
}
|
|
|
|
Keys.onReturnPressed: {
|
|
if (emojiPopup.visible && emojiModel.count > 0) {
|
|
var 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: {
|
|
if (event.key === Qt.Key_J && (event.modifiers & Qt.ControlModifier)) {
|
|
panePrompt.insert(panePrompt.cursorPosition, "\n")
|
|
event.accepted = true
|
|
} else 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_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Tab) {
|
|
if (emojiModel.count > 0) {
|
|
var eItem = emojiModel.get(emojiList.currentIndex)
|
|
applyEmoji(eItem.emoji, eItem.code)
|
|
event.accepted = true
|
|
}
|
|
} else if (event.key === Qt.Key_Escape) {
|
|
emojiPopup.visible = false
|
|
event.accepted = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: {
|
|
var 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"
|
|
}
|
|
}
|
|
|
|
// Click-to-focus: update focused pane index on click
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.LeftButton
|
|
propagateComposedEvents: true
|
|
onPressed: {
|
|
mouse.accepted = false
|
|
for (var i = 0; i < mainContent.panes.length; i++) {
|
|
if (mainContent.panes[i] === paneRoot) {
|
|
mainContent.focusedPaneIndex = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ctrl+L focuses the prompt input (like browser URL bar)
|
|
Shortcut {
|
|
sequence: "Ctrl+L"
|
|
onActivated: panePrompt.forceActiveFocus()
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Chat block delegates ---
|
|
|
|
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: chatModel.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: chatModel.copyToClipboard(block.blockContent)
|
|
ToolTip.text: "Copy"
|
|
ToolTip.visible: hovered
|
|
|
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
property bool expanded: false
|
|
|
|
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: chatModel.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: (expanded ? "\u25BC" : "\u25B6") + " \u26A1 call: " + (block ? (block.blockName || "") : "")
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.8
|
|
color: "#ff9800"
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: expanded = !expanded
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
model: expanded && 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: chatModel.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 {
|
|
width: parent ? parent.width : 100
|
|
text: chatModel.linkifyFilePaths(subBlock.content || "")
|
|
wrapMode: TextEdit.Wrap
|
|
textFormat: TextEdit.MarkdownText
|
|
color: palette.text
|
|
selectionColor: palette.highlight
|
|
selectedTextColor: palette.highlightedText
|
|
readOnly: true
|
|
selectByMouse: true
|
|
font.pointSize: root.font.pointSize
|
|
font.family: root.font.family
|
|
onLinkActivated: chatModel.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: 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: 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: {
|
|
var m = videoUrl.match(/[?&]v=([^&]+)/) || videoUrl.match(/youtu\.be\/([^?]+)/)
|
|
return m ? m[1] : ""
|
|
}
|
|
|
|
// Non-direct: thumbnail card that opens in browser
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
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: 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
|
|
|
|
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: {
|
|
var lang = (subBlock.language || "").toLowerCase()
|
|
return ["bash", "sh", "zsh", "fish", "python", "python3",
|
|
"py", "perl", "ruby", "rb", "lua", "node",
|
|
"javascript", "js", "php"].indexOf(lang) >= 0
|
|
}
|
|
onClicked: chatModel.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: chatModel.copyToClipboard(subBlock.content)
|
|
ToolTip.text: "Copy"
|
|
ToolTip.visible: hovered
|
|
}
|
|
}
|
|
|
|
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 = chatModel.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: chatModel.copyToClipboard(subBlock.content)
|
|
ToolTip.text: "Copy"
|
|
ToolTip.visible: hovered
|
|
}
|
|
}
|
|
|
|
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: chatModel.diagramUrl(subBlock.content || "", subBlock.language || "")
|
|
asynchronous: true
|
|
cache: false
|
|
|
|
BusyIndicator {
|
|
anchors.centerIn: parent
|
|
running: diagramImg.status === Image.Loading
|
|
visible: running
|
|
}
|
|
|
|
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) {
|
|
chatModel.invalidateDiagram(subBlock.content || "", subBlock.language || "")
|
|
diagramImg.source = ""
|
|
diagramImg.source = chatModel.diagramUrl(subBlock.content || "", subBlock.language || "")
|
|
} else {
|
|
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: chatModel.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
|
|
}
|
|
|
|
Shortcut {
|
|
sequence: "Escape"
|
|
enabled: diagramFullscreen.visible
|
|
onActivated: diagramFullscreen.close()
|
|
}
|
|
|
|
ToolButton {
|
|
anchors.top: parent.top
|
|
anchors.right: parent.right
|
|
anchors.margins: 8
|
|
z: 10
|
|
icon.name: "window-close"
|
|
onClicked: diagramFullscreen.close()
|
|
}
|
|
|
|
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
|
|
width: Math.max(diagramFlick.width, diagramFlick.width * zoomFactor)
|
|
height: Math.max(diagramFlick.height, diagramFlick.height * zoomFactor)
|
|
}
|
|
}
|
|
|
|
// Overlay for wheel zoom and right-click close (outside Flickable to avoid drag interference)
|
|
MouseArea {
|
|
anchors.fill: diagramFlick
|
|
acceptedButtons: Qt.RightButton
|
|
propagateComposedEvents: true
|
|
onClicked: function(mouse) {
|
|
if (mouse.button === Qt.RightButton)
|
|
diagramFullscreen.close()
|
|
}
|
|
onWheel: function(wheel) {
|
|
var factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87
|
|
var 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
|
|
}
|
|
}
|
|
|
|
// --- Process Manager Window ---
|
|
Window {
|
|
id: processManagerWindow
|
|
title: "Ollie \u2014 Detached Processes"
|
|
width: 600
|
|
height: 400
|
|
color: palette.window
|
|
|
|
property var processes: []
|
|
property int selectedPid: -1
|
|
property string selectedOutput: ""
|
|
|
|
Timer {
|
|
id: procRefreshTimer
|
|
interval: 1500
|
|
running: processManagerWindow.visible
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: {
|
|
if (dbus.activeSessionId === "") return
|
|
processManagerWindow.processes = dbus.listDetached(dbus.activeSessionId)
|
|
if (processManagerWindow.selectedPid > 0) {
|
|
processManagerWindow.selectedOutput =
|
|
dbus.getDetachedOutput(dbus.activeSessionId, processManagerWindow.selectedPid)
|
|
}
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
anchors.margins: 8
|
|
spacing: 8
|
|
|
|
Label {
|
|
text: "Session: " + (dbus.activeSessionId || "none")
|
|
font.bold: true
|
|
}
|
|
|
|
ListView {
|
|
id: procList
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 140
|
|
clip: true
|
|
model: processManagerWindow.processes
|
|
|
|
delegate: ItemDelegate {
|
|
width: procList.width
|
|
highlighted: modelData.pid === processManagerWindow.selectedPid
|
|
|
|
contentItem: RowLayout {
|
|
spacing: 8
|
|
|
|
Rectangle {
|
|
width: 8; height: 8; radius: 4
|
|
color: modelData.status === "running" ? "#4caf50" : "#9e9e9e"
|
|
}
|
|
|
|
Label {
|
|
text: modelData.command
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
font.family: "monospace"
|
|
}
|
|
|
|
Label {
|
|
text: "pid " + modelData.pid
|
|
opacity: 0.6
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
|
}
|
|
|
|
Label {
|
|
text: modelData.status
|
|
opacity: 0.6
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
|
}
|
|
|
|
Button {
|
|
text: "Term"
|
|
visible: modelData.status === "running"
|
|
onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "TERM")
|
|
}
|
|
|
|
Button {
|
|
text: "Kill"
|
|
visible: modelData.status === "running"
|
|
onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "KILL")
|
|
}
|
|
|
|
Button {
|
|
text: "Dismiss"
|
|
visible: modelData.status !== "running"
|
|
onClicked: dbus.dismissDetached(dbus.activeSessionId, modelData.pid)
|
|
}
|
|
}
|
|
|
|
onClicked: {
|
|
processManagerWindow.selectedPid = modelData.pid
|
|
processManagerWindow.selectedOutput =
|
|
dbus.getDetachedOutput(dbus.activeSessionId, modelData.pid)
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: palette.mid
|
|
}
|
|
|
|
RowLayout {
|
|
spacing: 8
|
|
Label {
|
|
text: processManagerWindow.selectedPid > 0
|
|
? "\u2014\u2014\u2014 Output: pid " + processManagerWindow.selectedPid + " \u2014\u2014\u2014"
|
|
: "Select a process to view output"
|
|
opacity: 0.6
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
|
Layout.fillWidth: true
|
|
}
|
|
Button {
|
|
text: "Copy"
|
|
visible: processManagerWindow.selectedPid > 0
|
|
onClicked: {
|
|
outputArea.selectAll()
|
|
outputArea.copy()
|
|
outputArea.deselect()
|
|
}
|
|
}
|
|
}
|
|
|
|
Flickable {
|
|
id: outputFlick
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: outputArea.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
property bool atBottom: true
|
|
|
|
onContentYChanged: {
|
|
var bottom = contentHeight - height
|
|
atBottom = (bottom <= 0 || contentY >= bottom - 4)
|
|
}
|
|
|
|
onContentHeightChanged: {
|
|
if (atBottom) {
|
|
contentY = Math.max(0, contentHeight - height)
|
|
}
|
|
}
|
|
|
|
TextArea {
|
|
id: outputArea
|
|
width: outputFlick.width
|
|
readOnly: true
|
|
font.family: "monospace"
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.9
|
|
text: processManagerWindow.selectedOutput
|
|
wrapMode: TextEdit.Wrap
|
|
background: null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|