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

866 lines
31 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: root
title: dbus.activeCwd ? "Ollie — " + dbus.activeCwd : "Ollie"
width: 900
height: 600
visible: true
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()
}
}
}
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
background: Rectangle {
color: sessionDelegate.highlighted
? palette.highlight : "transparent"
radius: 4
}
contentItem: RowLayout {
spacing: 6
Rectangle {
width: 8
height: 8
radius: 4
color: {
let state = modelData.state
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: 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
onContentYChanged: {
let bottom = contentHeight - height
parent.atBottom = (bottom <= 0 || contentY >= bottom - 4)
// Load earlier messages when scrolled near top
if (contentY < 100 && contentHeight > height) {
let oldHeight = contentHeight
if (chatModel.loadEarlier()) {
Qt.callLater(function() {
chatFlick.contentY += (chatFlick.contentHeight - oldHeight)
})
}
}
}
onContentHeightChanged: {
if (parent.atBottom) {
contentY = Math.max(0, contentHeight - height)
}
}
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
}
}
Component.onCompleted: forceActiveFocus()
}
}
ToolButton {
icon.name: "media-playback-stop"
enabled: dbus.activeState !== "idle" && dbus.activeSessionId !== ""
onClicked: dbus.interrupt(dbus.activeSessionId)
ToolTip.text: "Interrupt"
ToolTip.visible: hovered
}
}
// 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 ---
function linkifyPaths(text) {
// Escape HTML entities first
let escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// Inline code: `text` → monospace styled span
escaped = escaped.replace(/`([^`]+)`/g, '<code style="font-family:monospace;background:rgba(128,128,128,0.15);padding:1px 4px;border-radius:3px;">$1</code>')
// Convert newlines to <br>
escaped = escaped.replace(/\n/g, '<br>')
// Match /absolute/path or /absolute/path:line
escaped = escaped.replace(/(\/[\w\-.\/]+(?::\d+)?)/g, '<a href="file://$1">$1</a>')
return escaped
}
Component {
id: userDelegate
ColumnLayout {
spacing: 4
Rectangle {
Layout.fillWidth: true
implicitHeight: userContent.implicitHeight + 12
color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.08)
radius: 6
ColumnLayout {
id: userContent
anchors.fill: parent
anchors.margins: 6
spacing: 4
Repeater {
model: block.subBlocks
delegate: Loader {
Layout.fillWidth: true
property var subBlock: modelData
sourceComponent: subBlock.type === "code" ? codeSubDelegate
: subBlock.type === "diff" ? diffSubDelegate
: proseSubDelegate
}
}
}
}
}
}
Component {
id: assistantDelegate
ColumnLayout {
spacing: 4
Repeater {
model: block.subBlocks
delegate: Loader {
Layout.fillWidth: true
property var subBlock: modelData
sourceComponent: subBlock.type === "code" ? codeSubDelegate
: subBlock.type === "diff" ? diffSubDelegate
: proseSubDelegate
}
}
}
}
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.blockContent
wrapMode: Text.Wrap
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.85
opacity: 0.5
visible: false
}
}
}
Component {
id: callDelegate
ColumnLayout {
spacing: 2
Label {
text: (callText.visible ? "\u25BC" : "\u25B6") + " call: " + (block.blockName || "")
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.5
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: callText.visible = !callText.visible
}
}
Text {
id: callText
Layout.fillWidth: true
text: visible ? chatModel.prettyJson(String(block.blockContent || "")) : ""
textFormat: Text.RichText
wrapMode: Text.Wrap
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.85
opacity: 0.7
visible: false
color: palette.text
}
}
}
Component {
id: toolDelegate
ColumnLayout {
spacing: 2
Label {
text: (toolText.visible ? "\u25BC" : "\u25B6") + " tool: " + (block.blockName || "")
font.pixelSize: Qt.application.font.pixelSize * 0.8
opacity: 0.5
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: toolText.visible = !toolText.visible
}
}
Text {
id: toolText
Layout.fillWidth: true
text: {
if (!visible) return ""
let content = String(block.blockContent || "")
if (!content) return ""
let trimmed = content.trim()
if (trimmed.charAt(0) === '{' || trimmed.charAt(0) === '[')
return chatModel.prettyJson(content)
if (trimmed.indexOf("---") === 0 || trimmed.indexOf("diff ") === 0)
return chatModel.highlightedHtml(content, "diff")
return content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
}
textFormat: Text.RichText
wrapMode: Text.Wrap
font.family: "monospace"
font.pixelSize: Qt.application.font.pixelSize * 0.85
opacity: 0.7
visible: false
color: palette.text
maximumLineCount: 80
elide: Text.ElideRight
}
}
}
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.blockContent
wrapMode: Text.Wrap
color: "#e53935"
font.family: "monospace"
}
}
}
Component {
id: infoDelegate
Label {
text: 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: root.linkifyPaths(subBlock.content)
wrapMode: Text.Wrap
textFormat: Text.RichText
color: palette.text
font.pixelSize: Qt.application.font.pixelSize
lineHeight: 1.4
onLinkActivated: function(link) {
Qt.openUrlExternally(link)
}
}
}
Component {
id: codeSubDelegate
Rectangle {
implicitHeight: codeCol.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: codeCol
anchors.fill: parent
spacing: 0
// Header with language + copy button
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: "edit-copy"
implicitWidth: 22
implicitHeight: 22
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: 22
implicitHeight: 22
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
}
}
}
}
}
}
}
}
}