import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 ApplicationWindow { id: root visible: true width: 1000 height: 600 title: "Ollie" font.family: theme.fontFamily || Qt.application.font.family font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize // Apply theme palette to all Qt Quick Controls palette.active.window: theme.windowColor palette.active.windowText: theme.windowTextColor palette.active.base: theme.baseColor palette.active.text: theme.textColor palette.active.button: theme.buttonColor palette.active.buttonText: theme.buttonTextColor palette.active.highlight: theme.highlightColor palette.active.highlightedText: theme.highlightedTextColor palette.active.mid: theme.midColor palette.active.dark: theme.darkColor palette.active.light: theme.lightColor palette.active.shadow: theme.shadowColor palette.active.link: theme.linkColor palette.active.toolTipBase: theme.tooltipBaseColor palette.active.toolTipText: theme.tooltipTextColor palette.active.placeholderText: theme.placeholderTextColor palette.active.alternateBase: theme.alternateBaseColor palette.inactive.window: theme.windowColor palette.inactive.windowText: theme.windowTextColor palette.inactive.base: theme.baseColor palette.inactive.text: theme.textColor palette.inactive.button: theme.buttonColor palette.inactive.buttonText: theme.buttonTextColor palette.inactive.highlight: theme.highlightColor palette.inactive.highlightedText: theme.highlightedTextColor palette.inactive.mid: theme.midColor palette.inactive.dark: theme.darkColor palette.inactive.light: theme.lightColor palette.inactive.shadow: theme.shadowColor palette.inactive.link: theme.linkColor palette.inactive.toolTipBase: theme.tooltipBaseColor palette.inactive.toolTipText: theme.tooltipTextColor palette.inactive.placeholderText: theme.placeholderTextColor palette.inactive.alternateBase: theme.alternateBaseColor palette.disabled.window: theme.windowColor palette.disabled.windowText: theme.midColor palette.disabled.base: theme.baseColor palette.disabled.text: theme.midColor palette.disabled.button: theme.buttonColor palette.disabled.buttonText: theme.midColor palette.disabled.highlight: theme.highlightColor palette.disabled.highlightedText: theme.highlightedTextColor palette.disabled.mid: theme.midColor palette.disabled.dark: theme.darkColor palette.disabled.light: theme.lightColor palette.disabled.shadow: theme.shadowColor // Deferred scroll timer — fires once after model changes settle Timer { id: scrollTimer interval: 0 repeat: false onTriggered: chatView.positionViewAtEnd() } // Session tree model — flat list with level info ListModel { id: sessionTreeModel } function buildSessionTree() { console.log("buildSessionTree: sessions count = " + ollie.sessions.length) sessionTreeModel.clear() var sessions = ollie.sessions for (var i = 0; i < sessions.length; ++i) { var sess = sessions[i] var sid = sess.id || "" console.log(" session[" + i + "] = " + sid) // Auto-expand active session var expanded = (sid === ollie.activeSessionId) // Add session node sessionTreeModel.append({ type: "session", id: sid, level: 0, expanded: expanded, state: sess.state || "", model: sess.model || "", displayName: sid }) // Fetch agents and add them try { var agents = ollie.getAgents(sid) console.log(" agents for " + sid + ": " + agents.length) for (var j = 0; j < agents.length; ++j) { var aid = agents[j] sessionTreeModel.append({ type: "agent", parentId: sid, agentId: aid, level: 1, state: "idle", displayName: "Agent " + aid }) } } catch(e) { console.log(" error getting agents for " + sid + ": " + e) } } console.log(" total tree items: " + sessionTreeModel.count) } function toggleExpand(index) { var item = sessionTreeModel.get(index) if (item.type === "session") { sessionTreeModel.set(index, { expanded: !item.expanded }) } } function activateAgent(parentId, agentId) { ollie.switchAgent(parentId, agentId) } Connections { target: ollie function onSessionsChanged() { buildSessionTree() } } Component.onCompleted: { var log = ollie.readLog() if (log) { chatModel.appendData(log) chatModel.trimFront(30) } scrollTimer.start() buildSessionTree() } Connections { target: ollie function onActiveSessionIdChanged() { chatModel.reset() var log = ollie.readLog() if (log) { chatModel.appendData(log) chatModel.trimFront(30) } scrollTimer.start() // Expand the active session in the tree buildSessionTree() } function onStreamingDone() { chatModel.appendData("\n[info]\nsession ended\n") } } // --- 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 } 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 { id: newSessionDialog title: "New Session" anchors.centerIn: parent width: 420 modal: true standardButtons: Dialog.Ok | Dialog.Cancel contentItem: ColumnLayout { spacing: 16 Layout.margins: 16 // 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 } } } // Name RowLayout { Layout.fillWidth: true Label { text: "Name:"; Layout.preferredWidth: 80 } TextField { id: nameField Layout.fillWidth: true placeholderText: "(auto-generated)" } } // Backend RowLayout { Layout.fillWidth: true Label { text: "Backend:"; Layout.preferredWidth: 80 } TextField { id: backendField Layout.fillWidth: true placeholderText: "ollama" } Button { text: "..." onClicked: backendPicker.open() } } // Model RowLayout { Layout.fillWidth: true Label { text: "Model:"; Layout.preferredWidth: 80 } TextField { id: modelField Layout.fillWidth: true placeholderText: "(auto-populated)" enabled: backendField.text.trim() !== "" } Button { text: "..." enabled: backendField.text.trim() !== "" && ollie.rootModelsLoaded onClicked: modelPicker.open() } } // Agent RowLayout { Layout.fillWidth: true Label { text: "Agent:"; Layout.preferredWidth: 80 } ComboBox { id: agentCombo Layout.fillWidth: true enabled: ollie.rootAgentsLoaded model: ollie.availableAgents currentIndex: 0 } } // Remote RowLayout { Layout.fillWidth: true Label { text: "Remote:"; Layout.preferredWidth: 80 } TextField { id: remoteField Layout.fillWidth: true placeholderText: "user@host:port" } } // Backend picker dialog Dialog { id: backendPicker title: "Select Backend" anchors.centerIn: parent modal: true standardButtons: Dialog.Cancel contentItem: ListView { width: 300 height: 200 model: ollie.availableBackends delegate: ItemDelegate { width: parent.width text: modelData onClicked: { backendField.text = modelData backendPicker.close() } } } } // Model picker dialog Dialog { id: modelPicker title: "Select Model" anchors.centerIn: parent modal: true standardButtons: Dialog.Cancel contentItem: ListView { width: 400 height: 300 model: ollie.getAvailableModels(backendField.text.trim()) delegate: ItemDelegate { width: parent.width text: modelData onClicked: { modelField.text = modelData modelPicker.close() } } } } Component.onCompleted: { if (!ollie.rootBackendsLoaded) ollie.loadRootBackends() if (!ollie.rootAgentsLoaded) ollie.loadRootAgents() } Connections { target: backendField function onTextChanged() { var be = backendField.text.trim() if (be !== "" && ollie.rootModelsLoaded) { var models = ollie.getAvailableModels(be) if (models.length > 0) { modelField.placeholderText = models[0] } else { modelField.placeholderText = "(no models)" } } else { modelField.placeholderText = "(auto-populated)" } } } } onAccepted: { var cwd = dirField.text.trim() if (cwd === "") { console.log("New session: directory is required") return } var name = nameField.text.trim() var backend = backendField.text.trim() var model = modelField.text.trim() var agent = agentCombo.currentText var remote = remoteField.text.trim() var ok = ollie.createSession(cwd, name, backend, model, agent, remote) if (ok) { ollie.refreshSessions() } else { console.log("New session: creation failed") } } } SplitView { anchors.fill: parent // Left pane: session list ColumnLayout { SplitView.preferredWidth: root.width * 0.3 SplitView.minimumWidth: 150 spacing: 0 ToolBar { Layout.fillWidth: true RowLayout { anchors.fill: parent Label { text: "Sessions"; Layout.fillWidth: true; font.bold: true; leftPadding: 8 } ToolButton { text: "\u2795" onClicked: newSessionDialog.open() ToolTip.text: "New Session" ToolTip.visible: hovered } ToolButton { text: "\u21BB" onClicked: ollie.refreshSessions() } ToolButton { text: "\u2699" onClicked: settingsDialog.open() ToolTip.text: "Settings" ToolTip.visible: hovered } } } ListView { id: sessionList Layout.fillWidth: true Layout.fillHeight: true model: sessionTreeModel clip: true delegate: ItemDelegate { width: sessionList.width // Guard against undefined during initial render height: { if (!type) return 0 if (type === "agent" && !parentSessionExpanded(parentId)) return 0 return 28 } visible: height > 0 contentItem: RowLayout { anchors.fill: parent spacing: 0 // Indent + expand/collapse indicator Label { Layout.preferredWidth: (level || 0) * 16 + 16 text: type === "session" ? (expanded ? "▼" : "▶") : "" font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.7 : 10 color: theme.textColor opacity: 0.6 verticalAlignment: Text.AlignVCenter } // Name Label { Layout.fillWidth: true text: displayName || "" color: theme.textColor font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize verticalAlignment: Text.AlignVCenter } // State dot Rectangle { Layout.preferredWidth: 8 Layout.preferredHeight: 8 Layout.alignment: Qt.AlignVCenter radius: 4 color: { var s = state || "idle" if (s === "idle") return "#4caf50" if (s.startsWith("calling")) return "#ff9800" return "#2196f3" } } } onClicked: { if (type === "session") { toggleExpand(index) } else if (type === "agent") { activateAgent(parentId, agentId) } } highlighted: (type === "agent" && agentId === ollie.activeAgentId && parentId === ollie.activeSessionId) function parentSessionExpanded(pid) { for (var i = 0; i < sessionTreeModel.count; ++i) { var item = sessionTreeModel.get(i) if (item.id === pid && item.type === "session") return item.expanded } return false } } } } // Right pane: chat + input ColumnLayout { spacing: 0 // Header RowLayout { Layout.fillWidth: true Layout.margins: 6 spacing: 8 Rectangle { width: 10; height: 10; radius: 5 color: { if (ollie.activeState === "idle") return "#4caf50" if (ollie.activeState.startsWith("calling")) return "#ff9800" return "#2196f3" } } Label { text: ollie.activeState opacity: 0.7 font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11 } Item { Layout.fillWidth: true } ToolButton { text: "Stop" onClicked: ollie.interrupt() } } Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor } // Chat display — block-based ListView ScrollView { Layout.fillWidth: true Layout.fillHeight: true clip: true ListView { id: chatView model: chatModel spacing: 4 anchors.fill: parent anchors.margins: 4 boundsBehavior: Flickable.StopAtBounds onCountChanged: { if (count > 0) scrollTimer.start() } Connections { target: chatModel function onDataChanged() { if (chatView.atYEnd && chatView.count > 0) scrollTimer.start() } } delegate: ColumnLayout { width: chatView.width - 8 spacing: 2 // Header decoration Rectangle { Layout.fillWidth: true height: 22 radius: 3 color: { // Derive header colors from base background with subtle tint. // Always readable because text uses theme.textColor. var bg = theme.baseColor if (theme.isDark) { // Dark theme: lighter tints against dark background 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) default: return bg } } else { // Light theme: slightly darker tints 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) default: return bg } } } RowLayout { anchors.fill: parent anchors.leftMargin: 6 spacing: 4 Label { text: { switch (blockType) { case "user": return "User" case "assistant": return "Assistant" + (blockName ? " (" + blockName + ")" : "") case "reasoning": return "Reasoning" case "call": return "Call: " + blockName case "tool": return "Tool: " + blockName case "error": return "Error" case "info": return "Info" default: return "" } } font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11 font.bold: true color: theme.textColor opacity: 0.9 } } } ColumnLayout { Layout.fillWidth: true Layout.leftMargin: 4 Layout.rightMargin: 4 Layout.bottomMargin: 4 spacing: 0 // Rendered HTML (fenced code/diff) — shown when available TextEdit { Layout.fillWidth: true visible: blockRenderedHtml && blockRenderedHtml.length > 0 text: blockRenderedHtml textFormat: Text.RichText readOnly: true wrapMode: TextEdit.Wrap font.family: theme.fontFamily || "monospace" font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 color: theme.textColor } // Plain text fallback (prose, streaming content) Label { Layout.fillWidth: true visible: (!blockRenderedHtml || blockRenderedHtml.length === 0) text: blockContent || "" wrapMode: Text.Wrap textFormat: Text.PlainText font.family: theme.fontFamily || "monospace" font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 color: theme.textColor } } } } } Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor } // Input RowLayout { Layout.fillWidth: true Layout.margins: 6 spacing: 4 ScrollView { id: promptScroll Layout.fillWidth: true Layout.maximumHeight: 200 Layout.preferredHeight: Math.min(promptInput.contentHeight + 16, 200) clip: true TextArea { id: promptInput Layout.fillWidth: true placeholderText: ollie.activeSessionId ? "Message..." : "No session" enabled: ollie.activeSessionId !== "" wrapMode: TextEdit.Wrap Keys.onReturnPressed: function(event) { if (event.modifiers & Qt.ShiftModifier) { event.accepted = false } else { if (text.trim() !== "") { ollie.submit(text.trim()) text = "" } event.accepted = true } } } } } } } }