gui: extract dialogs and chat delegate into components

Phase 1 of GUI cleanup - component extraction:
- SettingsDialog.qml: theme and font settings (118 lines)
- NewSessionDialog.qml: session creation form (79 lines)
- NewAgentDialog.qml: agent creation form (174 lines)
- ChatBlockDelegate.qml: chat block with text/diagram segments (322 lines)

main.qml reduced from 1685 to 1030 lines (39% reduction).
Updated CMakeLists.txt for both Qt6 and KF5 builds.
This commit is contained in:
Levi Neely 2026-08-07 17:40:09 +02:00
parent bdad2b740f
commit 2d6e0ed787
7 changed files with 715 additions and 663 deletions

View File

@ -71,6 +71,11 @@ if(OLLIE_KF5)
file(READ ${CMAKE_CURRENT_SOURCE_DIR}/gui/main.qml _QML_SRC)
string(REGEX REPLACE "[ \t]*(//[^\n]*palette[^\n]*\n|palette\\.(active|inactive|disabled)\\.[^\n]*\n)" "" _QML_SRC "${_QML_SRC}")
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/main.qml "${_QML_SRC}")
# Copy component QML files (no palette stripping needed - they don't use grouped palette)
configure_file(gui/SettingsDialog.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/SettingsDialog.qml COPYONLY)
configure_file(gui/NewSessionDialog.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/NewSessionDialog.qml COPYONLY)
configure_file(gui/NewAgentDialog.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/NewAgentDialog.qml COPYONLY)
configure_file(gui/ChatBlockDelegate.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/ChatBlockDelegate.qml COPYONLY)
configure_file(gui/qml.qrc ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc COPYONLY)
qt5_add_resources(GUI_KF5_QRC ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc)
add_executable(ollie-gui
@ -170,7 +175,12 @@ else()
qt_add_resources(ollie-gui "gui"
PREFIX "/"
BASE gui
FILES gui/main.qml
FILES
gui/main.qml
gui/SettingsDialog.qml
gui/NewSessionDialog.qml
gui/NewAgentDialog.qml
gui/ChatBlockDelegate.qml
)
# --- Install ---

322
gui/ChatBlockDelegate.qml Normal file
View File

@ -0,0 +1,322 @@
// ChatBlockDelegate.qml — Delegate for rendering chat blocks in the ListView
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
ColumnLayout {
id: root
spacing: 2
// Required properties from model
required property string blockType
required property string blockName
required property var blockSegments
// Reference to parent ListView for scroll control
property var chatView: null
// Expand state based on block type
property bool blockExpanded: {
switch (blockType) {
case "user":
case "assistant":
case "tool":
case "info":
case "retry":
case "interrupt":
return true
default:
return false
}
}
// Header color based on block type and theme
function headerColor() {
var bg = theme.baseColor
if (theme.isDark) {
switch (blockType) {
case "user": return Qt.lighter(bg, 1.8)
case "assistant": return Qt.lighter(bg, 1.5)
case "reasoning": return Qt.lighter(bg, 1.3)
case "call": return Qt.lighter(bg, 1.7)
case "tool": return Qt.lighter(bg, 1.2)
case "error": return Qt.rgba(0.6, 0.15, 0.15, 0.7)
case "info": return Qt.lighter(bg, 1.4)
case "retry": return Qt.rgba(0.6, 0.5, 0.0, 0.5)
case "interrupt": return "#3d2e00"
default: return bg
}
} else {
switch (blockType) {
case "user": return Qt.darker(bg, 1.12)
case "assistant": return Qt.darker(bg, 1.06)
case "reasoning": return Qt.darker(bg, 1.03)
case "call": return Qt.darker(bg, 1.1)
case "tool": return Qt.darker(bg, 1.04)
case "error": return Qt.rgba(1.0, 0.8, 0.8, 0.7)
case "info": return Qt.darker(bg, 1.02)
case "retry": return Qt.rgba(1.0, 0.9, 0.6, 0.5)
case "interrupt": return "#fff8e1"
default: return bg
}
}
}
// Header label text based on block type
function headerText() {
switch (blockType) {
case "user": return "\uD83E\uDDD1 You"
case "assistant": return "\uD83E\uDD16 Ollie"
case "reasoning": return "Reasoning"
case "call": return "\u25B6\uFE0F " + blockName
case "tool": return "\uD83D\uDDA5\uFE0F " + blockName
case "error": return "\u274C Error"
case "info": return "\u2139\uFE0F Info"
case "retry": return "Retry"
case "interrupt": return "\u26A0\uFE0F User Interrupt"
default: return ""
}
}
// Clickable header
Rectangle {
Layout.fillWidth: true
height: 22
radius: 3
color: root.headerColor()
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.blockExpanded = !root.blockExpanded
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 6
spacing: 4
Label {
text: root.blockExpanded ? "▼" : "▶"
font.pixelSize: 8
opacity: 0.6
}
Label {
Layout.fillWidth: true
text: root.headerText()
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11
font.bold: true
color: theme.textColor
opacity: 0.9
}
}
}
// Block content rendered as segments
Column {
Layout.fillWidth: true
visible: root.blockExpanded
spacing: 4
Repeater {
model: root.blockSegments || []
delegate: Loader {
width: parent ? parent.width : 0
active: modelData !== null && modelData !== undefined
sourceComponent: (modelData && modelData.type === "diagram") ? diagramComponent : textComponent
property var segmentData: modelData || {}
}
}
}
// Text segment component
Component {
id: textComponent
TextEdit {
id: textEdit
width: parent ? parent.width : 100
text: segmentData.html || ""
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
onActiveFocusChanged: {
if (!activeFocus && root.chatView)
root.chatView.interactive = true
}
Menu {
id: contextMenu
MenuItem {
text: "Copy"
enabled: textEdit.selectedText.length > 0
onTriggered: textEdit.copy()
}
MenuItem {
text: "Select All"
onTriggered: textEdit.selectAll()
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
propagateComposedEvents: true
cursorShape: Qt.IBeamCursor
onPressed: function(mouse) {
if (root.chatView) root.chatView.interactive = false
mouse.accepted = false
}
onReleased: function(mouse) {
if (root.chatView) root.chatView.interactive = true
mouse.accepted = false
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
cursorShape: Qt.IBeamCursor
onClicked: contextMenu.popup()
}
}
}
// Diagram segment component
Component {
id: diagramComponent
Rectangle {
id: diagramRect
width: parent ? parent.width : 100
height: diagramContent.height + diagramHeader.height + 8
color: "transparent"
border.color: theme.midColor
border.width: 1
radius: 4
property bool showSource: false
RowLayout {
id: diagramHeader
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 4
height: 24
Label {
text: segmentData.lang || "diagram"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 1 : 11
color: theme.textColor
opacity: 0.7
}
Item { Layout.fillWidth: true }
Button {
text: diagramRect.showSource ? "📊" : "</>"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10
implicitWidth: 32
implicitHeight: 20
ToolTip.visible: hovered
ToolTip.text: diagramRect.showSource ? "Show diagram" : "Show source"
onClicked: diagramRect.showSource = !diagramRect.showSource
}
Button {
text: "📋"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10
implicitWidth: 32
implicitHeight: 20
ToolTip.visible: hovered
ToolTip.text: diagramRect.showSource ? "Copy source" : "Copy image"
onClicked: {
if (diagramRect.showSource) {
clipboard.copyText(segmentData.source)
} else if (segmentData.svg) {
clipboard.copyImage(segmentData.svg)
}
}
}
}
Item {
id: diagramContent
anchors.top: diagramHeader.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 4
height: diagramRect.showSource ? sourceEdit.height : imageColumn.height
TextEdit {
id: sourceEdit
width: parent.width
visible: diagramRect.showSource
text: segmentData.html || ""
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
propagateComposedEvents: true
cursorShape: Qt.IBeamCursor
onPressed: function(mouse) {
if (root.chatView) root.chatView.interactive = false
mouse.accepted = false
}
onReleased: function(mouse) {
if (root.chatView) root.chatView.interactive = true
mouse.accepted = false
}
}
}
Column {
id: imageColumn
width: parent.width
visible: !diagramRect.showSource
Rectangle {
visible: segmentData.svg && !segmentData.loading
color: "white"
width: diagramImg.width
height: diagramImg.height
Image {
id: diagramImg
source: segmentData.svg || ""
fillMode: Image.PreserveAspectFit
width: Math.min(implicitWidth, imageColumn.width)
}
}
Label {
visible: segmentData.loading
text: "Loading " + (segmentData.lang || "diagram") + "..."
font.italic: true
color: theme.textColor
opacity: 0.7
}
}
}
}
}
}

174
gui/NewAgentDialog.qml Normal file
View File

@ -0,0 +1,174 @@
// NewAgentDialog.qml — Dialog for creating a new agent in a session
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Dialog {
id: root
title: "New Agent"
anchors.centerIn: parent
width: 420
modal: true
// Session ID to create the agent in
property string targetSessionId: ""
signal agentCreated()
contentItem: ColumnLayout {
spacing: 16
Layout.margins: 16
Label {
text: "Agent"
font.bold: true
font.pointSize: theme.fontSize > 0 ? theme.fontSize + 1 : Qt.application.font.pointSize + 1
}
Label {
id: errorLabel
Layout.fillWidth: true
visible: text !== ""
color: "red"
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
Label { text: "Directory:"; Layout.preferredWidth: 80 }
TextField {
id: dirField
Layout.fillWidth: true
placeholderText: "/path/to/project"
}
Button {
text: "Browse..."
onClicked: {
var folder = theme.chooseFolder("Select Project Directory", dirField.text || "")
if (folder !== "")
dirField.text = folder
}
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Backend:"; Layout.preferredWidth: 80 }
ComboBox {
id: backendCombo
Layout.fillWidth: true
enabled: ollie.rootBackendsLoaded
model: ollie.availableBackends
currentIndex: 0
editable: true
onAccepted: backendCombo.editText = backendCombo.currentText
}
}
ListModel { id: modelList }
RowLayout {
Layout.fillWidth: true
Label { text: "Model:"; Layout.preferredWidth: 80 }
ComboBox {
id: modelCombo
Layout.fillWidth: true
enabled: backendCombo.currentIndex >= 0 && backendCombo.currentText.trim() !== "" && ollie.rootModelsLoaded
model: modelList
currentIndex: -1
editable: true
onAccepted: modelCombo.editText = modelCombo.currentText
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Profile:"; Layout.preferredWidth: 80 }
ComboBox {
id: agentCombo
Layout.fillWidth: true
enabled: ollie.rootAgentsLoaded
model: ollie.availableAgents
currentIndex: 0
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Name:"; Layout.preferredWidth: 80 }
TextField {
id: aliasField
Layout.fillWidth: true
placeholderText: "(auto-generated)"
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Remote:"; Layout.preferredWidth: 80 }
TextField {
id: remoteField
Layout.fillWidth: true
placeholderText: "user@host:port"
}
}
Component.onCompleted: {
if (!ollie.rootBackendsLoaded) ollie.loadRootBackends()
if (!ollie.rootAgentsLoaded) ollie.loadRootAgents()
}
Connections {
target: backendCombo
function onCurrentTextChanged() {
var be = backendCombo.currentText.trim()
modelList.clear()
if (be !== "" && ollie.rootModelsLoaded) {
var models = ollie.getAvailableModels(be)
for (var i = 0; i < models.length; ++i) {
modelList.append({ modelData: models[i] })
}
modelCombo.currentIndex = models.length > 0 ? 0 : -1
} else {
modelCombo.currentIndex = -1
}
}
}
}
footer: RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: "OK"
onClicked: {
errorLabel.text = ""
var cwd = dirField.text.trim()
if (cwd === "") {
errorLabel.text = "Directory is required"
return
}
var backend = backendCombo.currentText.trim()
var model = modelCombo.currentIndex >= 0 ? modelCombo.currentText.trim() : ""
var agent = agentCombo.currentText
var remote = remoteField.text.trim()
var agentAlias = aliasField.text.trim()
var err = ollie.createAgent(root.targetSessionId, cwd, backend, model, agent, remote, agentAlias)
if (err === "") {
ollie.refreshSessions()
root.agentCreated()
root.close()
} else {
errorLabel.text = err
}
}
}
Button {
text: "Cancel"
onClicked: root.close()
}
}
onOpened: errorLabel.text = ""
}

79
gui/NewSessionDialog.qml Normal file
View File

@ -0,0 +1,79 @@
// NewSessionDialog.qml — Dialog for creating a new session
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Dialog {
id: root
title: "New Session"
anchors.centerIn: parent
width: 380
modal: true
signal sessionCreated()
contentItem: ColumnLayout {
spacing: 16
Layout.margins: 16
Label {
id: errorLabel
Layout.fillWidth: true
visible: text !== ""
color: "red"
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
Label { text: "Name:"; Layout.preferredWidth: 80 }
TextField {
id: nameField
Layout.fillWidth: true
placeholderText: "(auto-generated)"
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Remote:"; Layout.preferredWidth: 80 }
TextField {
id: remoteField
Layout.fillWidth: true
placeholderText: "user@host (optional)"
}
}
}
footer: RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: "OK"
onClicked: {
errorLabel.text = ""
var name = nameField.text.trim()
var remote = remoteField.text.trim()
var err = ollie.createSession(name, remote)
if (err === "") {
ollie.refreshSessions()
root.sessionCreated()
root.close()
} else {
errorLabel.text = err
}
}
}
Button {
text: "Cancel"
onClicked: root.close()
}
}
onOpened: {
errorLabel.text = ""
nameField.text = ""
remoteField.text = ""
}
}

118
gui/SettingsDialog.qml Normal file
View File

@ -0,0 +1,118 @@
// SettingsDialog.qml — Theme and font settings dialog
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Dialog {
id: root
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
}
Label {
text: "Uses KDE Breeze color schemes from /usr/share/color-schemes/"
opacity: 0.6
wrapMode: Text.Wrap
Layout.fillWidth: true
font.pixelSize: Qt.application.font.pixelSize * 0.9
}
ColumnLayout {
spacing: 8
RadioButton {
text: "Use system theme"
checked: theme.mode === "system"
onClicked: theme.mode = "system"
}
RadioButton {
text: "Breeze Light"
checked: theme.mode === "light"
onClicked: theme.mode = "light"
}
RadioButton {
text: "Breeze Dark"
checked: theme.mode === "dark"
onClicked: theme.mode = "dark"
}
}
Label {
text: theme.isDark ? "Current: dark" : "Current: light"
opacity: 0.5
font.pixelSize: Qt.application.font.pixelSize * 0.85
}
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()
}
}
}

View File

@ -531,371 +531,19 @@ ApplicationWindow {
}
// --- Settings Dialog ---
Dialog {
SettingsDialog {
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
}
Label {
text: "Uses KDE Breeze color schemes from /usr/share/color-schemes/"
opacity: 0.6
wrapMode: Text.Wrap
Layout.fillWidth: true
font.pixelSize: Qt.application.font.pixelSize * 0.9
}
ColumnLayout {
spacing: 8
RadioButton {
id: systemRadio
text: "Use system theme"
checked: theme.mode === "system"
onClicked: theme.mode = "system"
}
RadioButton {
id: lightRadio
text: "Breeze Light"
checked: theme.mode === "light"
onClicked: theme.mode = "light"
}
RadioButton {
id: darkRadio
text: "Breeze Dark"
checked: theme.mode === "dark"
onClicked: theme.mode = "dark"
}
}
Label {
text: theme.isDark ? "Current: dark" : "Current: light"
opacity: 0.5
font.pixelSize: Qt.application.font.pixelSize * 0.85
}
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()
}
}
}
// --- New Session Dialog ---
Dialog {
NewSessionDialog {
id: newSessionDialog
title: "New Session"
anchors.centerIn: parent
width: 380
modal: true
contentItem: ColumnLayout {
spacing: 16
Layout.margins: 16
Label {
id: newSessionErrorLabel
Layout.fillWidth: true
visible: text !== ""
color: "red"
wrapMode: Text.Wrap
}
// Name
RowLayout {
Layout.fillWidth: true
Label { text: "Name:"; Layout.preferredWidth: 80 }
TextField {
id: sessionNameField
Layout.fillWidth: true
placeholderText: "(auto-generated)"
}
}
// Remote
RowLayout {
Layout.fillWidth: true
Label { text: "Remote:"; Layout.preferredWidth: 80 }
TextField {
id: sessionRemoteField
Layout.fillWidth: true
placeholderText: "user@host (optional)"
}
}
}
footer: RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: "OK"
onClicked: {
newSessionErrorLabel.text = ""
var name = sessionNameField.text.trim()
var remote = sessionRemoteField.text.trim()
var err = ollie.createSession(name, remote)
if (err === "") {
ollie.refreshSessions()
newSessionDialog.close()
} else {
newSessionErrorLabel.text = err
}
}
}
Button {
text: "Cancel"
onClicked: newSessionDialog.close()
}
}
onOpened: {
newSessionErrorLabel.text = ""
sessionNameField.text = ""
sessionRemoteField.text = ""
}
}
// --- New Agent Dialog ---
Dialog {
NewAgentDialog {
id: newAgentDialog
title: "New Agent"
anchors.centerIn: parent
width: 420
modal: true
contentItem: ColumnLayout {
spacing: 16
Layout.margins: 16
// --- Agent ---
Label { text: "Agent"; font.bold: true; font.pointSize: theme.fontSize > 0 ? theme.fontSize + 1 : Qt.application.font.pointSize + 1 }
// Error label
Label {
id: newAgentErrorLabel
Layout.fillWidth: true
visible: text !== ""
color: "red"
wrapMode: Text.Wrap
}
// Directory
RowLayout {
Layout.fillWidth: true
Label { text: "Directory:"; Layout.preferredWidth: 80 }
TextField {
id: dirField
Layout.fillWidth: true
placeholderText: "/path/to/project"
readOnly: false
}
Button {
text: "Browse..."
onClicked: {
var folder = theme.chooseFolder("Select Project Directory", dirField.text || "")
if (folder !== "")
dirField.text = folder
}
}
}
// Backend
RowLayout {
Layout.fillWidth: true
Label { text: "Backend:"; Layout.preferredWidth: 80 }
ComboBox {
id: backendCombo
Layout.fillWidth: true
enabled: ollie.rootBackendsLoaded
model: ollie.availableBackends
currentIndex: 0
editable: true
onAccepted: backendCombo.editText = backendCombo.currentText
}
}
// Model list (populated reactively from backend selection)
ListModel { id: modelList }
// Model
RowLayout {
Layout.fillWidth: true
Label { text: "Model:"; Layout.preferredWidth: 80 }
ComboBox {
id: modelCombo
Layout.fillWidth: true
enabled: backendCombo.currentIndex >= 0 && backendCombo.currentText.trim() !== "" && ollie.rootModelsLoaded
model: modelList
currentIndex: -1
editable: true
onAccepted: modelCombo.editText = modelCombo.currentText
}
}
// Profile
RowLayout {
Layout.fillWidth: true
Label { text: "Profile:"; Layout.preferredWidth: 80 }
ComboBox {
id: agentCombo
Layout.fillWidth: true
enabled: ollie.rootAgentsLoaded
model: ollie.availableAgents
currentIndex: 0
}
}
// Alias
RowLayout {
Layout.fillWidth: true
Label { text: "Name:"; Layout.preferredWidth: 80 }
TextField {
id: agentAliasField
Layout.fillWidth: true
placeholderText: "(auto-generated)"
}
}
// Remote
RowLayout {
Layout.fillWidth: true
Label { text: "Remote:"; Layout.preferredWidth: 80 }
TextField {
id: remoteField
Layout.fillWidth: true
placeholderText: "user@host:port"
}
}
Component.onCompleted: {
if (!ollie.rootBackendsLoaded) ollie.loadRootBackends()
if (!ollie.rootAgentsLoaded) ollie.loadRootAgents()
}
Connections {
target: backendCombo
function onCurrentTextChanged() {
var be = backendCombo.currentText.trim()
modelList.clear()
if (be !== "" && ollie.rootModelsLoaded) {
var models = ollie.getAvailableModels(be)
for (var i = 0; i < models.length; ++i) {
modelList.append({ modelData: models[i] })
}
if (models.length > 0) {
modelCombo.currentIndex = 0
} else {
modelCombo.currentIndex = -1
}
} else {
modelCombo.currentIndex = -1
}
}
}
}
footer: RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: "OK"
onClicked: {
newAgentErrorLabel.text = ""
var cwd = dirField.text.trim()
if (cwd === "") {
newAgentErrorLabel.text = "Directory is required"
return
}
var backend = backendCombo.currentText.trim()
var model = modelCombo.currentIndex >= 0 ? modelCombo.currentText.trim() : ""
var agent = agentCombo.currentText
var remote = remoteField.text.trim()
var agentAlias = agentAliasField.text.trim()
var err = ollie.createAgent(root.newAgentTargetSessionId, cwd, backend, model, agent, remote, agentAlias)
if (err === "") {
ollie.refreshSessions()
newAgentDialog.close()
} else {
newAgentErrorLabel.text = err
}
}
}
Button {
text: "Cancel"
onClicked: newAgentDialog.close()
}
}
onOpened: newAgentErrorLabel.text = ""
targetSessionId: root.newAgentTargetSessionId
}
SplitView {
@ -1279,312 +927,9 @@ ApplicationWindow {
}
}
delegate: ColumnLayout {
id: blockItem
delegate: ChatBlockDelegate {
width: chatView.width - 8
spacing: 2
property bool blockExpanded: {
switch (blockType) {
case "user": return true
case "assistant": return true
case "tool": return true
case "info": return true
case "retry": return true
case "interrupt": return true
default: return false
}
}
// Clickable header
Rectangle {
Layout.fillWidth: true
height: 22
radius: 3
color: {
var bg = theme.baseColor
if (theme.isDark) {
switch (blockType) {
case "user": return Qt.lighter(bg, 1.8)
case "assistant": return Qt.lighter(bg, 1.5)
case "reasoning": return Qt.lighter(bg, 1.3)
case "call": return Qt.lighter(bg, 1.7)
case "tool": return Qt.lighter(bg, 1.2)
case "error": return Qt.rgba(0.6, 0.15, 0.15, 0.7)
case "info": return Qt.lighter(bg, 1.4)
case "retry": return Qt.rgba(0.6, 0.5, 0.0, 0.5)
case "interrupt": return "#3d2e00"
default: return bg
}
} else {
switch (blockType) {
case "user": return Qt.darker(bg, 1.12)
case "assistant": return Qt.darker(bg, 1.06)
case "reasoning": return Qt.darker(bg, 1.03)
case "call": return Qt.darker(bg, 1.1)
case "tool": return Qt.darker(bg, 1.04)
case "error": return Qt.rgba(1.0, 0.8, 0.8, 0.7)
case "info": return Qt.darker(bg, 1.02)
case "retry": return Qt.rgba(1.0, 0.9, 0.6, 0.5)
case "interrupt": return "#fff8e1"
default: return bg
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: blockItem.blockExpanded = !blockItem.blockExpanded
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 6
spacing: 4
Label {
text: blockItem.blockExpanded ? "▼" : "▶"
font.pixelSize: 8
opacity: 0.6
}
Label {
Layout.fillWidth: true
text: {
switch (blockType) {
case "user": return "\uD83E\uDDD1 You"
case "assistant": return "\uD83E\uDD16 Ollie"
case "reasoning": return "Reasoning"
case "call": return "\u25B6\uFE0F " + blockName
case "tool": return "\uD83D\uDDA5\uFE0F " + blockName
case "error": return "\u274C Error"
case "info": return "\u2139\uFE0F Info"
case "retry": return "Retry"
case "interrupt": return "\u26A0\uFE0F User Interrupt"
default: return ""
}
}
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11
font.bold: true
color: theme.textColor
opacity: 0.9
}
}
}
// Block content rendered as segments (text and diagrams)
Column {
Layout.fillWidth: true
visible: blockItem.blockExpanded
spacing: 4
Repeater {
model: blockSegments || []
delegate: Loader {
width: parent ? parent.width : 0
active: modelData !== null && modelData !== undefined
sourceComponent: (modelData && modelData.type === "diagram") ? diagramComponent : textComponent
property var segmentData: modelData || {}
}
}
}
// Text segment component
Component {
id: textComponent
TextEdit {
id: textSegmentEdit
width: parent ? parent.width : 100
text: segmentData.html || ""
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
onActiveFocusChanged: {
if (!activeFocus)
chatView.interactive = true
}
Menu {
id: textSegmentMenu
MenuItem {
text: "Copy"
enabled: textSegmentEdit.selectedText.length > 0
onTriggered: textSegmentEdit.copy()
}
MenuItem {
text: "Select All"
onTriggered: textSegmentEdit.selectAll()
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
propagateComposedEvents: true
cursorShape: Qt.IBeamCursor
onPressed: function(mouse) {
chatView.interactive = false
mouse.accepted = false
}
onReleased: function(mouse) {
chatView.interactive = true
mouse.accepted = false
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
cursorShape: Qt.IBeamCursor
onClicked: textSegmentMenu.popup()
}
}
}
// Diagram segment component with source/image toggle
Component {
id: diagramComponent
Rectangle {
width: parent ? parent.width : 100
height: diagramContent.height + diagramHeader.height + 8
color: "transparent"
border.color: theme.midColor
border.width: 1
radius: 4
property bool showSource: false
// Header with language label and toggle button
RowLayout {
id: diagramHeader
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 4
height: 24
Label {
text: segmentData.lang || "diagram"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 1 : 11
color: theme.textColor
opacity: 0.7
}
Item { Layout.fillWidth: true }
Button {
text: parent.parent.showSource ? "📊" : "</>"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10
implicitWidth: 32
implicitHeight: 20
ToolTip.visible: hovered
ToolTip.text: parent.parent.showSource ? "Show diagram" : "Show source"
onClicked: parent.parent.showSource = !parent.parent.showSource
}
Button {
text: "📋"
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 2 : 10
implicitWidth: 32
implicitHeight: 20
ToolTip.visible: hovered
ToolTip.text: parent.parent.showSource ? "Copy source" : "Copy image"
onClicked: {
if (parent.parent.showSource) {
// Copy source code as plain text
clipboard.copyText(segmentData.source)
} else if (segmentData.svg) {
// Copy image
clipboard.copyImage(segmentData.svg)
}
}
}
}
// Content area
Item {
id: diagramContent
anchors.top: diagramHeader.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 4
height: showSource ? diagramSourceEdit.height : diagramImage.height
// Source view
TextEdit {
id: diagramSourceEdit
width: parent.width
visible: showSource
text: segmentData.html || ""
textFormat: Text.RichText
readOnly: true
selectByMouse: true
persistentSelection: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || Qt.application.font.family
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
selectionColor: theme.highlightColor
selectedTextColor: theme.highlightedTextColor
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
propagateComposedEvents: true
cursorShape: Qt.IBeamCursor
onPressed: function(mouse) {
chatView.interactive = false
mouse.accepted = false
}
onReleased: function(mouse) {
chatView.interactive = true
mouse.accepted = false
}
}
}
// Image view
Column {
id: diagramImage
width: parent.width
visible: !showSource
Rectangle {
visible: segmentData.svg && !segmentData.loading
color: "white"
width: diagramImg.width
height: diagramImg.height
Image {
id: diagramImg
source: segmentData.svg || ""
fillMode: Image.PreserveAspectFit
width: Math.min(implicitWidth, diagramImage.width)
}
}
Label {
visible: segmentData.loading
text: "Loading " + (segmentData.lang || "diagram") + "..."
font.italic: true
color: theme.textColor
opacity: 0.7
}
}
}
}
}
chatView: chatView
}
}

View File

@ -1,5 +1,9 @@
<RCC>
<qresource prefix="/">
<file>main.qml</file>
<file>SettingsDialog.qml</file>
<file>NewSessionDialog.qml</file>
<file>NewAgentDialog.qml</file>
<file>ChatBlockDelegate.qml</file>
</qresource>
</RCC>