diff --git a/CMakeLists.txt b/CMakeLists.txt index 177dfeb..66e22e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -247,7 +247,7 @@ else() ) qt_add_resources(ollie-gui "gui" PREFIX "/" - FILES gui/main.qml gui/SessionGraph.qml gui/emojis.js + FILES gui/main.qml gui/ChatPane.qml gui/SessionGraph.qml gui/emojis.js ) # --- Install --- diff --git a/gui/ChatPane.qml b/gui/ChatPane.qml new file mode 100644 index 0000000..017aabf --- /dev/null +++ b/gui/ChatPane.qml @@ -0,0 +1,788 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.ollie.private 1.0 +import "emojis.js" as Emojis + +// Self-contained chat pane with own session binding +Item { + id: paneRoot + + property string sessionId: "" + property bool isFocused: false + + // Pane-local chat model + ChatBlockModel { id: paneModel } + + // Track chat state for this pane's session + property string paneState: "idle" + property string paneCwd: "" + property int chatOffset: 0 + + // Fetch chat when session changes + onSessionIdChanged: { + paneModel.rawText = "" + chatOffset = 0 + paneState = "idle" + paneCwd = "" + if (sessionId !== "") { + // Fetch initial chat and config + let chat = dbus.getChat(sessionId, 0) + if (chat) { + paneModel.rawText = chat.text + chatOffset = chat.offset + } + let cfg = dbus.getConfig(sessionId) + if (cfg && cfg.cwd) paneCwd = cfg.cwd + let st = dbus.getState(sessionId) + if (st) paneState = st + } + } + + // Listen for D-Bus signals filtered to our session + Connections { + target: dbus + function onChatAppended(sid, offset, text) { + if (sid !== paneRoot.sessionId) return + if (offset === paneRoot.chatOffset) { + paneModel.rawText = paneModel.rawText + text + paneRoot.chatOffset += text.length + } else { + // Offset mismatch - refetch + let chat = dbus.getChat(paneRoot.sessionId, 0) + if (chat) { + paneModel.rawText = chat.text + paneRoot.chatOffset = chat.offset + } + } + } + function onSessionStateUpdated(sid, newState) { + if (sid === paneRoot.sessionId) + paneRoot.paneState = newState + } + } + + // Visual focus indicator + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: paneRoot.isFocused ? palette.highlight : "transparent" + border.width: 2 + z: 100 + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 2 + spacing: 0 + + // Header + RowLayout { + Layout.fillWidth: true + Layout.margins: 6 + + Rectangle { + width: 8; height: 8; radius: 4 + color: { + if (paneRoot.paneState === "idle") return "#4caf50" + if (paneRoot.paneState.startsWith("calling")) return "#ff9800" + return "#2196f3" + } + } + + Label { + text: paneRoot.paneCwd || paneRoot.sessionId || "No session" + Layout.fillWidth: true + elide: Text.ElideMiddle + font.bold: true + font.pixelSize: Qt.application.font.pixelSize * 0.9 + } + + Label { + text: paneRoot.paneState + opacity: 0.6 + font.pixelSize: Qt.application.font.pixelSize * 0.8 + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } + + // Chat view + Item { + Layout.fillWidth: true + Layout.fillHeight: true + property bool atBottom: true + + Flickable { + id: paneFlick + anchors.fill: parent + anchors.margins: 4 + clip: true + contentWidth: width + contentHeight: paneColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + property bool _settingY: false + + onContentYChanged: { + if (_settingY) return + let bottom = contentHeight - height + parent.atBottom = (bottom <= 0 || contentY >= bottom - 4) + } + + onContentHeightChanged: { + if (parent.atBottom) { + _settingY = true + contentY = Math.max(0, contentHeight - height) + _settingY = false + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: function(wheel) { + paneFlick._settingY = true + paneFlick.contentY = Math.max(0, + Math.min(paneFlick.contentY - wheel.angleDelta.y, + paneFlick.contentHeight - paneFlick.height)) + paneFlick._settingY = false + let bottom = paneFlick.contentHeight - paneFlick.height + paneFlick.parent.atBottom = (bottom <= 0 || paneFlick.contentY >= bottom - 4) + wheel.accepted = true + } + } + + Column { + id: paneColumn + width: paneFlick.width + spacing: 8 + + Label { + visible: paneChatRepeater.count === 0 + text: paneRoot.sessionId ? "(waiting...)" : "No session" + color: palette.placeholderText + } + + Repeater { + id: paneChatRepeater + model: paneModel + delegate: Loader { + width: paneColumn.width + property var block: model + sourceComponent: { + switch (blockType) { + case "user": return userDelegate + case "assistant": return assistantDelegate + case "reasoning": return reasoningDelegate + case "call": return callDelegate + case "tool": return toolDelegate + case "error": return errorDelegate + case "info": return infoDelegate + default: return assistantDelegate + } + } + } + } + } + } + + ToolButton { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 6 + icon.name: "go-down" + visible: !parent.atBottom + onClicked: { + paneFlick.contentY = Math.max(0, paneFlick.contentHeight - paneFlick.height) + parent.atBottom = true + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } + + // Prompt + RowLayout { + Layout.fillWidth: true + Layout.margins: 6 + spacing: 4 + + TextField { + id: panePrompt + Layout.fillWidth: true + placeholderText: paneRoot.sessionId ? "Message..." : "No session" + enabled: paneRoot.sessionId !== "" + onAccepted: { + if (text.trim() !== "" && paneRoot.sessionId !== "") { + dbus.submit(paneRoot.sessionId, text.trim()) + text = "" + } + } + } + + ToolButton { + icon.name: "media-playback-stop" + enabled: paneRoot.paneState !== "idle" && paneRoot.sessionId !== "" + onClicked: dbus.interrupt(paneRoot.sessionId) + ToolTip.text: "Interrupt" + ToolTip.visible: hovered + } + } + + // Status + Label { + Layout.fillWidth: true + Layout.leftMargin: 6 + Layout.bottomMargin: 2 + font.pixelSize: Qt.application.font.pixelSize * 0.75 + opacity: 0.4 + text: paneRoot.sessionId || "no session" + } + } + + // Click to focus + MouseArea { + anchors.fill: parent + propagateComposedEvents: true + onPressed: function(mouse) { + paneRoot.forceActiveFocus() + mouse.accepted = false + } + } + + Component { + id: userDelegate + Item { + implicitHeight: userBubble.implicitHeight + userLabel.implicitHeight + 4 + + Rectangle { + id: userBubble + anchors.top: parent.top + anchors.right: parent.right + width: Math.max(160, Math.min(userContent.implicitWidth + 24, parent.width * 0.85)) + implicitHeight: userContent.implicitHeight + 16 + color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.15) + radius: 12 + + HoverHandler { id: userHover } + + ToolButton { + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: 4 + icon.name: "edit-copy" + implicitWidth: 32 + implicitHeight: 32 + opacity: userHover.hovered ? 0.8 : 0 + onClicked: paneModel.copyToClipboard(block.blockContent) + ToolTip.text: "Copy" + ToolTip.visible: hovered + + Behavior on opacity { NumberAnimation { duration: 150 } } + } + + ColumnLayout { + id: userContent + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Repeater { + model: block ? block.subBlocks : [] + delegate: Loader { + Layout.fillWidth: true + property var subBlock: modelData + sourceComponent: subBlock.type === "code" ? codeSubDelegate + : subBlock.type === "diff" ? diffSubDelegate + : subBlock.type === "interrupt" ? interruptSubDelegate + : proseSubDelegate + } + } + } + } + + Label { + id: userLabel + text: "You" + font.pixelSize: Qt.application.font.pixelSize * 0.75 + opacity: 0.5 + anchors.right: parent.right + anchors.top: userBubble.bottom + anchors.topMargin: 2 + } + } + } + + Component { + id: assistantDelegate + Item { + implicitHeight: assistantBubble.implicitHeight + assistantLabel.implicitHeight + 4 + + Rectangle { + id: assistantBubble + anchors.top: parent.top + anchors.left: parent.left + width: parent.width * 0.95 + implicitHeight: assistantContent.implicitHeight + 16 + color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.06) + radius: 12 + + HoverHandler { id: assistantHover } + + ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 4 + icon.name: "edit-copy" + implicitWidth: 32 + implicitHeight: 32 + opacity: assistantHover.hovered ? 0.8 : 0 + onClicked: paneModel.copyToClipboard(block.blockContent) + ToolTip.text: "Copy" + ToolTip.visible: hovered + + Behavior on opacity { NumberAnimation { duration: 150 } } + } + + ColumnLayout { + id: assistantContent + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Repeater { + model: block ? block.subBlocks : [] + delegate: Loader { + Layout.fillWidth: true + property var subBlock: modelData + sourceComponent: subBlock.type === "code" ? codeSubDelegate + : subBlock.type === "diff" ? diffSubDelegate + : subBlock.type === "interrupt" ? interruptSubDelegate + : proseSubDelegate + } + } + } + } + + Label { + id: assistantLabel + text: "Ollie" + font.pixelSize: Qt.application.font.pixelSize * 0.75 + opacity: 0.5 + anchors.left: parent.left + anchors.top: assistantBubble.bottom + anchors.topMargin: 2 + } + } + } + + Component { + id: reasoningDelegate + ColumnLayout { + spacing: 2 + Label { + text: (reasoningText.visible ? "\u25BC" : "\u25B6") + " reasoning" + font.pixelSize: Qt.application.font.pixelSize * 0.8 + opacity: 0.5 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: reasoningText.visible = !reasoningText.visible + } + } + Label { + id: reasoningText + Layout.fillWidth: true + text: (block ? block.blockContent : "") + wrapMode: Text.Wrap + font.family: "monospace" + font.pixelSize: Qt.application.font.pixelSize * 0.85 + opacity: 0.5 + visible: false + } + } + } + + Component { + id: callDelegate + Item { + implicitHeight: callLayout.implicitHeight + 12 + + HoverHandler { id: callHover } + + Rectangle { + anchors.fill: parent + color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.03) + + Rectangle { + width: 3 + height: parent.height + color: "#ff9800" + } + } + + ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 4 + icon.name: "edit-copy" + implicitWidth: 24 + implicitHeight: 24 + opacity: callHover.hovered ? 0.8 : 0 + onClicked: paneModel.copyToClipboard(block.blockContent) + ToolTip.text: "Copy" + ToolTip.visible: hovered + Behavior on opacity { NumberAnimation { duration: 150 } } + } + + ColumnLayout { + id: callLayout + x: 10 + width: parent.width - 20 + y: 6 + spacing: 4 + + Label { + text: "\u26A1 call: " + (block ? (block.blockName || "") : "") + font.pixelSize: Qt.application.font.pixelSize * 0.8 + color: "#ff9800" + } + + Repeater { + model: block ? block.subBlocks : [] + delegate: Loader { + Layout.fillWidth: true + property var subBlock: modelData + sourceComponent: subBlock.type === "code" ? codeSubDelegate + : subBlock.type === "diff" ? diffSubDelegate + : subBlock.type === "interrupt" ? interruptSubDelegate + : plainSubDelegate + } + } + } + } + } + + Component { + id: toolDelegate + Item { + implicitHeight: toolLayout.implicitHeight + 12 + + HoverHandler { id: toolHover } + + Rectangle { + anchors.fill: parent + color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.03) + + Rectangle { + width: 3 + height: parent.height + color: palette.mid + } + } + + ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 4 + icon.name: "edit-copy" + implicitWidth: 24 + implicitHeight: 24 + opacity: toolHover.hovered ? 0.8 : 0 + onClicked: paneModel.copyToClipboard(block.blockContent) + ToolTip.text: "Copy" + ToolTip.visible: hovered + Behavior on opacity { NumberAnimation { duration: 150 } } + } + + ColumnLayout { + id: toolLayout + x: 10 + width: parent.width - 20 + y: 6 + spacing: 4 + + Label { + text: "\u25C6 tool: " + (block ? (block.blockName || "") : "") + font.pixelSize: Qt.application.font.pixelSize * 0.8 + opacity: 0.6 + } + + Repeater { + model: block ? block.subBlocks : [] + delegate: Loader { + Layout.fillWidth: true + property var subBlock: modelData + sourceComponent: subBlock.type === "code" ? codeSubDelegate + : subBlock.type === "diff" ? diffSubDelegate + : subBlock.type === "interrupt" ? interruptSubDelegate + : plainSubDelegate + } + } + } + } + } + + Component { + id: errorDelegate + Rectangle { + implicitHeight: errorLabel.implicitHeight + 12 + color: Qt.rgba(1, 0, 0, 0.08) + radius: 4 + Label { + id: errorLabel + anchors.fill: parent + anchors.margins: 6 + text: (block ? block.blockContent : "") + wrapMode: Text.Wrap + color: "#e53935" + font.family: "monospace" + } + } + } + + Component { + id: infoDelegate + Label { + text: (block ? block.blockContent : "") + wrapMode: Text.Wrap + font.pixelSize: Qt.application.font.pixelSize * 0.85 + opacity: 0.6 + font.italic: true + } + } + + // --- Sub-block delegates --- + + Component { + id: proseSubDelegate + Text { + width: parent ? parent.width : implicitWidth + text: paneModel.linkifyFilePaths(subBlock.content || "") + wrapMode: Text.Wrap + textFormat: Text.MarkdownText + color: palette.text + // Qt 6 renders Markdown text noticeably smaller than Controls text. + font.pointSize: root.font.pointSize + 2 + font.family: root.font.family + lineHeight: 1.3 + onLinkActivated: function(link) { + paneModel.openLink(link) + } + } + } + + Component { + id: plainSubDelegate + Text { + width: parent ? parent.width : implicitWidth + text: subBlock.content || "" + wrapMode: Text.Wrap + textFormat: Text.PlainText + color: palette.text + font.family: "monospace" + font.pixelSize: Qt.application.font.pixelSize * 0.9 + lineHeight: 1.3 + } + } + + Component { + id: interruptSubDelegate + Rectangle { + width: parent ? parent.width : 200 + height: interruptCol.implicitHeight + 16 + color: theme.isDark ? "#3d2e00" : "#fff8e1" + radius: 4 + + Rectangle { + width: 3 + height: parent.height + color: "#ffc107" + radius: 2 + } + + ColumnLayout { + id: interruptCol + x: 10 + y: 8 + width: parent.width - 20 + spacing: 4 + + Label { + text: "\u26A0 user interrupt" + font.pixelSize: Qt.application.font.pixelSize * 0.8 + font.bold: true + color: "#ffc107" + } + + Text { + Layout.fillWidth: true + text: subBlock.content || "" + wrapMode: Text.Wrap + textFormat: Text.MarkdownText + color: palette.text + font.pointSize: root.font.pointSize + font.family: root.font.family + lineHeight: 1.3 + } + } + } + } + + Component { + id: codeSubDelegate + Rectangle { + implicitHeight: codeCol.implicitHeight + implicitWidth: Math.max(280, codeCol.implicitWidth) + color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6) + radius: 4 + border.width: 1 + border.color: palette.mid + + ColumnLayout { + id: codeCol + anchors.fill: parent + spacing: 0 + + // Header with language + copy/exec buttons + RowLayout { + Layout.fillWidth: true + Layout.margins: 4 + + Label { + text: subBlock.language || "code" + font.pixelSize: Qt.application.font.pixelSize * 0.8 + opacity: 0.6 + Layout.fillWidth: true + } + + ToolButton { + icon.name: "media-playback-start" + implicitWidth: 32 + implicitHeight: 32 + visible: { + let lang = (subBlock.language || "").toLowerCase() + return ["bash", "sh", "zsh", "fish", "python", "python3", + "py", "perl", "ruby", "rb", "lua", "node", + "javascript", "js", "php"].indexOf(lang) >= 0 + } + onClicked: paneModel.executeInTerminal(subBlock.content, subBlock.language, + dbus.activeCwd) + ToolTip.text: "Execute in terminal" + ToolTip.visible: hovered + } + + ToolButton { + icon.name: "edit-copy" + implicitWidth: 32 + implicitHeight: 32 + onClicked: { + paneModel.copyToClipboard(subBlock.content) + } + ToolTip.text: "Copy" + ToolTip.visible: hovered + } + } + + // Code content — show plain first, highlight lazily + Text { + id: codeText + Layout.fillWidth: true + Layout.margins: 8 + text: subBlock.content + textFormat: Text.PlainText + wrapMode: Text.Wrap + font.family: "monospace" + font.pixelSize: Qt.application.font.pixelSize * 0.9 + color: palette.text + + property bool highlighted: false + Timer { + interval: 150 + running: true + onTriggered: { + codeText.text = paneModel.highlightedHtml(subBlock.content, subBlock.language) + codeText.textFormat = Text.RichText + codeText.highlighted = true + } + } + } + } + } + } + + Component { + id: diffSubDelegate + Rectangle { + implicitHeight: diffCol.implicitHeight + color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6) + radius: 4 + border.width: 1 + border.color: palette.mid + + ColumnLayout { + id: diffCol + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.margins: 4 + + Label { + text: "diff" + font.pixelSize: Qt.application.font.pixelSize * 0.8 + opacity: 0.6 + Layout.fillWidth: true + } + + ToolButton { + icon.name: "edit-copy" + implicitWidth: 32 + implicitHeight: 32 + onClicked: { + paneModel.copyToClipboard(subBlock.content) + } + ToolTip.text: "Copy" + ToolTip.visible: hovered + } + } + + // Diff lines with coloring + Column { + Layout.fillWidth: true + Layout.margins: 8 + spacing: 0 + + Repeater { + model: subBlock.content.split("\n") + Rectangle { + width: parent.width + implicitHeight: diffLineText.implicitHeight + color: { + if (modelData.startsWith("+")) return Qt.rgba(0.3, 0.8, 0.3, 0.08) + if (modelData.startsWith("-")) return Qt.rgba(0.8, 0.3, 0.3, 0.08) + return "transparent" + } + Text { + id: diffLineText + width: parent.width + text: modelData + font.family: "monospace" + font.pixelSize: Qt.application.font.pixelSize * 0.9 + wrapMode: Text.Wrap + color: { + if (modelData.startsWith("+")) return "#4caf50" + if (modelData.startsWith("-")) return "#f44336" + if (modelData.startsWith("@@")) return "#2196f3" + return palette.text + } + } + } + } + } + } + } + } +} diff --git a/gui/main.cpp b/gui/main.cpp index ffc7ced..8590af5 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -120,6 +120,9 @@ int main(int argc, char *argv[]) return 0; } + // Register types for QML instantiation + qmlRegisterType("org.ollie.private", 1, 0, "ChatBlockModel"); + // Register OllieDBusClient as a singleton auto *dbus = new OllieDBusClient(&app); auto *chatModel = new ChatBlockModel(&app); diff --git a/gui/main.qml b/gui/main.qml index 68e68d3..769cb2e 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -365,974 +365,82 @@ ApplicationWindow { } } - // --- Main content --- - Pane { + // --- Main content: split pane area --- + SplitView { + id: mainSplit SplitView.fillWidth: true - padding: 0 + orientation: width > height ? Qt.Horizontal : Qt.Vertical - ColumnLayout { - anchors.fill: parent - spacing: 0 + // Track which pane is focused + property int focusedPaneIndex: 0 - // Header bar - RowLayout { - Layout.fillWidth: true - Layout.margins: 8 + // Initial pane + ChatPane { + id: initialPane + sessionId: dbus.activeSessionId + isFocused: mainSplit.focusedPaneIndex === 0 + SplitView.fillWidth: true + SplitView.fillHeight: true + onActiveFocusChanged: if (activeFocus) mainSplit.focusedPaneIndex = 0 + } - Rectangle { - width: 10 - height: 10 - radius: 5 - color: { - if (dbus.sessions.length === 0) return "gray" - let state = dbus.activeState - if (state === "idle") return "#4caf50" - if (state.startsWith("calling")) return "#ff9800" - return "#2196f3" - } + // Dynamic panes stored here + property var extraPanes: [] + } + } + + // --- Split pane shortcuts --- + Shortcut { + sequence: "Ctrl+*" + onActivated: { + let comp = Qt.createComponent("SessionGraph.qml") // placeholder + comp = Qt.createComponent("ChatPane.qml") + if (comp.status !== Component.Ready) { + console.warn("ChatPane.qml not ready:", comp.errorString()) + return + } + let pane = comp.createObject(mainSplit, { + sessionId: dbus.activeSessionId, + isFocused: false + }) + if (pane) { + pane.SplitView.fillWidth = true + pane.SplitView.fillHeight = true + mainSplit.extraPanes.push(pane) + mainSplit.focusedPaneIndex = mainSplit.extraPanes.length + pane.activeFocusChanged.connect(function() { + if (pane.activeFocus) { + let idx = mainSplit.extraPanes.indexOf(pane) + if (idx >= 0) mainSplit.focusedPaneIndex = idx + 1 } - - Label { - text: dbus.activeCwd || "No session selected" - Layout.fillWidth: true - elide: Text.ElideMiddle - font.bold: true - - MouseArea { - anchors.fill: parent - onDoubleClicked: { - if (dbus.activeSessionId !== "") { - root.openCwdDialog() - } - } - } - } - - Label { - text: { - let parts = [] - if (dbus.activeBackend) parts.push(dbus.activeBackend) - if (dbus.activeModel) parts.push(dbus.activeModel) - return parts.join(" / ") || "" - } - opacity: 0.5 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - visible: text !== "" - } - - Label { - text: dbus.activeState - opacity: 0.6 - } - } - - // Separator - Rectangle { - Layout.fillWidth: true - height: 1 - color: palette.mid - } - - // Chat view - // Chat view — structured blocks - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - property bool atBottom: true - - Binding { - target: chatModel - property: "rawText" - value: dbus.chatText || "" - } - - Flickable { - id: chatFlick - anchors.fill: parent - anchors.margins: 4 - clip: true - contentWidth: width - contentHeight: chatColumn.implicitHeight - boundsBehavior: Flickable.StopAtBounds - - // Guard: true while we're programmatically setting contentY - property bool _settingY: false - // Guard: true while content is streaming (height growing) - property bool _contentGrowing: false - - onContentYChanged: { - if (_settingY) return - let bottom = contentHeight - height - parent.atBottom = (bottom <= 0 || contentY >= bottom - 4) - - // Load earlier messages when scrolled near top - // Only on user scroll — not during content growth or programmatic scroll - if (!_contentGrowing && contentY < 100 && contentHeight > height && !parent.atBottom) { - let oldHeight = contentHeight - if (chatModel.loadEarlier()) { - _settingY = true - Qt.callLater(function() { - chatFlick.contentY += (chatFlick.contentHeight - oldHeight) - chatFlick._settingY = false - }) - } - } - } - - // Timer-based debounce: coalesce all contentHeight changes within - // a single frame (~16ms) to avoid rapid scroll/restore flickering - Timer { - id: scrollTimer - interval: 16 - repeat: false - onTriggered: { - chatFlick._contentGrowing = false - if (chatFlick.parent.atBottom) { - chatFlick._settingY = true - chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height) - chatFlick._settingY = false - } - } - } - - onContentHeightChanged: { - if (parent.atBottom) { - _contentGrowing = true - scrollTimer.restart() - } - } - - // Direct wheel scroll without inertia - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton - onWheel: function(wheel) { - let dy = wheel.angleDelta.y - chatFlick._settingY = true - chatFlick.contentY = Math.max(0, - Math.min(chatFlick.contentY - dy, - chatFlick.contentHeight - chatFlick.height)) - chatFlick._settingY = false - // Update atBottom - let bottom = chatFlick.contentHeight - chatFlick.height - chatFlick.parent.atBottom = (bottom <= 0 || chatFlick.contentY >= bottom - 4) - wheel.accepted = true - } - } - - Column { - id: chatColumn - width: chatFlick.width - spacing: 8 - - // Placeholder - Label { - visible: chatRepeater.count === 0 - text: dbus.activeSessionId - ? "(waiting for output...)" - : "Select or create a session" - color: palette.placeholderText - } - - Repeater { - id: chatRepeater - model: chatModel - - delegate: Loader { - width: chatColumn.width - property var block: model - sourceComponent: { - switch (blockType) { - case "user": return userDelegate - case "assistant": return assistantDelegate - case "reasoning": return reasoningDelegate - case "call": return callDelegate - case "tool": return toolDelegate - case "error": return errorDelegate - case "info": return infoDelegate - default: return assistantDelegate - } - } - } - } - } - } - - ToolButton { - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.margins: 6 - icon.name: "go-down" - visible: !parent.atBottom - onClicked: { - chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height) - parent.atBottom = true - } - ToolTip.text: "Scroll to bottom" - ToolTip.visible: hovered - } - } - - // Separator - Rectangle { - Layout.fillWidth: true - height: 1 - color: palette.mid - } - - // Prompt input - RowLayout { - Layout.fillWidth: true - Layout.margins: 8 - spacing: 6 - - ScrollView { - Layout.fillWidth: true - Layout.maximumHeight: 100 - - TextArea { - id: promptInput - placeholderText: dbus.activeSessionId - ? "Message..." - : "No active session" - enabled: dbus.activeSessionId !== "" - wrapMode: TextEdit.Wrap - Keys.onReturnPressed: function(event) { - if (event.modifiers & (Qt.ShiftModifier | Qt.ControlModifier)) { - promptInput.insert(promptInput.cursorPosition, "\n") - } else { - if (text.trim() !== "" && dbus.activeSessionId !== "") { - dbus.submit(dbus.activeSessionId, text.trim()) - text = "" - } - } - event.accepted = true - } - Keys.onPressed: function(event) { - if (event.key === Qt.Key_J && (event.modifiers & Qt.ControlModifier)) { - promptInput.insert(promptInput.cursorPosition, "\n") - event.accepted = true - } else if (emojiPopup.visible) { - if (event.key === Qt.Key_Down) { - emojiList.currentIndex = Math.min(emojiList.currentIndex + 1, emojiModel.count - 1) - event.accepted = true - } else if (event.key === Qt.Key_Up) { - emojiList.currentIndex = Math.max(emojiList.currentIndex - 1, 0) - event.accepted = true - } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Tab) { - if (emojiModel.count > 0) { - let item = emojiModel.get(emojiList.currentIndex) - applyEmoji(item.emoji, item.code) - event.accepted = true - } - } else if (event.key === Qt.Key_Escape) { - emojiPopup.visible = false - event.accepted = true - } - } - } - - Component.onCompleted: forceActiveFocus() - - // Emoji shortcode replacement + completion - property var quickEmojis: ({ - ";)": "\uD83D\uDE09", ":)": "\uD83D\uDE42", - ":D": "\uD83D\uDE03", ":(": "\uD83D\uDE1E", - ":P": "\uD83D\uDE1B", ":p": "\uD83D\uDE1B", - "<3": "\u2764\uFE0F", ":O": "\uD83D\uDE2E", - ":o": "\uD83D\uDE2E", "B)": "\uD83D\uDE0E", - ":*": "\uD83D\uDE18", "XD": "\uD83D\uDE06", - "xD": "\uD83D\uDE06", ":|": "\uD83D\uDE10", - ">:(": "\uD83D\uDE20", ":'(": "\uD83D\uDE22" - }) - property bool _replacing: false - property string _colonPrefix: "" - - function emojiQuery() { - let pos = cursorPosition - let t = text - // Find `:` before cursor with word chars after - for (let i = pos - 1; i >= Math.max(0, pos - 20); i--) { - if (t[i] === ':') { - let query = t.substring(i + 1, pos) - if (/^[a-zA-Z0-9_]+$/.test(query) && query.length >= 2) - return {start: i, query: query} - return null - } - if (!/[a-zA-Z0-9_]/.test(t[i])) return null - } - return null - } - - function applyEmoji(emoji, code) { - let q = emojiQuery() - if (!q) return - _replacing = true - let before = text.substring(0, q.start) - let after = text.substring(cursorPosition) - text = before + emoji + after - cursorPosition = before.length + emoji.length - _replacing = false - emojiPopup.visible = false - } - - onTextChanged: { - if (_replacing) return - let pos = cursorPosition - - // Quick emoticons (instant replace) - for (let len = 2; len <= Math.min(4, pos); len++) { - let candidate = text.substring(pos - len, pos) - if (quickEmojis[candidate] !== undefined) { - _replacing = true - let before = text.substring(0, pos - len) - let after = text.substring(pos) - let emoji = quickEmojis[candidate] - text = before + emoji + after - cursorPosition = before.length + emoji.length - _replacing = false - return - } - } - - // Extended :shortcode: completion - let q = emojiQuery() - if (q) { - let matches = Emojis.emojiList.filter(function(e) { - return e.code.indexOf(q.query) === 0 - }).slice(0, 8) - if (matches.length > 0) { - emojiModel.clear() - for (let m of matches) - emojiModel.append({code: m.code, emoji: m.emoji}) - emojiPopup.visible = true - emojiList.currentIndex = 0 - } else { - emojiPopup.visible = false - } - } else { - emojiPopup.visible = false - } - } - - - } - } - - ToolButton { - icon.name: "media-playback-stop" - enabled: dbus.activeState !== "idle" && dbus.activeSessionId !== "" - onClicked: dbus.interrupt(dbus.activeSessionId) - ToolTip.text: "Interrupt" - ToolTip.visible: hovered - } - // Emoji completion popup - ListModel { id: emojiModel } - - Popup { - id: emojiPopup - y: -height - 4 - x: 8 - width: 280 - height: Math.min(emojiList.contentHeight + 12, 240) - padding: 6 - visible: false - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - background: Rectangle { - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 6 - } - - ListView { - id: emojiList - anchors.fill: parent - model: emojiModel - clip: true - currentIndex: 0 - - delegate: ItemDelegate { - width: emojiList.width - height: 32 - highlighted: index === emojiList.currentIndex - - contentItem: RowLayout { - spacing: 8 - Label { - text: model.emoji - font.pixelSize: 18 - } - Label { - text: ":" + model.code + ":" - Layout.fillWidth: true - font.family: "monospace" - opacity: 0.8 - } - } - - onClicked: { - promptInput.applyEmoji(model.emoji, model.code) - } - } - } - } - } - - // Status bar - Label { - Layout.fillWidth: true - Layout.leftMargin: 8 - Layout.bottomMargin: 4 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - opacity: 0.5 - text: { - let id = dbus.activeSessionId - if (!id) return "ollie" - return dbus.activeState + " │ " + id - } - } + }) } } } - // --- Chat block delegates --- - - - - Component { - id: userDelegate - Item { - implicitHeight: userBubble.implicitHeight + userLabel.implicitHeight + 4 - - Rectangle { - id: userBubble - anchors.top: parent.top - anchors.right: parent.right - width: Math.max(160, Math.min(userContent.implicitWidth + 24, parent.width * 0.85)) - implicitHeight: userContent.implicitHeight + 16 - color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.15) - radius: 12 - - HoverHandler { id: userHover } - - ToolButton { - anchors.top: parent.top - anchors.left: parent.left - anchors.margins: 4 - icon.name: "edit-copy" - implicitWidth: 32 - implicitHeight: 32 - opacity: userHover.hovered ? 0.8 : 0 - onClicked: chatModel.copyToClipboard(block.blockContent) - ToolTip.text: "Copy" - ToolTip.visible: hovered - - Behavior on opacity { NumberAnimation { duration: 150 } } - } - - ColumnLayout { - id: userContent - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - - Repeater { - model: block ? block.subBlocks : [] - delegate: Loader { - Layout.fillWidth: true - property var subBlock: modelData - sourceComponent: subBlock.type === "code" ? codeSubDelegate - : subBlock.type === "diff" ? diffSubDelegate - : subBlock.type === "interrupt" ? interruptSubDelegate - : proseSubDelegate - } - } - } - } - - Label { - id: userLabel - text: "You" - font.pixelSize: Qt.application.font.pixelSize * 0.75 - opacity: 0.5 - anchors.right: parent.right - anchors.top: userBubble.bottom - anchors.topMargin: 2 - } + Shortcut { + sequence: "Ctrl+W" + onActivated: { + if (mainSplit.extraPanes.length === 0) return + // Remove the last extra pane (or focused one) + let idx = mainSplit.focusedPaneIndex - 1 + if (idx < 0) idx = mainSplit.extraPanes.length - 1 + let pane = mainSplit.extraPanes[idx] + mainSplit.extraPanes.splice(idx, 1) + pane.destroy() + mainSplit.focusedPaneIndex = 0 } } - Component { - id: assistantDelegate - Item { - implicitHeight: assistantBubble.implicitHeight + assistantLabel.implicitHeight + 4 - - Rectangle { - id: assistantBubble - anchors.top: parent.top - anchors.left: parent.left - width: parent.width * 0.95 - implicitHeight: assistantContent.implicitHeight + 16 - color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.06) - radius: 12 - - HoverHandler { id: assistantHover } - - ToolButton { - anchors.top: parent.top - anchors.right: parent.right - anchors.margins: 4 - icon.name: "edit-copy" - implicitWidth: 32 - implicitHeight: 32 - opacity: assistantHover.hovered ? 0.8 : 0 - onClicked: chatModel.copyToClipboard(block.blockContent) - ToolTip.text: "Copy" - ToolTip.visible: hovered - - Behavior on opacity { NumberAnimation { duration: 150 } } - } - - ColumnLayout { - id: assistantContent - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - - Repeater { - model: block ? block.subBlocks : [] - delegate: Loader { - Layout.fillWidth: true - property var subBlock: modelData - sourceComponent: subBlock.type === "code" ? codeSubDelegate - : subBlock.type === "diff" ? diffSubDelegate - : subBlock.type === "interrupt" ? interruptSubDelegate - : proseSubDelegate - } - } - } - } - - Label { - id: assistantLabel - text: "Ollie" - font.pixelSize: Qt.application.font.pixelSize * 0.75 - opacity: 0.5 - anchors.left: parent.left - anchors.top: assistantBubble.bottom - anchors.topMargin: 2 - } - } - } - - Component { - id: reasoningDelegate - ColumnLayout { - spacing: 2 - Label { - text: (reasoningText.visible ? "\u25BC" : "\u25B6") + " reasoning" - font.pixelSize: Qt.application.font.pixelSize * 0.8 - opacity: 0.5 - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: reasoningText.visible = !reasoningText.visible - } - } - Label { - id: reasoningText - Layout.fillWidth: true - text: (block ? block.blockContent : "") - wrapMode: Text.Wrap - font.family: "monospace" - font.pixelSize: Qt.application.font.pixelSize * 0.85 - opacity: 0.5 - visible: false - } - } - } - - Component { - id: callDelegate - Item { - implicitHeight: callLayout.implicitHeight + 12 - - HoverHandler { id: callHover } - - Rectangle { - anchors.fill: parent - color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.03) - - Rectangle { - width: 3 - height: parent.height - color: "#ff9800" - } - } - - ToolButton { - anchors.top: parent.top - anchors.right: parent.right - anchors.margins: 4 - icon.name: "edit-copy" - implicitWidth: 24 - implicitHeight: 24 - opacity: callHover.hovered ? 0.8 : 0 - onClicked: chatModel.copyToClipboard(block.blockContent) - ToolTip.text: "Copy" - ToolTip.visible: hovered - Behavior on opacity { NumberAnimation { duration: 150 } } - } - - ColumnLayout { - id: callLayout - x: 10 - width: parent.width - 20 - y: 6 - spacing: 4 - - Label { - text: "\u26A1 call: " + (block ? (block.blockName || "") : "") - font.pixelSize: Qt.application.font.pixelSize * 0.8 - color: "#ff9800" - } - - Repeater { - model: block ? block.subBlocks : [] - delegate: Loader { - Layout.fillWidth: true - property var subBlock: modelData - sourceComponent: subBlock.type === "code" ? codeSubDelegate - : subBlock.type === "diff" ? diffSubDelegate - : subBlock.type === "interrupt" ? interruptSubDelegate - : plainSubDelegate - } - } - } - } - } - - Component { - id: toolDelegate - Item { - implicitHeight: toolLayout.implicitHeight + 12 - - HoverHandler { id: toolHover } - - Rectangle { - anchors.fill: parent - color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.03) - - Rectangle { - width: 3 - height: parent.height - color: palette.mid - } - } - - ToolButton { - anchors.top: parent.top - anchors.right: parent.right - anchors.margins: 4 - icon.name: "edit-copy" - implicitWidth: 24 - implicitHeight: 24 - opacity: toolHover.hovered ? 0.8 : 0 - onClicked: chatModel.copyToClipboard(block.blockContent) - ToolTip.text: "Copy" - ToolTip.visible: hovered - Behavior on opacity { NumberAnimation { duration: 150 } } - } - - ColumnLayout { - id: toolLayout - x: 10 - width: parent.width - 20 - y: 6 - spacing: 4 - - Label { - text: "\u25C6 tool: " + (block ? (block.blockName || "") : "") - font.pixelSize: Qt.application.font.pixelSize * 0.8 - opacity: 0.6 - } - - Repeater { - model: block ? block.subBlocks : [] - delegate: Loader { - Layout.fillWidth: true - property var subBlock: modelData - sourceComponent: subBlock.type === "code" ? codeSubDelegate - : subBlock.type === "diff" ? diffSubDelegate - : subBlock.type === "interrupt" ? interruptSubDelegate - : plainSubDelegate - } - } - } - } - } - - Component { - id: errorDelegate - Rectangle { - implicitHeight: errorLabel.implicitHeight + 12 - color: Qt.rgba(1, 0, 0, 0.08) - radius: 4 - Label { - id: errorLabel - anchors.fill: parent - anchors.margins: 6 - text: (block ? block.blockContent : "") - wrapMode: Text.Wrap - color: "#e53935" - font.family: "monospace" - } - } - } - - Component { - id: infoDelegate - Label { - text: (block ? block.blockContent : "") - wrapMode: Text.Wrap - font.pixelSize: Qt.application.font.pixelSize * 0.85 - opacity: 0.6 - font.italic: true - } - } - - // --- Sub-block delegates --- - - Component { - id: proseSubDelegate - Text { - width: parent ? parent.width : implicitWidth - text: chatModel.linkifyFilePaths(subBlock.content || "") - wrapMode: Text.Wrap - textFormat: Text.MarkdownText - color: palette.text - // Qt 6 renders Markdown text noticeably smaller than Controls text. - font.pointSize: root.font.pointSize + 2 - font.family: root.font.family - lineHeight: 1.3 - onLinkActivated: function(link) { - chatModel.openLink(link) - } - } - } - - Component { - id: plainSubDelegate - Text { - width: parent ? parent.width : implicitWidth - text: subBlock.content || "" - wrapMode: Text.Wrap - textFormat: Text.PlainText - color: palette.text - font.family: "monospace" - font.pixelSize: Qt.application.font.pixelSize * 0.9 - lineHeight: 1.3 - } - } - - Component { - id: interruptSubDelegate - Rectangle { - width: parent ? parent.width : 200 - height: interruptCol.implicitHeight + 16 - color: theme.isDark ? "#3d2e00" : "#fff8e1" - radius: 4 - - Rectangle { - width: 3 - height: parent.height - color: "#ffc107" - radius: 2 - } - - ColumnLayout { - id: interruptCol - x: 10 - y: 8 - width: parent.width - 20 - spacing: 4 - - Label { - text: "\u26A0 user interrupt" - font.pixelSize: Qt.application.font.pixelSize * 0.8 - font.bold: true - color: "#ffc107" - } - - Text { - Layout.fillWidth: true - text: subBlock.content || "" - wrapMode: Text.Wrap - textFormat: Text.MarkdownText - color: palette.text - font.pointSize: root.font.pointSize - font.family: root.font.family - lineHeight: 1.3 - } - } - } - } - - Component { - id: codeSubDelegate - Rectangle { - implicitHeight: codeCol.implicitHeight - implicitWidth: Math.max(280, codeCol.implicitWidth) - color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6) - radius: 4 - border.width: 1 - border.color: palette.mid - - ColumnLayout { - id: codeCol - anchors.fill: parent - spacing: 0 - - // Header with language + copy/exec buttons - RowLayout { - Layout.fillWidth: true - Layout.margins: 4 - - Label { - text: subBlock.language || "code" - font.pixelSize: Qt.application.font.pixelSize * 0.8 - opacity: 0.6 - Layout.fillWidth: true - } - - ToolButton { - icon.name: "media-playback-start" - implicitWidth: 32 - implicitHeight: 32 - visible: { - let lang = (subBlock.language || "").toLowerCase() - return ["bash", "sh", "zsh", "fish", "python", "python3", - "py", "perl", "ruby", "rb", "lua", "node", - "javascript", "js", "php"].indexOf(lang) >= 0 - } - onClicked: chatModel.executeInTerminal(subBlock.content, subBlock.language, - dbus.activeCwd) - ToolTip.text: "Execute in terminal" - ToolTip.visible: hovered - } - - ToolButton { - icon.name: "edit-copy" - implicitWidth: 32 - implicitHeight: 32 - onClicked: { - chatModel.copyToClipboard(subBlock.content) - } - ToolTip.text: "Copy" - ToolTip.visible: hovered - } - } - - // Code content — show plain first, highlight lazily - Text { - id: codeText - Layout.fillWidth: true - Layout.margins: 8 - text: subBlock.content - textFormat: Text.PlainText - wrapMode: Text.Wrap - font.family: "monospace" - font.pixelSize: Qt.application.font.pixelSize * 0.9 - color: palette.text - - property bool highlighted: false - Timer { - interval: 150 - running: true - onTriggered: { - codeText.text = chatModel.highlightedHtml(subBlock.content, subBlock.language) - codeText.textFormat = Text.RichText - codeText.highlighted = true - } - } - } - } - } - } - - Component { - id: diffSubDelegate - Rectangle { - implicitHeight: diffCol.implicitHeight - color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6) - radius: 4 - border.width: 1 - border.color: palette.mid - - ColumnLayout { - id: diffCol - anchors.fill: parent - spacing: 0 - - RowLayout { - Layout.fillWidth: true - Layout.margins: 4 - - Label { - text: "diff" - font.pixelSize: Qt.application.font.pixelSize * 0.8 - opacity: 0.6 - Layout.fillWidth: true - } - - ToolButton { - icon.name: "edit-copy" - implicitWidth: 32 - implicitHeight: 32 - onClicked: { - chatModel.copyToClipboard(subBlock.content) - } - ToolTip.text: "Copy" - ToolTip.visible: hovered - } - } - - // Diff lines with coloring - Column { - Layout.fillWidth: true - Layout.margins: 8 - spacing: 0 - - Repeater { - model: subBlock.content.split("\n") - Rectangle { - width: parent.width - implicitHeight: diffLineText.implicitHeight - color: { - if (modelData.startsWith("+")) return Qt.rgba(0.3, 0.8, 0.3, 0.08) - if (modelData.startsWith("-")) return Qt.rgba(0.8, 0.3, 0.3, 0.08) - return "transparent" - } - Text { - id: diffLineText - width: parent.width - text: modelData - font.family: "monospace" - font.pixelSize: Qt.application.font.pixelSize * 0.9 - wrapMode: Text.Wrap - color: { - if (modelData.startsWith("+")) return "#4caf50" - if (modelData.startsWith("-")) return "#f44336" - if (modelData.startsWith("@@")) return "#2196f3" - return palette.text - } - } - } - } - } + // Sidebar click updates focused pane's session + Connections { + target: dbus + function onActiveSessionIdChanged() { + let idx = mainSplit.focusedPaneIndex + if (idx === 0) { + // initialPane is bound to dbus.activeSessionId already + } else if (idx - 1 < mainSplit.extraPanes.length) { + mainSplit.extraPanes[idx - 1].sessionId = dbus.activeSessionId } } } diff --git a/plasmoid/plugin/olliedbusclient.cpp b/plasmoid/plugin/olliedbusclient.cpp index 419f222..71f81f2 100644 --- a/plasmoid/plugin/olliedbusclient.cpp +++ b/plasmoid/plugin/olliedbusclient.cpp @@ -349,6 +349,43 @@ QStringList OllieDBusClient::peerList(const QString &sessionId) return {}; } +QVariantMap OllieDBusClient::getChat(const QString &sessionId, int offset) +{ + QDBusMessage msg = QDBusMessage::createMethodCall( + "org.ollie.SessionManager", "/org/ollie/SessionManager", + "org.ollie.SessionManager", "GetChat"); + msg << sessionId << (qlonglong)offset; + QDBusMessage reply = QDBusConnection::sessionBus().call(msg); + QVariantMap result; + if (reply.type() == QDBusMessage::ReplyMessage && reply.arguments().size() >= 2) { + result["text"] = reply.arguments()[0].toString(); + result["offset"] = reply.arguments()[1].toInt(); + } + return result; +} + +QVariantMap OllieDBusClient::getConfig(const QString &sessionId) +{ + QDBusReply reply = m_iface->call("GetConfig", sessionId); + QVariantMap result; + if (reply.isValid()) { + for (const QString &line : reply.value().split('\n')) { + int eq = line.indexOf('='); + if (eq > 0) + result[line.left(eq)] = line.mid(eq + 1); + } + } + return result; +} + +QString OllieDBusClient::getState(const QString &sessionId) +{ + QDBusReply reply = m_iface->call("GetState", sessionId); + if (reply.isValid()) + return reply.value(); + return "idle"; +} + bool OllieDBusClient::killSession(const QString &sessionId) { if (sessionId == m_activeSessionId) { @@ -453,6 +490,9 @@ void OllieDBusClient::onStateChanged(const QString &session_id, const QString &n void OllieDBusClient::onChatUpdated(const QString &session_id, qlonglong offset, const QString &new_text) { + // Emit for all panes listening to any session + emit chatAppended(session_id, static_cast(offset), new_text); + if (session_id != m_activeSessionId) return; // Append directly from the signal — no need to poll diff --git a/plasmoid/plugin/olliedbusclient.h b/plasmoid/plugin/olliedbusclient.h index ba24e92..c79349a 100644 --- a/plasmoid/plugin/olliedbusclient.h +++ b/plasmoid/plugin/olliedbusclient.h @@ -34,6 +34,9 @@ public: QVariantList sessions() const { return m_sessions; } Q_INVOKABLE QStringList peerList(const QString &sessionId); + Q_INVOKABLE QVariantMap getChat(const QString &sessionId, int offset); + Q_INVOKABLE QVariantMap getConfig(const QString &sessionId); + Q_INVOKABLE QString getState(const QString &sessionId); QString activeSessionId() const { return m_activeSessionId; } void setActiveSessionId(const QString &id); QString chatText() const { return m_chatText; } @@ -61,6 +64,7 @@ signals: void activeModelChanged(); void activeBackendChanged(); void sessionStateUpdated(const QString &sessionId, const QString &newState); + void chatAppended(const QString &sessionId, int offset, const QString &text); private slots: void onSessionCreated(const QString &session_id);