1282 lines
49 KiB
QML
1282 lines
49 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
import "emojis.js" as Emojis
|
|
|
|
ApplicationWindow {
|
|
id: root
|
|
title: dbus.activeCwd ? "Ollie — " + dbus.activeCwd : "Ollie"
|
|
width: 900
|
|
height: 600
|
|
visible: true
|
|
|
|
onClosing: function(close) {
|
|
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
|
|
placeholderText: theme.placeholderTextColor
|
|
}
|
|
|
|
// --- 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
|
|
let 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: {
|
|
let fam = theme.fontFamily || Qt.application.font.family
|
|
let 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: {
|
|
let st = theme.fontStyle
|
|
let 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() {
|
|
let path = theme.chooseFolder("Choose working directory for new session")
|
|
if (path !== "")
|
|
dbus.createSession(path)
|
|
}
|
|
|
|
function openCwdDialog() {
|
|
let path = theme.chooseFolder("Change working directory",
|
|
dbus.activeCwd || "")
|
|
if (path !== "")
|
|
dbus.setConfig(dbus.activeSessionId, "cwd", path)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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: {
|
|
let 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: {
|
|
let 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 ---
|
|
Pane {
|
|
SplitView.fillWidth: true
|
|
padding: 0
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
// Header bar
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.margins: 8
|
|
|
|
Rectangle {
|
|
width: 10
|
|
height: 10
|
|
radius: 5
|
|
color: {
|
|
if (dbus.sessions.length === 0) return "gray"
|
|
let state = dbus.activeState
|
|
if (state === "idle") return "#4caf50"
|
|
if (state.startsWith("calling")) return "#ff9800"
|
|
return "#2196f3"
|
|
}
|
|
}
|
|
|
|
Label {
|
|
text: dbus.activeCwd || "No session selected"
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideMiddle
|
|
font.bold: true
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onDoubleClicked: {
|
|
if (dbus.activeSessionId !== "") {
|
|
root.openCwdDialog()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Label {
|
|
text: {
|
|
let parts = []
|
|
if (dbus.activeBackend) parts.push(dbus.activeBackend)
|
|
if (dbus.activeModel) parts.push(dbus.activeModel)
|
|
return parts.join(" / ") || ""
|
|
}
|
|
opacity: 0.5
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
|
visible: text !== ""
|
|
}
|
|
|
|
Label {
|
|
text: dbus.activeState
|
|
opacity: 0.6
|
|
}
|
|
}
|
|
|
|
// Separator
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: palette.mid
|
|
}
|
|
|
|
// Chat view
|
|
// Chat view — structured blocks
|
|
Item {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
|
|
property bool atBottom: true
|
|
|
|
Binding {
|
|
target: chatModel
|
|
property: "rawText"
|
|
value: dbus.chatText || ""
|
|
}
|
|
|
|
Flickable {
|
|
id: chatFlick
|
|
anchors.fill: parent
|
|
anchors.margins: 4
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: chatColumn.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
// Guard: true while we're programmatically setting contentY
|
|
property bool _settingY: false
|
|
// Guard: true while content is streaming (height growing)
|
|
property bool _contentGrowing: false
|
|
|
|
onContentYChanged: {
|
|
if (_settingY) return
|
|
let bottom = contentHeight - height
|
|
parent.atBottom = (bottom <= 0 || contentY >= bottom - 4)
|
|
|
|
// Load earlier messages when scrolled near top
|
|
// Only on user scroll — not during content growth or programmatic scroll
|
|
if (!_contentGrowing && contentY < 100 && contentHeight > height && !parent.atBottom) {
|
|
let oldHeight = contentHeight
|
|
if (chatModel.loadEarlier()) {
|
|
_settingY = true
|
|
Qt.callLater(function() {
|
|
chatFlick.contentY += (chatFlick.contentHeight - oldHeight)
|
|
chatFlick._settingY = false
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Timer-based debounce: coalesce all contentHeight changes within
|
|
// a single frame (~16ms) to avoid rapid scroll/restore flickering
|
|
Timer {
|
|
id: scrollTimer
|
|
interval: 16
|
|
repeat: false
|
|
onTriggered: {
|
|
chatFlick._contentGrowing = false
|
|
if (chatFlick.parent.atBottom) {
|
|
chatFlick._settingY = true
|
|
chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height)
|
|
chatFlick._settingY = false
|
|
}
|
|
}
|
|
}
|
|
|
|
onContentHeightChanged: {
|
|
if (parent.atBottom) {
|
|
_contentGrowing = true
|
|
scrollTimer.restart()
|
|
}
|
|
}
|
|
|
|
// Direct wheel scroll without inertia
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.NoButton
|
|
onWheel: function(wheel) {
|
|
let dy = wheel.angleDelta.y
|
|
chatFlick._settingY = true
|
|
chatFlick.contentY = Math.max(0,
|
|
Math.min(chatFlick.contentY - dy,
|
|
chatFlick.contentHeight - chatFlick.height))
|
|
chatFlick._settingY = false
|
|
// Update atBottom
|
|
let bottom = chatFlick.contentHeight - chatFlick.height
|
|
chatFlick.parent.atBottom = (bottom <= 0 || chatFlick.contentY >= bottom - 4)
|
|
wheel.accepted = true
|
|
}
|
|
}
|
|
|
|
Column {
|
|
id: chatColumn
|
|
width: chatFlick.width
|
|
spacing: 8
|
|
|
|
// Placeholder
|
|
Label {
|
|
visible: chatRepeater.count === 0
|
|
text: dbus.activeSessionId
|
|
? "(waiting for output...)"
|
|
: "Select or create a session"
|
|
color: palette.placeholderText
|
|
}
|
|
|
|
Repeater {
|
|
id: chatRepeater
|
|
model: chatModel
|
|
|
|
delegate: Loader {
|
|
width: chatColumn.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: {
|
|
chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height)
|
|
parent.atBottom = true
|
|
}
|
|
ToolTip.text: "Scroll to bottom"
|
|
ToolTip.visible: hovered
|
|
}
|
|
}
|
|
|
|
// Separator
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: palette.mid
|
|
}
|
|
|
|
// Prompt input
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.margins: 8
|
|
spacing: 6
|
|
|
|
ScrollView {
|
|
Layout.fillWidth: true
|
|
Layout.maximumHeight: 100
|
|
|
|
TextArea {
|
|
id: promptInput
|
|
placeholderText: dbus.activeSessionId
|
|
? "Message..."
|
|
: "No active session"
|
|
enabled: dbus.activeSessionId !== ""
|
|
wrapMode: TextEdit.Wrap
|
|
Keys.onReturnPressed: function(event) {
|
|
if (event.modifiers & (Qt.ShiftModifier | Qt.ControlModifier)) {
|
|
promptInput.insert(promptInput.cursorPosition, "\n")
|
|
} else {
|
|
if (text.trim() !== "" && dbus.activeSessionId !== "") {
|
|
dbus.submit(dbus.activeSessionId, text.trim())
|
|
text = ""
|
|
}
|
|
}
|
|
event.accepted = true
|
|
}
|
|
Keys.onPressed: function(event) {
|
|
if (event.key === Qt.Key_J && (event.modifiers & Qt.ControlModifier)) {
|
|
promptInput.insert(promptInput.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) {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: forceActiveFocus()
|
|
|
|
// Emoji shortcode replacement + completion
|
|
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
|
|
property string _colonPrefix: ""
|
|
|
|
function emojiQuery() {
|
|
let pos = cursorPosition
|
|
let t = text
|
|
// Find `:` before cursor with word chars after
|
|
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
|
|
}
|
|
|
|
onTextChanged: {
|
|
if (_replacing) return
|
|
let pos = cursorPosition
|
|
|
|
// Quick emoticons (instant replace)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Extended :shortcode: completion
|
|
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
|
|
}
|
|
}
|
|
|
|
|
|
}
|
|
}
|
|
|
|
ToolButton {
|
|
icon.name: "media-playback-stop"
|
|
enabled: dbus.activeState !== "idle" && dbus.activeSessionId !== ""
|
|
onClicked: dbus.interrupt(dbus.activeSessionId)
|
|
ToolTip.text: "Interrupt"
|
|
ToolTip.visible: hovered
|
|
}
|
|
// Emoji completion popup
|
|
ListModel { id: emojiModel }
|
|
|
|
Popup {
|
|
id: emojiPopup
|
|
y: -height - 4
|
|
x: 8
|
|
width: 280
|
|
height: Math.min(emojiList.contentHeight + 12, 240)
|
|
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: 32
|
|
highlighted: index === emojiList.currentIndex
|
|
|
|
contentItem: RowLayout {
|
|
spacing: 8
|
|
Label {
|
|
text: model.emoji
|
|
font.pixelSize: 18
|
|
}
|
|
Label {
|
|
text: ":" + model.code + ":"
|
|
Layout.fillWidth: true
|
|
font.family: "monospace"
|
|
opacity: 0.8
|
|
}
|
|
}
|
|
|
|
onClicked: {
|
|
promptInput.applyEmoji(model.emoji, model.code)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Status bar
|
|
Label {
|
|
Layout.fillWidth: true
|
|
Layout.leftMargin: 8
|
|
Layout.bottomMargin: 4
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
|
opacity: 0.5
|
|
text: {
|
|
let id = dbus.activeSessionId
|
|
if (!id) return "ollie"
|
|
return dbus.activeState + " │ " + id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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
|
|
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 === "diff" ? diffSubDelegate
|
|
: subBlock.type === "interrupt" ? interruptSubDelegate
|
|
: 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
|
|
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 === "diff" ? diffSubDelegate
|
|
: subBlock.type === "interrupt" ? interruptSubDelegate
|
|
: 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
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
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 === "diff" ? diffSubDelegate
|
|
: subBlock.type === "interrupt" ? interruptSubDelegate
|
|
: plainSubDelegate
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: toolDelegate
|
|
Item {
|
|
implicitHeight: toolLayout.implicitHeight + 12
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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 === "diff" ? diffSubDelegate
|
|
: subBlock.type === "interrupt" ? interruptSubDelegate
|
|
: 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
|
|
Text {
|
|
width: parent ? parent.width : implicitWidth
|
|
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
|
|
onLinkActivated: function(link) {
|
|
Qt.openUrlExternally(link)
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: plainSubDelegate
|
|
Text {
|
|
width: parent ? parent.width : implicitWidth
|
|
text: subBlock.content || ""
|
|
wrapMode: Text.Wrap
|
|
textFormat: Text.PlainText
|
|
color: palette.text
|
|
font.family: "monospace"
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.9
|
|
lineHeight: 1.3
|
|
}
|
|
}
|
|
|
|
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: chatModel.executeInTerminal(subBlock.content, subBlock.language)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Code content — show plain first, highlight lazily
|
|
Text {
|
|
id: codeText
|
|
Layout.fillWidth: true
|
|
Layout.margins: 8
|
|
text: subBlock.content
|
|
textFormat: Text.PlainText
|
|
wrapMode: Text.Wrap
|
|
font.family: "monospace"
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.9
|
|
color: palette.text
|
|
|
|
property bool highlighted: false
|
|
Timer {
|
|
interval: 150
|
|
running: true
|
|
onTriggered: {
|
|
codeText.text = chatModel.highlightedHtml(subBlock.content, subBlock.language)
|
|
codeText.textFormat = Text.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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|