import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Item { id: chatPane property bool sidebarVisible: true property int maxVisibleBlocks: 80 property var pendingBypass: null // {sessionId, id, cmd, cwd} property bool hasPendingBypass: false signal toggleSidebar() // Do not opt the chat pane into focus navigation. activeFocusOnTab: false // Expose method to set prompt text (for Edit & Resend) function setPromptText(text) { promptInput.text = text promptInput.forceActiveFocus() } function scrollToBottom() { chatView.positionViewAtEnd() } function trimVisibleBlocks() { if (chatModel.count > chatPane.maxVisibleBlocks) chatModel.trimFront(chatPane.maxVisibleBlocks) } function loadChat() { chatModel.reset() if (ollie.activeSessionId && ollie.activeAgentId) { var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId) if (log) { chatModel.appendData(log) chatModel.trimFront(chatPane.maxVisibleBlocks) } } scrollToBottom() } Component.onCompleted: { loadChat() scrollToBottom() } ColumnLayout { anchors.fill: parent spacing: 0 // Header - shows active session/agent and state RowLayout { Layout.fillWidth: true Layout.margins: 6 spacing: 8 // Sidebar toggle button (larger touch target) ToolButton { text: chatPane.sidebarVisible ? "◀" : "▶" font.pixelSize: 14 implicitWidth: 32 implicitHeight: 32 onClicked: chatPane.toggleSidebar() ToolTip.text: chatPane.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 < sessionModel.count; ++i) { var item = sessionModel.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 } // Bypass request notification Rectangle { id: bypassBanner anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.margins: 8 height: bypassLayout.implicitHeight + 16 radius: 6 color: "#fff3e0" // amber light border.color: "#ff9800" border.width: 1 visible: chatPane.hasPendingBypass z: 100 ColumnLayout { id: bypassLayout anchors.fill: parent anchors.margins: 8 spacing: 6 Label { text: "⚠️ Elevation Request" font.bold: true font.pixelSize: theme.fontSize > 0 ? theme.fontSize + 2 : 14 color: "#e65100" } Label { text: chatPane.pendingBypass ? chatPane.pendingBypass.cmd : "" font.family: "monospace" font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12 wrapMode: Text.WrapAnywhere Layout.fillWidth: true color: "#333" } Label { text: chatPane.pendingBypass ? "cwd: " + chatPane.pendingBypass.cwd : "" font.pixelSize: theme.fontSize > 0 ? theme.fontSize - 1 : 10 color: "#666" visible: chatPane.pendingBypass && chatPane.pendingBypass.cwd !== "" } RowLayout { spacing: 8 Layout.topMargin: 4 Button { text: "Approve" highlighted: true onClicked: { if (chatPane.pendingBypass) { ollie.resolveBypass(chatPane.pendingBypass.sessionId, chatPane.pendingBypass.id, true) chatPane.pendingBypass = null chatPane.hasPendingBypass = false } } } Button { text: "Deny" onClicked: { if (chatPane.pendingBypass) { ollie.resolveBypass(chatPane.pendingBypass.sessionId, chatPane.pendingBypass.id, false) chatPane.pendingBypass = null chatPane.hasPendingBypass = false } } } } } } ListView { id: chatView model: chatModel spacing: 4 anchors.fill: parent anchors.margins: 4 visible: ollie.daemonConnected && ollie.activeAgentId !== "" onCountChanged: { if (!scrollButton.visible) chatPane.scrollToBottom() } onContentHeightChanged: { if (!scrollButton.visible) chatPane.scrollToBottom() } delegate: ChatBlockDelegate { width: chatView.width - 8 } } // Chat overview and navigation minimap Rectangle { id: chatMinimap anchors.top: chatView.top anchors.right: parent.right anchors.bottom: chatView.bottom width: 24 color: Qt.rgba(theme.midColor.r, theme.midColor.g, theme.midColor.b, 0.05) visible: chatView.visible && chatModel.count > 0 Column { anchors.fill: parent spacing: 1 Repeater { model: chatModel Rectangle { width: chatMinimap.width height: Math.max(2, chatMinimap.height / Math.max(1, chatModel.count) - 1) opacity: 0.65 color: { switch (model.blockType) { case "user": return "#4caf50" case "assistant": return "#2196f3" case "error": return "#f44336" case "tool": return "#ff9800" default: return theme.midColor } } } } } MouseArea { anchors.fill: parent onClicked: chatView.positionViewAtIndex( Math.floor(mouse.y / height * chatModel.count), ListView.Beginning) } } Button { id: scrollButton anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.margins: 12 text: "↓" visible: chatView.visible && !chatView.atYEnd onClicked: chatPane.scrollToBottom() ToolTip.text: "Scroll to bottom" ToolTip.visible: hovered } } Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor } // Action toolbar — quick actions without keyboard RowLayout { id: actionToolbar Layout.fillWidth: true Layout.margins: 4 spacing: 4 visible: ollie.daemonConnected && ollie.activeAgentId !== "" ToolButton { text: "⏹ Stop" font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 implicitHeight: 28 enabled: ollie.activeState !== "idle" onClicked: ollie.interrupt() ToolTip.visible: hovered ToolTip.text: "Stop the agent (Escape)" } ToolButton { text: "📦 Compact" font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 implicitHeight: 28 onClicked: ollie.submit("/compact") ToolTip.visible: hovered ToolTip.text: "Compact context window" } ToolButton { text: "📋 Copy Last" font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 implicitHeight: 28 enabled: chatModel.count > 0 onClicked: { // Find last assistant block and copy its content for (var i = chatModel.count - 1; i >= 0; i--) { var block = chatModel.get(i) if (block && block.blockType === "assistant") { // The renderedHtml is HTML, but we want plain text // For now, copy the HTML - TODO: strip tags clipboard.copyText(block.blockRenderedHtml.replace(/<[^>]*>/g, '')) break } } } ToolTip.visible: hovered ToolTip.text: "Copy last assistant response" } // Templates dropdown ToolButton { id: templateButton text: "📝 Templates ▾" font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 implicitHeight: 28 onClicked: templateMenu.popup() Menu { id: templateMenu MenuItem { text: "Yes"; onTriggered: ollie.submit("Yes") } MenuItem { text: "No"; onTriggered: ollie.submit("No") } MenuSeparator {} MenuItem { text: "Explain this"; onTriggered: promptInput.text = "Explain this:\n" } MenuItem { text: "Fix the bug"; onTriggered: promptInput.text = "Fix the bug in " } MenuItem { text: "Write tests"; onTriggered: promptInput.text = "Write tests for " } MenuItem { text: "Summarize"; onTriggered: promptInput.text = "Summarize " } MenuItem { text: "Refactor"; onTriggered: promptInput.text = "Refactor " } MenuItem { text: "Add comments"; onTriggered: promptInput.text = "Add comments to " } MenuSeparator {} MenuItem { text: "Continue"; onTriggered: ollie.submit("continue") } MenuItem { text: "Retry"; onTriggered: ollie.submit("retry") } } } Item { Layout.fillWidth: true } // Spacer } // Input area RowLayout { id: inputRow Layout.fillWidth: true Layout.margins: 6 spacing: 4 function doSubmit() { if (promptInput.text.trim() !== "") { ollie.submit(promptInput.text.trim()) promptInput.text = "" } } // Clear button ToolButton { text: "✕" font.pixelSize: 12 implicitWidth: 28 implicitHeight: 28 Layout.alignment: Qt.AlignBottom visible: promptInput.text.length > 0 onClicked: promptInput.text = "" ToolTip.visible: hovered ToolTip.text: "Clear input" } // TextArea in ScrollView for overflow, with mouse chording 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 "B1=sel B2=exec B3=plumb B1+B2=cut B1+B3=paste" } 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 { inputRow.doSubmit() event.accepted = true } } } } // Acme-style mouse chording overlay // B1 = select (handled by TextArea) // B2 = execute selection // B3 = plumb selection / context menu // B1+B2 = cut (snarf) // B1+B3 = paste MouseArea { id: chordArea anchors.fill: parent acceptedButtons: Qt.MiddleButton | Qt.RightButton propagateComposedEvents: true cursorShape: Qt.IBeamCursor // Track if B1 (left) is currently held for chord detection property bool leftHeld: false // We can't directly detect B1 here since TextArea handles it, // but we can check mouse.buttons in the handlers onPressed: function(mouse) { // Check if this is a chord (B1 already held) if (mouse.buttons & Qt.LeftButton) { leftHeld = true if (mouse.button === Qt.MiddleButton) { // B1+B2 = cut promptInput.cut() mouse.accepted = true return } else if (mouse.button === Qt.RightButton) { // B1+B3 = paste promptInput.paste() mouse.accepted = true return } } leftHeld = false mouse.accepted = false // Let it propagate for non-chord } onClicked: function(mouse) { // Only handle non-chord clicks if (leftHeld) { leftHeld = false return } if (mouse.button === Qt.MiddleButton) { // B2: Execute selection as prompt var sel = promptInput.selectedText if (sel && sel.trim() !== "") { ollie.submit(sel.trim()) } else { // No selection - paste from clipboard and execute? // Or just ignore. For now, ignore. } } else if (mouse.button === Qt.RightButton) { // B3: Plumb selection or show context menu var sel = promptInput.selectedText if (sel && sel.trim() !== "" && plumber.available()) { plumber.plumb(sel.trim()) } else { promptContextMenu.popup() } } } onReleased: { leftHeld = false } } Menu { id: promptContextMenu MenuItem { text: "Execute Selection (B2)" enabled: promptInput.selectedText.length > 0 onTriggered: { var sel = promptInput.selectedText if (sel && sel.trim() !== "") ollie.submit(sel.trim()) } } MenuItem { text: "Plumb Selection (B3)" enabled: promptInput.selectedText.length > 0 && plumber.available() onTriggered: { var sel = promptInput.selectedText if (sel && sel.trim() !== "") plumber.plumb(sel.trim()) } } MenuSeparator {} MenuItem { text: "Cut (B1+B2)" enabled: promptInput.selectedText.length > 0 onTriggered: promptInput.cut() } MenuItem { text: "Copy" enabled: promptInput.selectedText.length > 0 onTriggered: promptInput.copy() } MenuItem { text: "Paste (B1+B3)" enabled: promptInput.canPaste onTriggered: promptInput.paste() } MenuSeparator {} MenuItem { text: "Select All" onTriggered: promptInput.selectAll() } } } // Paste button (mouse-first workflow) ToolButton { text: "📋" font.pixelSize: 14 implicitWidth: 36 implicitHeight: 36 Layout.alignment: Qt.AlignBottom enabled: promptInput.enabled onClicked: promptInput.paste() ToolTip.visible: hovered ToolTip.text: "Paste from clipboard" } // Send button (larger touch target) Button { text: "▶" font.pixelSize: 16 Layout.alignment: Qt.AlignBottom Layout.preferredWidth: 48 Layout.preferredHeight: 48 focusPolicy: Qt.NoFocus enabled: promptInput.enabled && promptInput.text.trim() !== "" onClicked: inputRow.doSubmit() ToolTip.visible: hovered ToolTip.text: "Send (Enter)" } } } Connections { target: chatModel function onCountChanged() { chatPane.trimVisibleBlocks() } } Connections { target: ollie function onActiveSessionIdChanged() { if (!ollie.activeAgentId) chatPane.loadChat() } function onActiveAgentIdChanged() { if (ollie.activeAgentId) chatPane.loadChat() } function onBypassRequested(sessionId, id, cmd, cwd) { // Show bypass request immediately (no session filter - GUI is global) chatPane.pendingBypass = { sessionId: sessionId, id: id, cmd: cmd, cwd: cwd } chatPane.hasPendingBypass = true } } }