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 Shortcut { sequence: "Ctrl+B" onActivated: root.sidebarVisible = !root.sidebarVisible } Shortcut { sequence: "Escape" onActivated: { if (renamingIndex >= 0) { cancelRename() } else { // Always send stop - server-side Interrupt() is safe to call // on an idle agent (no-op). This avoids races where the GUI's // cached activeState is stale. ollie.interrupt() } } } // --- Slash command definitions --- readonly property var slashCommands: [ { name: "model", desc: "Set model" }, { name: "backend", desc: "Set backend" }, { name: "agent", desc: "Switch agent" }, { name: "stop", desc: "Stop current run" }, { name: "kill", desc: "Kill session" }, { name: "new", desc: "New session" }, { name: "compact", desc: "Compact context" }, { name: "help", desc: "Show help" } ] // Sidebar visibility property bool sidebarVisible: true // Deferred scroll timer — fires once after model changes settle Timer { id: scrollTimer interval: 16 // ~1 frame at 60fps, allows layout to settle repeat: false onTriggered: { if (chatView.shouldAutoScroll) chatView.positionViewAtEnd() } } // Session ID to kill — set on right-click, consumed by context menu property string killTargetSessionId: "" // Target session for "New Agent" dialog property string newAgentTargetSessionId: "" // Agent kill target — set on right-click property string killTargetAgentSessionId: "" property string killTargetAgentId: "" // Session tree model — flat list with level info ListModel { id: sessionTreeModel } // Cache for session expanded state (sessionId -> bool) property var sessionExpandedCache: ({}) // Cache of current model structure for diffing: { sessionId: { agents: [agentId, ...] } } property var modelCache: ({}) function buildSessionTree() { // Full rebuild - used on initial load var hadFocus = promptInput.activeFocus sessionTreeModel.clear() modelCache = {} var sessions = ollie.sessions // Sessions are already sorted: active first, paused last. // Insert group headers when paused state changes. var lastPaused = null for (var i = 0; i < sessions.length; ++i) { var sess = sessions[i] var isPaused = sess.paused || false // Insert header on transition if (lastPaused === null && !isPaused) { sessionTreeModel.append({ type: "header", level: 0, displayName: "Active" }) } else if (lastPaused === false && isPaused) { sessionTreeModel.append({ type: "header", level: 0, displayName: "Inactive" }) } else if (lastPaused === null && isPaused) { // All sessions are paused - show Inactive header sessionTreeModel.append({ type: "header", level: 0, displayName: "Inactive" }) } lastPaused = isPaused appendSessionToModel(sess) } if (hadFocus) promptInput.forceActiveFocus() } // Compute connection color from session data (avoids C++ call) function computeConnectionColor(sess) { var paused = sess.paused || false var connected = sess.connected || false if (paused && connected) return "yellow" // zombie if (paused) return "gray" // intentionally paused if (connected) return "green" // healthy return "red" // crashed/broken } function appendSessionToModel(sess) { var sessionId = sess.id || "" var sessionName = sess.name || sessionId var agents = sess.agents || [] var agentCount = agents.length var displayLabel = agentCount > 0 ? sessionName + " (" + agentCount + ")" : sessionName var wasExpanded = sessionExpandedCache[sessionId] !== undefined ? sessionExpandedCache[sessionId] : true sessionExpandedCache[sessionId] = wasExpanded var isPaused = sess.paused || false sessionTreeModel.append({ type: "session", id: sessionId, level: 0, expanded: wasExpanded, state: sess.state || "", model: sess.model || "", displayName: displayLabel, baseName: sessionName, connectionColor: computeConnectionColor(sess), paused: isPaused }) var agentIds = [] for (var j = 0; j < agents.length; ++j) { var agent = agents[j] // Use state from session/idx data directly (already contains agent state) sessionTreeModel.append({ type: "agent", parentId: sessionId, agentId: agent.id, level: 1, state: agent.state || "idle", displayName: agent.name, parentExpanded: wasExpanded, parentPaused: isPaused }) agentIds.push(agent.id) } modelCache[sessionId] = { agents: agentIds } } function updateSessionTree() { // Incremental update - diff old vs new var hadFocus = promptInput.activeFocus var sessions = ollie.sessions // Build lookup of new sessions var newSessionIds = {} for (var i = 0; i < sessions.length; ++i) { newSessionIds[sessions[i].id] = sessions[i] } // Build lookup of old sessions from cache var oldSessionIds = Object.keys(modelCache) // Find removed sessions and delete from model (iterate backwards to preserve indices) for (var idx = sessionTreeModel.count - 1; idx >= 0; --idx) { var item = sessionTreeModel.get(idx) if (item.type === "session" && !newSessionIds[item.id]) { // Remove session and all its agents removeSessionFromModel(idx, item.id) } } // Update existing sessions and add new ones for (var j = 0; j < sessions.length; ++j) { var sess = sessions[j] var sessionId = sess.id if (modelCache[sessionId]) { // Session exists - update properties updateSessionInModel(sess) } else { // New session - append at end appendSessionToModel(sess) } } if (hadFocus) promptInput.forceActiveFocus() } function findSessionIndex(sessionId) { for (var i = 0; i < sessionTreeModel.count; ++i) { var item = sessionTreeModel.get(i) if (item.type === "session" && item.id === sessionId) return i } return -1 } function removeSessionFromModel(sessionIdx, sessionId) { // Count agents to remove (they follow the session) var removeCount = 1 for (var i = sessionIdx + 1; i < sessionTreeModel.count; ++i) { var item = sessionTreeModel.get(i) if (item.type === "session") break if (item.type === "agent" && item.parentId === sessionId) removeCount++ } // Remove session + agents for (var r = 0; r < removeCount; ++r) { sessionTreeModel.remove(sessionIdx) } delete modelCache[sessionId] delete sessionExpandedCache[sessionId] } function updateSessionInModel(sess) { var sessionId = sess.id var sessionIdx = findSessionIndex(sessionId) if (sessionIdx < 0) return var sessionName = sess.name || sessionId var agents = sess.agents || [] var agentCount = agents.length var displayLabel = agentCount > 0 ? sessionName + " (" + agentCount + ")" : sessionName var isPaused = sess.paused || false var wasExpanded = sessionExpandedCache[sessionId] !== undefined ? sessionExpandedCache[sessionId] : true // Update session properties (use QML-computed color, avoids C++ call) sessionTreeModel.setProperty(sessionIdx, "displayName", displayLabel) sessionTreeModel.setProperty(sessionIdx, "baseName", sessionName) sessionTreeModel.setProperty(sessionIdx, "state", sess.state || "") sessionTreeModel.setProperty(sessionIdx, "model", sess.model || "") sessionTreeModel.setProperty(sessionIdx, "connectionColor", computeConnectionColor(sess)) sessionTreeModel.setProperty(sessionIdx, "paused", isPaused) // Build agent lookup var newAgentIds = {} for (var i = 0; i < agents.length; ++i) { newAgentIds[agents[i].id] = agents[i] } var oldAgentIds = modelCache[sessionId] ? modelCache[sessionId].agents : [] // Update/remove existing agents for (var idx = sessionIdx + 1; idx < sessionTreeModel.count; ++idx) { var item = sessionTreeModel.get(idx) if (item.type === "session") break if (item.type === "agent" && item.parentId === sessionId) { if (newAgentIds[item.agentId]) { // Update agent - use state from session/idx directly var agent = newAgentIds[item.agentId] sessionTreeModel.setProperty(idx, "state", agent.state || "idle") sessionTreeModel.setProperty(idx, "displayName", agent.name) sessionTreeModel.setProperty(idx, "parentPaused", isPaused) } else { // Remove agent sessionTreeModel.remove(idx) --idx // Adjust for removed item } } } // Add new agents (at end of session's agents) var insertIdx = sessionIdx + 1 for (var k = insertIdx; k < sessionTreeModel.count; ++k) { var checkItem = sessionTreeModel.get(k) if (checkItem.type === "session") break insertIdx = k + 1 } var newAgentList = [] for (var m = 0; m < agents.length; ++m) { var ag = agents[m] newAgentList.push(ag.id) var found = false for (var n = 0; n < oldAgentIds.length; ++n) { if (oldAgentIds[n] === ag.id) { found = true; break } } if (!found) { // Use state from session/idx directly sessionTreeModel.insert(insertIdx, { type: "agent", parentId: sessionId, agentId: ag.id, level: 1, state: ag.state || "idle", displayName: ag.name, parentExpanded: wasExpanded, parentPaused: isPaused }) } } modelCache[sessionId] = { agents: newAgentList } } function toggleExpand(index) { var item = sessionTreeModel.get(index) if (!item.expanded === undefined) return // only expandable items var newExpanded = !item.expanded var itemLevel = item.level || 0 sessionTreeModel.setProperty(index, "expanded", newExpanded) // Cache for sessions (by id) if (item.type === "session" && item.id) { sessionExpandedCache[item.id] = newExpanded } // Update all descendants: items with level > itemLevel until we hit same or lower level for (var i = index + 1; i < sessionTreeModel.count; ++i) { var child = sessionTreeModel.get(i) var childLevel = child.level || 0 if (childLevel <= itemLevel) break // end of descendants // Determine visibility: walk up to find if any ancestor is collapsed var visible = newExpanded if (visible) { // Check all ancestors between this item and the child for (var j = i - 1; j >= index; --j) { var ancestor = sessionTreeModel.get(j) var ancestorLevel = ancestor.level || 0 if (ancestorLevel < childLevel && ancestor.expanded === false) { visible = false break } if (ancestorLevel < childLevel) { childLevel = ancestorLevel // track the level we've checked up to } } } sessionTreeModel.setProperty(i, "parentExpanded", visible) } } function activateAgent(parentId, agentId) { ollie.switchAgent(parentId, agentId) } // Rename tracking property int renamingIndex: -1 property string renamingText: "" function startRename(index) { var item = sessionTreeModel.get(index) if (item.type === "session" || item.type === "agent") { renamingIndex = index // Use baseName for sessions (without agent count), displayName for agents renamingText = item.type === "session" ? (item.baseName || item.displayName) : item.displayName } } function commitRename() { if (renamingIndex < 0) return var item = sessionTreeModel.get(renamingIndex) var newName = renamingText.trim() // For sessions, compare against baseName (without count) var oldName = item.type === "session" ? (item.baseName || item.displayName) : item.displayName if (newName !== "" && newName !== oldName) { var success = false if (item.type === "session") { success = ollie.renameSession(item.id, newName) // Don't update displayName here - let refreshSessions rebuild with count } else if (item.type === "agent") { success = ollie.renameAgent(item.parentId, item.agentId, newName) if (success) { sessionTreeModel.set(renamingIndex, { agentId: newName, displayName: newName }) } } if (!success) { renameErrorDialog.itemType = item.type renameErrorDialog.attemptedName = newName renameErrorDialog.open() } } renamingIndex = -1 renamingText = "" } function cancelRename() { renamingIndex = -1 renamingText = "" } Connections { target: ollie function onSessionsChanged() { // Always do full rebuild since headers complicate incremental updates buildSessionTree() } function onAgentStateChanged(sessionId, agentId, state) { // Update only the specific agent in the model for (var i = 0; i < sessionTreeModel.count; ++i) { var item = sessionTreeModel.get(i) if (item.type === "agent" && item.parentId === sessionId && item.agentId === agentId) { sessionTreeModel.setProperty(i, "state", state) break } } } } Component.onCompleted: { if (ollie.activeAgentId) { var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId) if (log) { chatModel.appendData(log) chatModel.trimFront(20) } } scrollTimer.start() // Ensure we have the latest session list ollie.refreshSessions() buildSessionTree() } Connections { target: ollie function onActiveSessionIdChanged() { chatModel.reset() chatView.shouldAutoScroll = true // Reset scroll state on session switch if (ollie.activeSessionId && ollie.activeAgentId) { var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId) if (log) { chatModel.appendData(log) chatModel.trimFront(20) } } scrollTimer.start() } function onActiveAgentIdChanged() { chatModel.reset() chatView.shouldAutoScroll = true // Reset scroll state on agent switch if (ollie.activeSessionId && ollie.activeAgentId) { var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId) if (log) { chatModel.appendData(log) chatModel.trimFront(20) } } scrollTimer.start() } } // --- Rename Error Dialog --- Dialog { id: renameErrorDialog title: "Rename Failed" anchors.centerIn: parent width: 320 modal: true standardButtons: Dialog.Ok property string itemType: "session" property string attemptedName: "" contentItem: Label { text: "Could not rename " + renameErrorDialog.itemType + " to \"" + renameErrorDialog.attemptedName + "\".\n\nThe name may already be in use." wrapMode: Text.Wrap } } // --- 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: 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 { 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 = "" } SplitView { anchors.fill: parent // Left pane: session list ColumnLayout { id: sidebarPane SplitView.preferredWidth: root.sidebarVisible ? root.width * 0.15 : 0 SplitView.minimumWidth: root.sidebarVisible ? 300 : 0 SplitView.maximumWidth: root.sidebarVisible ? Infinity : 0 visible: root.sidebarVisible 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 focus: false activeFocusOnTab: false // Show placeholder when empty or disconnected Label { anchors.centerIn: parent text: ollie.daemonConnected ? "(no sessions)" : "(not connected)" color: theme.midColor font.italic: true font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 12 visible: sessionList.count === 0 || !ollie.daemonConnected activeFocusOnTab: false } delegate: ItemDelegate { width: sessionList.width // Guard against undefined during initial render height: { if (!type) return 0 if (type === "header") return 24 if (type === "agent" && !parentExpanded) return 0 return 28 } visible: height > 0 property int modelIndex: index property bool isHeader: type === "header" // Headers are not interactive enabled: !isHeader MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton hoverEnabled: false enabled: !parent.isHeader onClicked: function(mouse) { if (mouse.button === Qt.RightButton) { if (type === "session") { var item = sessionTreeModel.get(parent.modelIndex) if (item) root.killTargetSessionId = item.id sessionCtxMenu.popup() } else if (type === "agent") { var agentItem = sessionTreeModel.get(parent.modelIndex) if (agentItem) { root.killTargetAgentSessionId = agentItem.parentId root.killTargetAgentId = agentItem.agentId } agentCtxMenu.popup() } return } if (renamingIndex >= 0) return if (type === "session") { // Find first agent in this session and select it var foundAgent = null for (var i = 0; i < sessionTreeModel.count; ++i) { var item = sessionTreeModel.get(i) if (item.type === "agent" && item.parentId === id) { foundAgent = item break } } if (foundAgent) { // Select the first agent activateAgent(id, foundAgent.agentId) } else { // No agents - just select session (clears agent) ollie.setActiveSessionId(id) } toggleExpand(parent.modelIndex) } else if (type === "agent") { activateAgent(parentId, agentId) } } onDoubleClicked: { if (type === "session" || type === "agent") { startRename(parent.modelIndex) } } Menu { id: sessionCtxMenu MenuItem { text: "New Agent..." onClicked: { root.newAgentTargetSessionId = root.killTargetSessionId newAgentDialog.open() } } MenuItem { text: ollie.isSessionPaused(root.killTargetSessionId) ? "Resume" : "Pause" onClicked: { if (root.killTargetSessionId) { if (ollie.isSessionPaused(root.killTargetSessionId)) ollie.resumeSession(root.killTargetSessionId) else ollie.pauseSession(root.killTargetSessionId) } } } MenuItem { text: "Kill Session" onClicked: { if (root.killTargetSessionId) ollie.killSession(root.killTargetSessionId) ollie.refreshSessions() } } } Menu { id: agentCtxMenu MenuItem { text: "Kill" onClicked: { if (root.killTargetAgentSessionId && root.killTargetAgentId) ollie.killAgent(root.killTargetAgentSessionId, root.killTargetAgentId) ollie.refreshSessions() } } } } contentItem: RowLayout { anchors.fill: parent spacing: 0 // Indent + expand/collapse indicator Item { Layout.preferredWidth: type === "header" ? 8 : (level || 0) * 16 + 16 Layout.preferredHeight: type === "header" ? 24 : 28 Label { anchors.centerIn: parent text: type === "session" ? (expanded ? "▼" : "▶") : "" font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.7 : 10 color: theme.textColor opacity: 0.6 visible: type !== "header" } } // Connection status indicator (session rows only) Rectangle { Layout.preferredWidth: 8 Layout.preferredHeight: 8 Layout.alignment: Qt.AlignVCenter Layout.rightMargin: 6 radius: 4 color: connectionColor || "gray" visible: type === "session" } // Name area Item { Layout.fillWidth: true Layout.preferredHeight: type === "header" ? 24 : 28 TextField { anchors.fill: parent text: (modelIndex === renamingIndex) ? renamingText : "" visible: modelIndex === renamingIndex verticalAlignment: Text.AlignVCenter font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize color: theme.textColor background: Rectangle { color: theme.baseColor border.color: theme.highlightColor radius: 2 } onTextChanged: { if (modelIndex === renamingIndex) { renamingText = text } } Keys.onReturnPressed: commitRename() Keys.onEscapePressed: cancelRename() onAccepted: commitRename() onVisibleChanged: { if (visible) { forceActiveFocus() selectAll() } } } Label { anchors.fill: parent text: displayName || "" color: type === "header" ? theme.midColor : theme.textColor font.pointSize: theme.fontSize > 0 ? theme.fontSize * (type === "header" ? 0.85 : 1) : Qt.application.font.pointSize font.bold: type === "header" font.capitalization: type === "header" ? Font.AllUppercase : Font.MixedCase verticalAlignment: Text.AlignVCenter visible: modelIndex !== renamingIndex } } } highlighted: (type === "agent" && agentId === ollie.activeAgentId && parentId === ollie.activeSessionId) } } } // Right pane: chat + input ColumnLayout { spacing: 0 // Header - shows active session/agent and state RowLayout { Layout.fillWidth: true Layout.margins: 6 spacing: 8 // Sidebar toggle button ToolButton { text: root.sidebarVisible ? "◀" : "▶" font.pixelSize: 12 implicitWidth: 24 implicitHeight: 24 onClicked: root.sidebarVisible = !root.sidebarVisible ToolTip.text: root.sidebarVisible ? "Hide sidebar (Ctrl+B)" : "Show sidebar (Ctrl+B)" ToolTip.visible: hovered } // Active session/agent label Label { text: { var sessionName = "" var agentName = "" for (var i = 0; i < sessionTreeModel.count; ++i) { var item = sessionTreeModel.get(i) if (item.type === "session" && item.id === ollie.activeSessionId) { sessionName = item.displayName || item.id } if (item.type === "agent" && item.agentId === ollie.activeAgentId && item.parentId === ollie.activeSessionId) { agentName = item.displayName || item.agentId } } if (sessionName && agentName) { return sessionName + " / " + agentName } else if (sessionName) { return sessionName + " (no agent)" } return "(no session)" } font.bold: true font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize elide: Text.ElideMiddle Layout.fillWidth: true } // State indicator Rectangle { width: 10; height: 10; radius: 5 color: { if (!ollie.activeAgentId) return "#9e9e9e" // gray when no agent if (ollie.activeSessionId && ollie.isSessionPaused(ollie.activeSessionId)) return "#9e9e9e" // gray when paused if (ollie.activeState === "idle") return "#4caf50" if (ollie.activeState.startsWith("calling")) return "#ff9800" return "#2196f3" } } Label { text: { if (!ollie.activeAgentId) return "" if (ollie.activeSessionId && ollie.isSessionPaused(ollie.activeSessionId)) return "inactive" return ollie.activeState } opacity: 0.7 font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11 } } Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor } // Chat display — block-based ListView with scroll-to-bottom button Item { Layout.fillWidth: true Layout.fillHeight: true ScrollView { anchors.fill: parent clip: true // Placeholder when no agent selected or disconnected Label { anchors.centerIn: parent text: { if (!ollie.daemonConnected) return "Not connected" if (!ollie.activeSessionId) return "Select a session" return "Select an agent to view chat" } color: theme.midColor font.italic: true font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12 visible: !ollie.activeAgentId || !ollie.daemonConnected } ListView { id: chatView model: chatModel spacing: 4 anchors.fill: parent anchors.margins: 4 boundsBehavior: Flickable.StopAtBounds visible: ollie.daemonConnected && ollie.activeAgentId !== "" // Auto-scroll when near bottom (within ~50px or at end) // Reset to true on new blocks, user can scroll up to disable property bool shouldAutoScroll: true readonly property bool nearBottom: contentHeight - contentY - height < 50 onContentYChanged: { // User scrolled up - disable auto-scroll // User scrolled to bottom - re-enable if (dragging || flicking) { shouldAutoScroll = nearBottom || atYEnd } } onCountChanged: { // New block added - always scroll if we were following if (count > 0) { if (shouldAutoScroll) scrollTimer.start() } } Connections { target: chatModel function onDataChanged() { // Content updated (streaming) - scroll if following if (chatView.shouldAutoScroll && chatView.count > 0) scrollTimer.start() } } delegate: ColumnLayout { id: blockItem 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 } } } } } } } } // Scroll-to-bottom floating button Rectangle { id: scrollToBottomBtn width: 40 height: 40 radius: 20 color: theme.highlightColor opacity: 0.9 anchors.right: parent.right anchors.bottom: parent.bottom anchors.margins: 16 visible: !chatView.shouldAutoScroll && chatView.count > 0 && chatView.visible Label { anchors.centerIn: parent text: "↓" font.pixelSize: 20 font.bold: true color: theme.highlightedTextColor } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { chatView.shouldAutoScroll = true chatView.positionViewAtEnd() } } } } Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor } // Input RowLayout { Layout.fillWidth: true Layout.margins: 6 spacing: 4 // TextArea in ScrollView for overflow, with context menu overlay Item { Layout.fillWidth: true Layout.maximumHeight: 200 Layout.preferredHeight: Math.min(promptInput.contentHeight + 16, 200) clip: true ScrollView { id: promptScroll anchors.fill: parent ScrollBar.horizontal.policy: ScrollBar.AlwaysOff TextArea { id: promptInput width: promptScroll.width placeholderText: { if (!ollie.daemonConnected) return "Not connected" if (!ollie.activeSessionId) return "No session" if (!ollie.activeAgentId) return "No agent" if (ollie.isSessionPaused(ollie.activeSessionId)) return "Session paused" return "Message..." } enabled: ollie.daemonConnected && ollie.activeSessionId !== "" && ollie.activeAgentId !== "" && !ollie.isSessionPaused(ollie.activeSessionId) wrapMode: TextEdit.Wrap selectByMouse: true persistentSelection: true property string _lastText: "" property int _lastCursorPos: 0 onTextChanged: { _lastText = text _lastCursorPos = cursorPosition } Keys.onReturnPressed: function(event) { if (event.modifiers & Qt.ShiftModifier) { event.accepted = false } else { if (text.trim() !== "") { ollie.submit(text.trim()) text = "" } event.accepted = true } } } } // Context menu overlay - only catches right-click, on top of ScrollView MouseArea { anchors.fill: parent acceptedButtons: Qt.RightButton cursorShape: Qt.IBeamCursor onClicked: promptContextMenu.popup() } Menu { id: promptContextMenu MenuItem { text: "Cut" enabled: promptInput.selectedText.length > 0 onTriggered: promptInput.cut() } MenuItem { text: "Copy" enabled: promptInput.selectedText.length > 0 onTriggered: promptInput.copy() } MenuItem { text: "Paste" enabled: promptInput.canPaste onTriggered: promptInput.paste() } MenuSeparator {} MenuItem { text: "Select All" onTriggered: promptInput.selectAll() } } } } } } }