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 // 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) } } ollie.refreshSessions() buildSessionTree() } Connections { target: ollie function onActiveSessionIdChanged() { chatModel.reset() chatView.followTail = true if (ollie.activeSessionId && ollie.activeAgentId) { var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId) if (log) { chatModel.appendData(log) chatModel.trimFront(20) } } } function onActiveAgentIdChanged() { chatModel.reset() chatView.followTail = true if (ollie.activeSessionId && ollie.activeAgentId) { var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId) if (log) { chatModel.appendData(log) chatModel.trimFront(20) } } } } // --- 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 --- SettingsDialog { id: settingsDialog } // --- New Session Dialog --- NewSessionDialog { id: newSessionDialog } // --- New Agent Dialog --- NewAgentDialog { id: newAgentDialog targetSessionId: root.newAgentTargetSessionId } 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 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 !== "" property bool followTail: true reuseItems: true onCountChanged: { if (followTail) scrollTimer.restart() } onContentHeightChanged: { if (followTail) scrollTimer.restart() } onMovementEnded: followTail = atYEnd Timer { id: scrollTimer interval: 16 onTriggered: { if (chatView.followTail && chatView.contentHeight > chatView.height) chatView.positionViewAtEnd() } } delegate: ChatBlockDelegate { width: chatView.width - 8 parentListView: chatView } } // Scroll to bottom button Button { anchors.right: parent.right anchors.bottom: parent.bottom anchors.margins: 12 text: "↓" font.pixelSize: 16 implicitWidth: 36 implicitHeight: 36 visible: chatView.visible && !chatView.followTail onClicked: { chatView.followTail = true chatView.positionViewAtEnd() } ToolTip.text: "Scroll to bottom" ToolTip.visible: hovered } } 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() } } } } } } }