diff --git a/CMakeLists.txt b/CMakeLists.txt index 0d9a6ca..0645321 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,7 +39,7 @@ endif() if(OLLIE_KF5) # --- Qt5 / KF5 --- - find_package(Qt5 REQUIRED COMPONENTS Core DBus Widgets Quick Qml QuickControls2 Network) + find_package(Qt5 REQUIRED COMPONENTS Core Concurrent DBus Widgets Quick Qml QuickControls2 Network) # Override KDE_INSTALL_PLUGINDIR so kcoreaddons_add_plugin installs under # CMAKE_INSTALL_PREFIX (e.g. ~/.local/lib/qt5/plugins), not the system dir. @@ -114,32 +114,21 @@ if(OLLIE_KF5) # --- Plasmoid Package --- plasma_install_package(plasmoid org.ollie.plasmoid) - # --- GUI App --- + # --- GUI App (9P-based) --- configure_file(gui/main-kf5.qml ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/main.qml COPYONLY) - configure_file(gui/emojis.js ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/emojis.js COPYONLY) configure_file(gui/qml.qrc ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc COPYONLY) qt5_add_resources(GUI_KF5_QRC ${CMAKE_CURRENT_BINARY_DIR}/gui-kf5/qml.qrc) add_executable(ollie-gui gui/main.cpp - gui/thememanager.cpp - gui/chatblockmodel.cpp - plasmoid/plugin/olliedbusclient.cpp + gui/ollie9pclient.cpp ${GUI_KF5_QRC} ) - target_include_directories(ollie-gui PRIVATE - ${CMAKE_SOURCE_DIR}/plasmoid/plugin - ${CMAKE_SOURCE_DIR}/gui - ) + target_include_directories(ollie-gui PRIVATE ${CMAKE_SOURCE_DIR}/gui) target_link_libraries(ollie-gui Qt5::Core - Qt5::DBus Qt5::Widgets Qt5::Quick - Qt5::Qml Qt5::QuickControls2 - Qt5::Network - KF5::ConfigCore - KF5::SyntaxHighlighting ) # --- Install --- @@ -151,7 +140,7 @@ if(OLLIE_KF5) else() # --- Qt6 / KF6 --- - find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets Quick QuickControls2 Multimedia Network) + find_package(Qt6 REQUIRED COMPONENTS Core Concurrent DBus Widgets Quick QuickControls2 Multimedia Network) find_package(KF6StatusNotifierItem REQUIRED) find_package(KF6 REQUIRED COMPONENTS Runner Notifications CoreAddons Config) find_package(Plasma REQUIRED) @@ -236,6 +225,7 @@ else() ) target_link_libraries(ollie-gui Qt6::Core + Qt6::Concurrent Qt6::DBus Qt6::Widgets Qt6::Quick diff --git a/gui/ChatPane.qml b/gui/ChatPane.qml index 58fdceb..00024cd 100644 --- a/gui/ChatPane.qml +++ b/gui/ChatPane.qml @@ -61,11 +61,11 @@ Item { // Listen for D-Bus signals filtered to our session Connections { target: dbus - function onChatAppended(sid, offset, text) { + function onChatAppended(sid, offset, text, endOffset) { if (sid !== paneRoot.sessionId) return if (offset === paneRoot.chatOffset) { - paneModel.rawText = paneModel.rawText + text - paneRoot.chatOffset += text.length + paneModel.appendRawText(text) + paneRoot.chatOffset = endOffset } else { // Offset mismatch - refetch let chat = dbus.getChat(paneRoot.sessionId, 0) diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index 7e4b3cd..c43f204 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -26,19 +26,36 @@ #include #include #include +#include +#include +#include +#include static KSyntaxHighlighting::Repository *s_repo = nullptr; +static std::atomic s_repoReady{false}; -static KSyntaxHighlighting::Repository &repo() +static void initRepo() { - if (!s_repo) - s_repo = new KSyntaxHighlighting::Repository(); - return *s_repo; + s_repo = new KSyntaxHighlighting::Repository(); + s_repoReady.store(true, std::memory_order_release); +} + +static KSyntaxHighlighting::Repository *repoIfReady() +{ + if (s_repoReady.load(std::memory_order_acquire)) + return s_repo; + return nullptr; } ChatBlockModel::ChatBlockModel(QObject *parent) : QAbstractListModel(parent) { + connect(&m_parseWatcher, &QFutureWatcher>::finished, + this, &ChatBlockModel::onParseFinished); + + // Eagerly load syntax highlighting definitions on a background thread + // so the first highlightedHtml() call doesn't block the UI for ~4 seconds. + QtConcurrent::run([]() { initRepo(); }); } int ChatBlockModel::rowCount(const QModelIndex &parent) const @@ -76,8 +93,12 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const return block.responseId; case SubBlocksRole: if (block.cachedContent != block.content) { + QElapsedTimer st; st.start(); block.cachedSubBlocks = parseSubBlocks(block.content); block.cachedContent = block.content; + qint64 elapsed = st.elapsed(); + if (elapsed > 5) + qDebug() << "parseSubBlocks: row=" << index.row() << "type=" << (int)block.type << "contentLen=" << block.content.size() << "took" << elapsed << "ms"; } return block.cachedSubBlocks; case FormattedContentRole: { @@ -120,12 +141,37 @@ void ChatBlockModel::setRawText(const QString &text) { if (text == m_rawText) return; m_rawText = text; - reparse(); - if (!m_filterText.isEmpty()) - applyFilter(); + scheduleReparse(); emit rawTextChanged(); } +void ChatBlockModel::appendRawText(const QString &text) +{ + if (text.isEmpty()) return; + m_rawText += text; + + // Fast path: if we have blocks and the last one is a "tool" block, + // just extend its content directly without full reparse. + if (!m_allBlocks.isEmpty() && m_allBlocks.last().type == ChatBlock::Tool) { + m_allBlocks.last().content += text; + // Update visible window's last block too + if (!m_blocks.isEmpty() && m_blocks.last().type == ChatBlock::Tool) { + m_blocks.last().content += text; + m_blocks.last().cachedContent.clear(); // invalidate subBlocks cache + // Defer dataChanged to avoid blocking the signal handler + QTimer::singleShot(0, this, [this]() { + if (!m_blocks.isEmpty()) { + QModelIndex idx = index(m_blocks.size() - 1); + emit dataChanged(idx, idx); + } + }); + } + return; + } + + scheduleReparse(); +} + void ChatBlockModel::setFilterText(const QString &text) { if (text == m_filterText) return; @@ -151,13 +197,51 @@ void ChatBlockModel::applyFilter() endResetModel(); } -static constexpr int INITIAL_BLOCKS = 20; +static constexpr int INITIAL_BLOCKS = 8; static constexpr int LOAD_MORE_COUNT = 30; -void ChatBlockModel::reparse() +void ChatBlockModel::scheduleReparse() { - m_allBlocks = parseBlocks(m_rawText); + if (m_parseRunning) { + // A parse is already in flight — just mark dirty so we re-run when it finishes. + m_parseDirty = true; + return; + } + + m_parseRunning = true; + m_parseDirty = false; + + // Capture the current text for the worker thread. + QString text = m_rawText; + m_parseWatcher.setFuture(QtConcurrent::run([text]() { + QElapsedTimer pt; pt.start(); + auto result = ChatBlockModel::parseBlocks(text); + qint64 elapsed = pt.elapsed(); + if (elapsed > 50) + qDebug() << "parseBlocks (worker): textLen=" << text.size() << "blocks=" << result.size() << "took" << elapsed << "ms"; + return result; + })); +} + +void ChatBlockModel::onParseFinished() +{ + QElapsedTimer t; t.start(); + m_parseRunning = false; + + m_allBlocks = m_parseWatcher.result(); + qint64 e1 = t.elapsed(); updateVisibleWindow(); + qint64 e2 = t.elapsed(); + + if (!m_filterText.isEmpty()) + applyFilter(); + + qDebug() << "onParseFinished: getResult=" << e1 << "ms, updateVisible=" << (e2-e1) << "ms, total=" << e2 << "ms, blocks=" << m_allBlocks.size(); + + // If rawText changed while we were parsing, re-schedule immediately. + if (m_parseDirty) { + scheduleReparse(); + } } void ChatBlockModel::updateVisibleWindow() @@ -235,7 +319,7 @@ bool ChatBlockModel::loadEarlier() return true; } -QList ChatBlockModel::parseBlocks(const QString &text) const +QList ChatBlockModel::parseBlocks(const QString &text) { QList blocks; if (text.isEmpty()) return blocks; @@ -503,9 +587,20 @@ QString ChatBlockModel::highlightedHtml(const QString &code, const QString &lang if (cached != m_highlightCache.end()) return *cached; - auto def = repo().definitionForName(language); + QElapsedTimer t; t.start(); + + auto *repository = repoIfReady(); + if (!repository) { + // Repo still loading — return plain text, will be re-highlighted later + QString plain = QStringLiteral("
")
+            + code.toHtmlEscaped() + QStringLiteral("
"); + // Don't cache — will re-render when repo is ready + return plain; + } + + auto def = repository->definitionForName(language); if (!def.isValid()) { - def = repo().definitionForFileName("file." + language); + def = repository->definitionForFileName("file." + language); } if (!def.isValid()) { QString plain = QStringLiteral("
")
@@ -516,8 +611,8 @@ QString ChatBlockModel::highlightedHtml(const QString &code, const QString &lang
 
     bool dark = QGuiApplication::palette().color(QPalette::Window).lightnessF() < 0.5;
     auto theme = dark
-        ? repo().defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
-        : repo().defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
+        ? repository->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
+        : repository->defaultTheme(KSyntaxHighlighting::Repository::LightTheme);
 
     HtmlBuilder builder;
     builder.setDefinition(def);
@@ -534,6 +629,7 @@ QString ChatBlockModel::highlightedHtml(const QString &code, const QString &lang
     result += builder.html + QStringLiteral("
"); m_highlightCache.insert(cacheKey, result); + qDebug() << "highlightedHtml: CACHE MISS" << language << "len=" << code.size() << "took" << t.elapsed() << "ms"; return result; } @@ -830,6 +926,7 @@ static bool fetchFromMermaidServer(const QString &source, const QString &outPath QString ChatBlockModel::diagramUrl(const QString &source, const QString &language) const { + QElapsedTimer t; t.start(); if (source.isEmpty()) return QString(); QString lang = language.toLower().trimmed(); @@ -855,6 +952,8 @@ QString ChatBlockModel::diagramUrl(const QString &source, const QString &languag if (QFileInfo::exists(outPath)) return QUrl::fromLocalFile(outPath).toString(); + qDebug() << "diagramUrl: CACHE MISS for" << lang << "hash=" << hash << "- fetching..."; + if (lang == QStringLiteral("plantuml")) { // Try local PlantUML server first (localhost:8080) if (fetchFromPlantUmlServer(source, outPath)) diff --git a/gui/chatblockmodel.h b/gui/chatblockmodel.h index 1ebbd19..13f4ed4 100644 --- a/gui/chatblockmodel.h +++ b/gui/chatblockmodel.h @@ -4,6 +4,7 @@ #include #include #include +#include // A single block in the chat output struct ChatBlock { @@ -65,6 +66,8 @@ public: QString rawText() const { return m_rawText; } void setRawText(const QString &text); + Q_INVOKABLE void appendRawText(const QString &text); + QString filterText() const { return m_filterText; } void setFilterText(const QString &text); bool filtering() const { return !m_filterText.isEmpty(); } @@ -84,11 +87,14 @@ signals: void rawTextChanged(); void filterTextChanged(); +private slots: + void onParseFinished(); + private: - void reparse(); + void scheduleReparse(); void updateVisibleWindow(); void applyFilter(); - QList parseBlocks(const QString &text) const; + static QList parseBlocks(const QString &text); QVariantList parseSubBlocks(const QString &content) const; QString m_rawText; @@ -98,6 +104,11 @@ private: QList m_filteredBlocks; // filtered subset when filterText is active int m_visibleStart = 0; // index into m_allBlocks where visible window starts mutable QHash m_highlightCache; + + // Async reparse state + QFutureWatcher> m_parseWatcher; + bool m_parseDirty = false; // rawText changed while parse was in flight + bool m_parseRunning = false; // a parse is currently executing off-thread }; #endif // CHAT_BLOCK_MODEL_H diff --git a/gui/main-kf5.qml b/gui/main-kf5.qml index 60fc31e..d8816d8 100644 --- a/gui/main-kf5.qml +++ b/gui/main-kf5.qml @@ -1,2450 +1,173 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 -import QtQuick.Window 2.15 -import "emojis.js" as Emojis ApplicationWindow { id: root - title: dbus.activeCwd ? "Ollie \u2014 " + dbus.activeCwd : "Ollie" - width: 900 - height: 600 visible: true + width: 1000 + height: 600 + title: "Ollie" - Component.onCompleted: activator.windowOpened() + // Chat tail buffer — only keep last 8KB for display + property string chatTail: "" + readonly property int maxTail: 8192 - Component.onDestruction: { - activator.windowClosed() - if (activator.windowCount === 0 - && !(theme.closeToTray && systemTrayAvailable)) { - Qt.quit() - } + Component.onCompleted: { + // Load initial log content + var log = ollie.readLog() + if (log.length > maxTail) + log = log.slice(-maxTail) + chatTail = log } - onClosing: { - if (theme.closeToTray && systemTrayAvailable) { - close.accepted = false - root.hide() - } - } - - color: palette.window - font.family: theme.fontFamily || Qt.application.font.family - font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize - - palette { - window: theme.windowColor - windowText: theme.windowTextColor - base: theme.baseColor - alternateBase: theme.alternateBaseColor - text: theme.textColor - button: theme.buttonColor - buttonText: theme.buttonTextColor - highlight: theme.highlightColor - highlightedText: theme.highlightedTextColor - toolTipBase: theme.tooltipBaseColor - toolTipText: theme.tooltipTextColor - link: theme.linkColor - mid: theme.midColor - dark: theme.darkColor - light: theme.lightColor - shadow: theme.shadowColor - } - - // --- 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 - } - - ComboBox { - id: schemeCombo - Layout.fillWidth: true - model: ["System"].concat(theme.availableSchemes) - currentIndex: { - if (theme.mode === "system") return 0 - var idx = theme.availableSchemes.indexOf(theme.mode) - return idx >= 0 ? idx + 1 : 0 - } - onActivated: { - if (currentIndex === 0) { - theme.mode = "system" - } else { - theme.mode = theme.availableSchemes[currentIndex - 1] - } - } - } - - Label { - text: theme.isDark ? "Current: dark" : "Current: light" - opacity: 0.5 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - } - - // --- Font section --- - 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: { - var fam = theme.fontFamily || Qt.application.font.family - var 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: { - var st = theme.fontStyle - var 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() - } - - // --- Close to tray section --- - Rectangle { - Layout.fillWidth: true - height: 1 - color: palette.mid - Layout.topMargin: 8 - } - - Label { - text: "System Tray" - font.bold: true - } - - CheckBox { - text: "Close to tray instead of quitting" - checked: theme.closeToTray - onToggled: theme.closeToTray = checked - enabled: systemTrayAvailable - } - - Label { - text: systemTrayAvailable - ? "Window will hide to the system tray when closed." - : "System tray is not available." - opacity: 0.5 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - wrapMode: Text.Wrap - Layout.fillWidth: true - visible: theme.closeToTray || !systemTrayAvailable + Connections { + target: ollie + function onChatReceived(text) { + chatTail += text + if (chatTail.length > maxTail * 2) { + chatTail = chatTail.slice(-maxTail) } } - } - - function openNewSessionDialog() { - nameInput.text = "" - remoteInput.text = "" - newSessionDialog.backendModel = dbus.listBackends() - newSessionDialog.agentModel = dbus.listAgents() - newSessionDialog.cwdField.text = dbus.activeCwd || "" - newSessionDialog.backendCombo.currentIndex = 0 - newSessionDialog.agentCombo.currentIndex = 0 - newSessionDialog.modelCombo.editText = "" - // Populate model list for the default (first) backend - if (newSessionDialog.backendCombo.currentText) - newSessionDialog.modelCombo.model = dbus.listModelsByBackend(newSessionDialog.backendCombo.currentText) - newSessionDialog.open() - } - - Dialog { - id: newSessionDialog - title: "New Session" - modal: true - anchors.centerIn: parent - width: 450 - standardButtons: Dialog.Ok | Dialog.Cancel - - property alias cwdField: cwdInput - property alias backendCombo: backendSelect - property alias agentCombo: agentSelect - property alias modelCombo: modelSelect - property var backendModel: [] - property var agentModel: [] - - onAccepted: { - var cwd = cwdInput.text.trim() - if (cwd === "") cwd = dbus.activeCwd || "" - var backend = backendSelect.currentText - var model = modelSelect.editText.trim() || modelSelect.currentText - var agent = agentSelect.currentText - var name = nameInput.text.trim() - var remote = remoteInput.text.trim() - dbus.createSession(cwd, backend, model, name, agent, remote) - } - - GridLayout { - columns: 2 - width: parent.width - columnSpacing: 10 - rowSpacing: 8 - - Label { text: "Name:" } - TextField { - id: nameInput - Layout.fillWidth: true - placeholderText: "(auto-generated)" - } - - Label { text: "Working directory:" } - RowLayout { - Layout.fillWidth: true - TextField { - id: cwdInput - Layout.fillWidth: true - placeholderText: "/home/user/project" - } - Button { - text: "Browse" - onClicked: { - var path = theme.chooseFolder("Choose working directory", cwdInput.text) - if (path !== "") - cwdInput.text = path - } - } - } - - Label { text: "Backend:" } - ComboBox { - id: backendSelect - Layout.fillWidth: true - model: newSessionDialog.backendModel - onCurrentTextChanged: { - if (currentText) - modelSelect.model = dbus.listModelsByBackend(currentText) - } - } - - Label { text: "Agent:" } - ComboBox { - id: agentSelect - Layout.fillWidth: true - model: newSessionDialog.agentModel - currentIndex: 0 - } - - Label { text: "Model:" } - ComboBox { - id: modelSelect - Layout.fillWidth: true - editable: true - editText: "" - } - - Label { text: "Remote:" } - TextField { - id: remoteInput - Layout.fillWidth: true - placeholderText: "user@host (empty = local)" - } + function onActiveSessionIdChanged() { + chatTail = "" + var log = ollie.readLog() + if (log.length > maxTail) + log = log.slice(-maxTail) + chatTail = log } - } - - function openCwdDialog() { - var path = theme.chooseFolder("Change working directory", - dbus.activeCwd || "") - if (path !== "") - dbus.setConfig(dbus.activeSessionId, "cwd", path) - } - - // --- Session Graph Window --- - Window { - id: graphWindow - title: "Ollie \u2014 Session Topology" - width: 700 - height: 500 - visible: false - color: palette.window - - Item { - id: graphRoot - anchors.fill: parent - clip: true - - property var nodePositions: ({}) - property real panX: 0 - property real panY: 0 - property real zoom: 1.0 - property real minZoom: 0.3 - property real maxZoom: 3.0 - - function stateColor(state) { - if (state === "idle") return "#4caf50" - if (state && state.startsWith("calling")) return "#ff9800" - if (state === "thinking") return "#2196f3" - return "#2196f3" - } - - function layoutNodes() { - var positions = {} - var count = dbus.sessions.length - if (count === 0) { nodePositions = positions; graphCanvas.requestPaint(); return } - - var cx = width / 2 - var cy = height / 2 - var radius = Math.min(width, height) * 0.35 - - if (count === 1) { - positions[dbus.sessions[0].id] = {x: cx, y: cy} - } else { - for (var i = 0; i < count; i++) { - var angle = (2 * Math.PI * i / count) - Math.PI / 2 - positions[dbus.sessions[i].id] = { - x: cx + radius * Math.cos(angle), - y: cy + radius * Math.sin(angle) - } - } - } - nodePositions = positions - graphCanvas.requestPaint() - } - - Connections { - target: dbus - function onSessionsChanged() { graphRoot.layoutNodes() } - } - onWidthChanged: layoutNodes() - onHeightChanged: layoutNodes() - Component.onCompleted: layoutNodes() - - MouseArea { - anchors.fill: parent - property real lastX: 0 - property real lastY: 0 - onPressed: { lastX = mouse.x; lastY = mouse.y } - onPositionChanged: { - if (pressed) { - graphRoot.panX += (mouse.x - lastX) - graphRoot.panY += (mouse.y - lastY) - lastX = mouse.x - lastY = mouse.y - } - } - onWheel: { - var factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87 - var newZoom = Math.max(graphRoot.minZoom, Math.min(graphRoot.maxZoom, graphRoot.zoom * factor)) - // Zoom toward mouse position - var mx = wheel.x - graphRoot.panX - var my = wheel.y - graphRoot.panY - graphRoot.panX -= mx * (newZoom / graphRoot.zoom - 1) - graphRoot.panY -= my * (newZoom / graphRoot.zoom - 1) - graphRoot.zoom = newZoom - } - } - - Item { - x: graphRoot.panX - y: graphRoot.panY - width: parent.width - height: parent.height - scale: graphRoot.zoom - transformOrigin: Item.TopLeft - - Canvas { - id: graphCanvas - anchors.fill: parent - onPaint: { - var ctx = getContext("2d") - ctx.clearRect(0, 0, width, height) - ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.4) - ctx.lineWidth = 2 - - for (var i = 0; i < dbus.sessions.length; i++) { - var sid = dbus.sessions[i].id - var peers = dbus.peerList(sid) - var fromPos = graphRoot.nodePositions[sid] - if (!fromPos) continue - - for (var j = 0; j < peers.length; j++) { - var toPos = graphRoot.nodePositions[peers[j]] - if (!toPos) continue - - ctx.beginPath() - ctx.moveTo(fromPos.x, fromPos.y) - ctx.lineTo(toPos.x, toPos.y) - ctx.stroke() - - var dx = toPos.x - fromPos.x - var dy = toPos.y - fromPos.y - var len = Math.sqrt(dx * dx + dy * dy) - if (len < 1) continue - var ux = dx / len - var uy = dy / len - var ax = toPos.x - ux * 30 - var ay = toPos.y - uy * 30 - var arrowSize = 8 - ctx.beginPath() - ctx.moveTo(ax, ay) - ctx.lineTo(ax - arrowSize * ux + arrowSize * 0.5 * uy, - ay - arrowSize * uy - arrowSize * 0.5 * ux) - ctx.lineTo(ax - arrowSize * ux - arrowSize * 0.5 * uy, - ay - arrowSize * uy + arrowSize * 0.5 * ux) - ctx.closePath() - ctx.fillStyle = Qt.rgba(0.5, 0.5, 0.5, 0.6) - ctx.fill() - } - } - } - } - - Repeater { - model: dbus.sessions - Rectangle { - id: graphNode - width: 120 - height: 50 - radius: 8 - color: palette.base - border.color: graphRoot.stateColor(modelData.state) - border.width: modelData.id === dbus.activeSessionId ? 3 : 2 - x: { - var pos = graphRoot.nodePositions[modelData.id] - return pos ? pos.x - width / 2 : 0 - } - y: { - var pos = graphRoot.nodePositions[modelData.id] - return pos ? pos.y - height / 2 : 0 - } - - SequentialAnimation on opacity { - running: modelData.state !== "idle" - loops: Animation.Infinite - NumberAnimation { to: 0.7; duration: 600; easing.type: Easing.InOutSine } - NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutSine } - } - - Column { - anchors.centerIn: parent - spacing: 2 - Label { - text: modelData.id - font.bold: true - font.pixelSize: 11 - anchors.horizontalCenter: parent.horizontalCenter - elide: Text.ElideRight - width: graphNode.width - 12 - horizontalAlignment: Text.AlignHCenter - } - Label { - text: modelData.state || "idle" - font.pixelSize: 9 - opacity: 0.7 - anchors.horizontalCenter: parent.horizontalCenter - color: graphRoot.stateColor(modelData.state) - } - } - - MouseArea { - anchors.fill: parent - onClicked: dbus.activeSessionId = modelData.id - cursorShape: Qt.PointingHandCursor - } - } - } - } - - Label { - anchors.centerIn: parent - text: "No active sessions" - opacity: 0.5 - visible: dbus.sessions.length === 0 - } + function onStreamingDone() { + // Session was killed + chatTail += "\n[session ended]\n" } } SplitView { anchors.fill: parent - orientation: Qt.Horizontal - // --- Session sidebar --- - Pane { - SplitView.preferredWidth: 220 - SplitView.minimumWidth: 160 - padding: 0 - - ColumnLayout { - anchors.fill: parent - spacing: 0 - - // Toolbar - RowLayout { - Layout.fillWidth: true - Layout.margins: 6 - - Label { - text: "Sessions" - font.bold: true - Layout.fillWidth: true - } - - ToolButton { - icon.name: "list-add" - onClicked: root.openNewSessionDialog() - ToolTip.text: "New session" - ToolTip.visible: hovered - } - - ToolButton { - icon.name: "configure" - onClicked: settingsDialog.open() - ToolTip.text: "Settings" - ToolTip.visible: hovered - } - - ToolButton { - icon.name: "network-workgroup" - onClicked: graphWindow.visible = !graphWindow.visible - ToolTip.text: "Session topology" - ToolTip.visible: hovered - } - } - - // Session list - ListView { - id: sessionList - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - model: dbus.sessions - - delegate: ItemDelegate { - id: sessionDelegate - width: sessionList.width - highlighted: modelData.id === dbus.activeSessionId - - property bool renaming: false - property string sessionState: modelData.state - - Connections { - target: dbus - function onSessionStateUpdated(sessionId, newState) { - if (sessionId === modelData.id) - sessionDelegate.sessionState = newState - } - } - - background: Rectangle { - color: sessionDelegate.highlighted - ? palette.highlight : "transparent" - radius: 4 - } - - contentItem: RowLayout { - spacing: 6 - - Rectangle { - width: 8 - height: 8 - radius: 4 - color: { - var state = sessionDelegate.sessionState - if (state === "idle") return "#4caf50" - if (state.startsWith("calling")) return "#ff9800" - return "#2196f3" - } - } - - Label { - id: sessionLabel - text: modelData.id - Layout.fillWidth: true - elide: Text.ElideRight - font.family: "monospace" - color: sessionDelegate.highlighted - ? palette.highlightedText : palette.text - visible: !sessionDelegate.renaming - } - - TextField { - id: renameField - Layout.fillWidth: true - font.family: "monospace" - visible: sessionDelegate.renaming - onAccepted: { - var newName = text.trim() - if (newName !== "" && newName !== modelData.id) { - dbus.renameSession(modelData.id, newName) - } - sessionDelegate.renaming = false - } - Keys.onEscapePressed: sessionDelegate.renaming = false - onVisibleChanged: { - if (visible) { - text = modelData.id - selectAll() - forceActiveFocus() - } - } - onActiveFocusChanged: { - if (!activeFocus && sessionDelegate.renaming) { - sessionDelegate.renaming = false - } - } - } - - Label { - text: modelData.agent - opacity: 0.6 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - color: sessionDelegate.highlighted - ? palette.highlightedText : palette.text - visible: !sessionDelegate.renaming - } - - ToolButton { - icon.name: "process-stop" - implicitWidth: 24 - implicitHeight: 24 - visible: !sessionDelegate.renaming - onClicked: dbus.killSession(modelData.id) - ToolTip.text: "Kill session" - ToolTip.visible: hovered - } - } - - onClicked: dbus.activeSessionId = modelData.id - onDoubleClicked: sessionDelegate.renaming = true - } - } - } - } - - // --- Main content: tiling pane area --- - Pane { - id: mainContent - SplitView.fillWidth: true - padding: 0 - - property int focusedPaneIndex: 0 - property var panes: [] - property int paneCount: 0 - - // BSP tree: each node is {pane: item} or {split: 'h'|'v', left: node, right: node} - property var bspTree: null - property real gap: 2 - - function layoutTree(node, x, y, w, h) { - if (!node) return - if (node.pane) { - node.pane.x = x - node.pane.y = y - node.pane.width = w - node.pane.height = h - } else if (node.split) { - if (node.split === 'h') { - var lw = (w - gap) / 2 - layoutTree(node.left, x, y, lw, h) - layoutTree(node.right, x + lw + gap, y, w - lw - gap, h) - } else { - var th = (h - gap) / 2 - layoutTree(node.left, x, y, w, th) - layoutTree(node.right, x, y + th + gap, w, h - th - gap) - } - } - } - - function relayout() { - if (bspTree) - layoutTree(bspTree, 0, 0, width, height) - } - - function splitNode(tree, targetPane, newPane, direction) { - if (!tree) return null - if (tree.pane === targetPane) { - return {split: direction, left: {pane: targetPane}, right: {pane: newPane}} - } - if (tree.split) { - var newLeft = splitNode(tree.left, targetPane, newPane, direction) - if (newLeft) { tree.left = newLeft; return tree } - var newRight = splitNode(tree.right, targetPane, newPane, direction) - if (newRight) { tree.right = newRight; return tree } - } - return null - } - - function removeNode(tree, targetPane) { - if (!tree) return null - if (tree.pane === targetPane) return null - if (tree.split) { - if (tree.left && tree.left.pane === targetPane) return tree.right - if (tree.right && tree.right.pane === targetPane) return tree.left - var newLeft = removeNode(tree.left, targetPane) - if (newLeft !== tree.left) { tree.left = newLeft; return tree } - var newRight = removeNode(tree.right, targetPane) - if (newRight !== tree.right) { tree.right = newRight; return tree } - } - return tree - } - - onWidthChanged: relayout() - onHeightChanged: relayout() - onPaneCountChanged: relayout() - - // Container for panes (absolute positioning) - Item { - id: paneContainer - anchors.fill: parent - } - - Component.onCompleted: { - var pane = paneCmp.createObject(paneContainer, {sessionId: dbus.activeSessionId}) - panes = [pane] - paneCount = 1 - bspTree = {pane: pane} - relayout() - } - } - } - - // --- Window shortcuts --- - Shortcut { - sequence: "Ctrl+Q" - onActivated: Qt.quit() - } - - Shortcut { - sequence: "Ctrl+N" - onActivated: activator.NewWindow() - } - - // --- Split pane shortcuts --- - Shortcut { - sequence: "Ctrl+\\" - onActivated: { - var focusedPane = mainContent.panes[mainContent.focusedPaneIndex] - if (!focusedPane) return - - var pane = paneCmp.createObject(paneContainer, { - sessionId: dbus.activeSessionId, - isFocused: false - }) - if (!pane) return - - var dir = focusedPane.width >= focusedPane.height ? 'h' : 'v' - mainContent.bspTree = mainContent.splitNode(mainContent.bspTree, focusedPane, pane, dir) - - var panes = mainContent.panes - var insertAt = mainContent.focusedPaneIndex + 1 - panes.splice(insertAt, 0, pane) - mainContent.panes = panes - mainContent.paneCount = panes.length - mainContent.focusedPaneIndex = insertAt - mainContent.relayout() - } - } - - Shortcut { - sequence: "Ctrl+W" - onActivated: { - if (mainContent.panes.length <= 1) return - var idx = mainContent.focusedPaneIndex - if (idx < 0 || idx >= mainContent.panes.length) - idx = mainContent.panes.length - 1 - var pane = mainContent.panes[idx] - - mainContent.bspTree = mainContent.removeNode(mainContent.bspTree, pane) - - var panes = mainContent.panes - panes.splice(idx, 1) - mainContent.panes = panes - mainContent.paneCount = panes.length - pane.destroy() - mainContent.focusedPaneIndex = Math.min(idx, panes.length - 1) - mainContent.relayout() - } - } - - Shortcut { - sequence: "Ctrl+F" - onActivated: { - var pane = mainContent.panes[mainContent.focusedPaneIndex] - if (!pane) return - var fb = pane.children[0] // ColumnLayout - // Toggle via the filterBar id inside the pane - // Since filterBar is inside the Component, access via pane property - pane.toggleFilter() - } - } - - // Handle pane swap: find target pane at drop coordinates - function handlePaneSwap(sourcePane, rootX, rootY) { - for (var i = 0; i < mainContent.panes.length; i++) { - var p = mainContent.panes[i] - if (p === sourcePane) continue - var localPt = p.mapFromItem(null, rootX, rootY) - if (localPt.x >= 0 && localPt.x <= p.width && - localPt.y >= 0 && localPt.y <= p.height) { - var tmp = sourcePane.sessionId - sourcePane.sessionId = p.sessionId - p.sessionId = tmp - return - } - } - } - - // Sidebar click updates focused pane's session - Connections { - target: dbus - function onActiveSessionIdChanged() { - var idx = mainContent.focusedPaneIndex - if (idx >= 0 && idx < mainContent.panes.length) { - mainContent.panes[idx].sessionId = dbus.activeSessionId - } - } - function onErrorOccurred(message) { - errorDialog.text = message - errorDialog.open() - } - } - - Dialog { - id: errorDialog - title: "Error" - modal: true - anchors.centerIn: parent - standardButtons: Dialog.Ok - property alias text: errorLabel.text - Label { - id: errorLabel - wrapMode: Text.Wrap - width: parent.width - } - } - - // --- Inline ChatPane Component --- - Component { - id: paneCmp - - Item { - id: paneRoot - - property string sessionId: "" - property bool isFocused: false - - signal startedDrag() - signal endedDrag(real globalX, real globalY) - - // Pane-local chat model - property var paneModel: chatModel // fallback; replaced on creation - property string paneState: "idle" - property string paneCwd: "" - property int chatOffset: 0 - - // Per-session draft text storage - property var _drafts: ({}) - property string _prevSessionId: "" - - Component.onCompleted: { - // Create a dedicated ChatBlockModel for this pane - paneModel = Qt.createQmlObject( - 'import org.ollie.gui 1.0; ChatBlockModel {}', paneRoot, "paneModel") - if (sessionId !== "") refreshPaneChat() - } - - function toggleFilter() { - filterBar.visible = !filterBar.visible - if (filterBar.visible) { - filterInput.forceActiveFocus() - filterInput.selectAll() - } else { - paneModel.filterText = "" - filterInput.text = "" - } - } - - function refreshPaneChat() { - paneModel.rawText = "" - chatOffset = 0 - paneState = "idle" - paneCwd = "" - if (sessionId !== "") { - var chat = dbus.getChat(sessionId, 0) - if (chat) { - paneModel.rawText = chat.text - chatOffset = chat.offset - - } - var cfg = dbus.getConfig(sessionId) - if (cfg && cfg.cwd) paneCwd = cfg.cwd - var st = dbus.getState(sessionId) - if (st) paneState = st - } - } - - onSessionIdChanged: { - // Save draft from previous session - if (_prevSessionId !== "") { - _drafts[_prevSessionId] = panePrompt.text - } - refreshPaneChat() - // Restore draft for new session (or empty) - if (sessionId !== "") { - panePrompt.text = _drafts[sessionId] || "" - } else { - panePrompt.text = "" - } - _prevSessionId = sessionId - } - - Connections { - target: dbus - function onChatAppended(sid, offset, text) { - if (sid !== paneRoot.sessionId) return - if (offset === paneRoot.chatOffset) { - paneRoot.paneModel.rawText = paneRoot.paneModel.rawText + text - paneRoot.chatOffset += text.length - } else { - var chat = dbus.getChat(paneRoot.sessionId, 0) - if (chat) { - paneRoot.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 ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.4) : "transparent" - border.width: 1 - z: 100 - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 2 - spacing: 0 - - // Header (drag handle) - Rectangle { - id: paneHeader - Layout.fillWidth: true - implicitHeight: headerRow.implicitHeight + 12 - color: "transparent" - - MouseArea { - id: paneHeaderDrag - anchors.fill: parent - property bool dragging: false - cursorShape: dragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor - - onPositionChanged: { - if (pressed && !dragging) { - dragging = true - paneRoot.startedDrag() - } - } - onReleased: { - if (dragging) { - var rootPt = paneHeaderDrag.mapToItem(null, mouse.x, mouse.y) - paneRoot.endedDrag(rootPt.x, rootPt.y) - } - dragging = false - } - } - - RowLayout { - id: headerRow - anchors.fill: parent - anchors.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 } - - // Filter bar (Ctrl+F) - RowLayout { - id: filterBar - Layout.fillWidth: true - Layout.margins: 4 - spacing: 4 - visible: false - - TextField { - id: filterInput - Layout.fillWidth: true - placeholderText: "Filter messages..." - onTextChanged: paneRoot.paneModel.filterText = text - Keys.onEscapePressed: { - filterBar.visible = false - paneRoot.paneModel.filterText = "" - text = "" - } - } - - Label { - text: paneRoot.paneModel.filtering ? paneRoot.paneModel.rowCount() + " matches" : "" - opacity: 0.6 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - } - - ToolButton { - icon.name: "window-close" - onClicked: { - filterBar.visible = false - paneRoot.paneModel.filterText = "" - filterInput.text = "" - } - } - } - - // 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 - var bottom = contentHeight - height - parent.atBottom = (bottom <= 0 || contentY >= bottom - 4) - - // Lazy-load earlier blocks when near top - if (contentY < 80 && contentY >= 0 && !_settingY) { - var oldHeight = contentHeight - if (paneRoot.paneModel.loadEarlier()) { - _settingY = true - contentY += (contentHeight - oldHeight) - _settingY = false - } - } - } - - onContentHeightChanged: { - if (parent.atBottom) { - _settingY = true - contentY = Math.max(0, contentHeight - height) - _settingY = false - } - } - - ScrollBar.vertical: ScrollBar { - id: chatScrollBar - policy: ScrollBar.AsNeeded - contentItem: Rectangle { - implicitWidth: 6 - radius: 3 - color: chatScrollBar.pressed ? palette.highlight - : chatScrollBar.hovered ? palette.mid - : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, chatScrollBar.active ? 0.6 : 0.0) - Behavior on color { ColorAnimation { duration: 150 } } - } - } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton - onWheel: { - paneFlick._settingY = true - paneFlick.contentY = Math.max(0, - Math.min(paneFlick.contentY - wheel.angleDelta.y, - paneFlick.contentHeight - paneFlick.height)) - paneFlick._settingY = false - var bottom = paneFlick.contentHeight - paneFlick.height - paneFlick.parent.atBottom = (bottom <= 0 || paneFlick.contentY >= bottom - 4) - } - } - - Column { - id: paneColumn - width: paneFlick.width - 12 // leave room for scrollbar - spacing: 8 - - Label { - visible: paneChatRepeater.count === 0 - text: paneRoot.sessionId ? "(waiting...)" : "No session" - color: theme.placeholderTextColor - } - - Repeater { - id: paneChatRepeater - model: paneRoot.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 - } - ToolTip.text: "Scroll to bottom" - ToolTip.visible: hovered - } - } - - Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } - - // Prompt input - RowLayout { - Layout.fillWidth: true - Layout.margins: 6 - spacing: 4 - - ScrollView { - Layout.fillWidth: true - Layout.maximumHeight: 100 - - TextArea { - id: panePrompt - placeholderText: paneRoot.sessionId ? "Message..." : "No session" - enabled: paneRoot.sessionId !== "" - wrapMode: TextEdit.Wrap - - 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 - - function emojiQuery() { - var pos = cursorPosition - var t = text - for (var i = pos - 1; i >= Math.max(0, pos - 20); i--) { - if (t[i] === ':') { - var 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) { - var q = emojiQuery() - if (!q) return - _replacing = true - var before = text.substring(0, q.start) - var after = text.substring(cursorPosition) - text = before + emoji + after - cursorPosition = before.length + emoji.length - _replacing = false - emojiPopup.visible = false - } - - onTextChanged: { - if (_replacing) return - var pos = cursorPosition - - // Quick emoticons - for (var len = 2; len <= Math.min(4, pos); len++) { - var candidate = text.substring(pos - len, pos) - if (quickEmojis[candidate] !== undefined) { - _replacing = true - var before = text.substring(0, pos - len) - var after = text.substring(pos) - var emoji = quickEmojis[candidate] - text = before + emoji + after - cursorPosition = before.length + emoji.length - _replacing = false - return - } - } - - // :shortcode: completion popup - var q = emojiQuery() - if (q) { - var matches = Emojis.emojiList.filter(function(e) { - return e.code.indexOf(q.query) === 0 - }).slice(0, 8) - if (matches.length > 0) { - emojiModel.clear() - for (var mi = 0; mi < matches.length; mi++) - emojiModel.append({code: matches[mi].code, emoji: matches[mi].emoji}) - emojiPopup.visible = true - emojiList.currentIndex = 0 - } else { - emojiPopup.visible = false - } - } else { - emojiPopup.visible = false - } - } - - Keys.onReturnPressed: { - if (emojiPopup.visible && emojiModel.count > 0) { - var item = emojiModel.get(emojiList.currentIndex) - applyEmoji(item.emoji, item.code) - event.accepted = true - return - } - if (event.modifiers & (Qt.ShiftModifier | Qt.ControlModifier)) { - panePrompt.insert(panePrompt.cursorPosition, "\n") - } else { - if (text.trim() !== "" && paneRoot.sessionId !== "") { - dbus.submit(paneRoot.sessionId, text.trim()) - text = "" - } - } - event.accepted = true - } - - Keys.onPressed: { - if (event.key === Qt.Key_J && (event.modifiers & Qt.ControlModifier)) { - panePrompt.insert(panePrompt.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) { - var eItem = emojiModel.get(emojiList.currentIndex) - applyEmoji(eItem.emoji, eItem.code) - event.accepted = true - } - } else if (event.key === Qt.Key_Escape) { - emojiPopup.visible = false - event.accepted = true - } - } - } - } - } - - // Emoji popup - ListModel { id: emojiModel } - Popup { - id: emojiPopup - y: -height - 4 - x: 8 - width: 260 - height: Math.min(emojiList.contentHeight + 12, 200) - 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: 28 - highlighted: index === emojiList.currentIndex - contentItem: RowLayout { - spacing: 8 - Label { text: model.emoji; font.pixelSize: 16 } - Label { text: ":" + model.code + ":"; Layout.fillWidth: true; font.family: "monospace"; opacity: 0.8 } - } - onClicked: panePrompt.applyEmoji(model.emoji, model.code) - } - } - } - - ToolButton { - icon.name: "media-playback-stop" - enabled: paneRoot.paneState !== "idle" && paneRoot.sessionId !== "" - onClicked: dbus.interrupt(paneRoot.sessionId) - ToolTip.text: "Interrupt" - ToolTip.visible: hovered - } - - ToolButton { - icon.name: "process-stop" - visible: paneRoot.paneState.startsWith("calling:") - onClicked: dbus.detachProcess(paneRoot.sessionId) - ToolTip.text: "Detach process" - ToolTip.visible: hovered - } - - // Detached process badge - ToolButton { - id: detachBadge - visible: detachBadge.count > 0 - property int count: 0 - text: "⚙ " + count - font.pixelSize: Qt.application.font.pixelSize * 0.85 - onClicked: processManagerWindow.show() - ToolTip.text: count + " detached process" + (count !== 1 ? "es" : "") - ToolTip.visible: hovered - - Timer { - interval: 2000 - running: paneRoot.sessionId !== "" - repeat: true - triggeredOnStart: true - onTriggered: { - var procs = dbus.listDetached(paneRoot.sessionId) - detachBadge.count = procs.length - } - } - } - } - - // 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: update focused pane index on click - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton - propagateComposedEvents: true - onPressed: { - mouse.accepted = false - for (var i = 0; i < mainContent.panes.length; i++) { - if (mainContent.panes[i] === paneRoot) { - mainContent.focusedPaneIndex = i - break - } - } - } - } - - // Ctrl+L focuses the prompt input (like browser URL bar) - Shortcut { - sequence: "Ctrl+L" - onActivated: panePrompt.forceActiveFocus() - } - } - } - - // --- 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 - z: 10 - 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 === "diagram" ? diagramSubDelegate - : subBlock.type === "diff" ? diffSubDelegate - : subBlock.type === "interrupt" ? interruptSubDelegate - : subBlock.type === "video" ? videoSubDelegate - : 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 - z: 10 - 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 === "diagram" ? diagramSubDelegate - : subBlock.type === "diff" ? diffSubDelegate - : subBlock.type === "interrupt" ? interruptSubDelegate - : subBlock.type === "video" ? videoSubDelegate - : 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 + // Left pane: session list 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 + SplitView.preferredWidth: 200 + SplitView.minimumWidth: 120 + spacing: 0 + + ToolBar { 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 - - property bool expanded: false - - 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 - z: 10 - 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: (expanded ? "\u25BC" : "\u25B6") + " \u26A1 call: " + (block ? (block.blockName || "") : "") - font.pixelSize: Qt.application.font.pixelSize * 0.8 - color: "#ff9800" - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: expanded = !expanded - } - } - - Repeater { - model: expanded && block ? block.subBlocks : [] - delegate: Loader { - Layout.fillWidth: true - property var subBlock: modelData - sourceComponent: subBlock.type === "code" ? codeSubDelegate - : subBlock.type === "diagram" ? diagramSubDelegate - : subBlock.type === "diff" ? diffSubDelegate - : subBlock.type === "interrupt" ? interruptSubDelegate - : subBlock.type === "video" ? videoSubDelegate - : 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 - z: 10 - 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 === "diagram" ? diagramSubDelegate - : subBlock.type === "diff" ? diffSubDelegate - : subBlock.type === "interrupt" ? interruptSubDelegate - : subBlock.type === "video" ? videoSubDelegate - : 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 - TextEdit { - width: parent ? parent.width : 100 - text: chatModel.linkifyFilePaths(subBlock.content || "") - wrapMode: TextEdit.Wrap - textFormat: TextEdit.MarkdownText - color: palette.text - selectionColor: palette.highlight - selectedTextColor: palette.highlightedText - readOnly: true - selectByMouse: true - font.pointSize: root.font.pointSize - font.family: root.font.family - onLinkActivated: chatModel.openLink(link) - } - } - - Component { - id: plainSubDelegate - TextEdit { - width: parent ? parent.width : 100 - text: subBlock.content || "" - wrapMode: TextEdit.Wrap - textFormat: TextEdit.PlainText - color: palette.text - selectionColor: palette.highlight - selectedTextColor: palette.highlightedText - readOnly: true - selectByMouse: true - font.family: "monospace" - font.pixelSize: Qt.application.font.pixelSize * 0.9 - } - } - - 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: videoSubDelegate - Item { - id: videoItem - width: parent ? parent.width : 400 - height: Math.max(200, Math.min(width * 9/16, 360)) - - property string videoUrl: subBlock.content || "" - property bool isDirectMedia: /\.(mp4|webm|ogv|m3u8)(\?|$)/i.test(videoUrl) - property string youtubeId: { - var m = videoUrl.match(/[?&]v=([^&]+)/) || videoUrl.match(/youtu\.be\/([^?]+)/) - return m ? m[1] : "" - } - - // Non-direct: thumbnail card that opens in browser - Rectangle { - anchors.fill: parent - color: "#181818" - radius: 8 - clip: true - - // YouTube thumbnail - Image { + RowLayout { anchors.fill: parent - source: videoItem.youtubeId !== "" ? - "https://img.youtube.com/vi/" + videoItem.youtubeId + "/hqdefault.jpg" : "" - fillMode: Image.PreserveAspectCrop - visible: videoItem.youtubeId !== "" - } - - // Play button overlay - Rectangle { - anchors.centerIn: parent - width: 72; height: 72 - radius: 36 - color: "#aa000000" - Text { - anchors.centerIn: parent - text: "\u25B6" - font.pixelSize: 36 - color: "white" - } - } - - // URL label at bottom - Rectangle { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - height: 28 - color: "#cc000000" - radius: 4 - Text { - anchors.centerIn: parent - width: parent.width - 16 - text: videoItem.videoUrl - color: "#ccc" - font.pixelSize: 11 - elide: Text.ElideMiddle - horizontalAlignment: Text.AlignHCenter - } - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: Qt.openUrlExternally(videoItem.videoUrl) - } - } - } - } - - 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 - - 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 - } - + Label { text: "Sessions"; Layout.fillWidth: true; font.bold: true; leftPadding: 8 } ToolButton { - icon.name: "media-playback-start" - implicitWidth: 32 - implicitHeight: 32 - visible: { - var 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 + text: "↻" + onClicked: ollie.refreshSessions() } } - - TextEdit { - id: codeText - Layout.fillWidth: true - Layout.margins: 8 - text: subBlock.content - textFormat: TextEdit.PlainText - wrapMode: TextEdit.Wrap - font.family: "monospace" - font.pixelSize: Qt.application.font.pixelSize * 0.9 - color: palette.text - selectionColor: palette.highlight - selectedTextColor: palette.highlightedText - readOnly: true - selectByMouse: true - - property bool highlighted: false - Timer { - interval: 150 - running: true - onTriggered: { - codeText.text = chatModel.highlightedHtml(subBlock.content, subBlock.language) - codeText.textFormat = TextEdit.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 - } - } - - 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 - } - } - } - } - } - } - } - } - - Component { - id: diagramSubDelegate - Rectangle { - implicitHeight: diagramCol.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: diagramCol - anchors.left: parent.left - anchors.right: parent.right - spacing: 0 - - // Rendered diagram image (white background for dark theme readability) - Rectangle { - Layout.fillWidth: true - Layout.margins: 8 - Layout.maximumHeight: 600 - implicitHeight: diagramImg.implicitHeight > 0 ? Math.min(diagramImg.implicitHeight, 600) : 100 - color: "#ffffff" - radius: 4 - - Image { - id: diagramImg - anchors.fill: parent - anchors.margins: 4 - fillMode: Image.PreserveAspectFit - source: chatModel.diagramUrl(subBlock.content || "", subBlock.language || "") - asynchronous: true - cache: false - - BusyIndicator { - anchors.centerIn: parent - running: diagramImg.status === Image.Loading - visible: running - } - - Label { - anchors.centerIn: parent - text: "\u26A0 Failed to load diagram" - visible: diagramImg.status === Image.Error - color: "#666" - } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.MiddleButton - cursorShape: Qt.PointingHandCursor - onClicked: function(mouse) { - if (mouse.button === Qt.MiddleButton) { - chatModel.invalidateDiagram(subBlock.content || "", subBlock.language || "") - diagramImg.source = "" - diagramImg.source = chatModel.diagramUrl(subBlock.content || "", subBlock.language || "") - } else { - diagramFullscreen.diagramSource = diagramImg.source - diagramFullscreen.visible = true - } - } - } - } - } - - // Collapsible source toggle - RowLayout { - Layout.fillWidth: true - Layout.margins: 4 - spacing: 4 - - Label { - id: diagramToggle - property bool expanded: false - text: (expanded ? "\u25BC" : "\u25B6") + " " + (subBlock.language || "source") - font.pixelSize: Qt.application.font.pixelSize * 0.8 - opacity: 0.6 - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: diagramToggle.expanded = !diagramToggle.expanded - } - } - - Item { Layout.fillWidth: true } - - ToolButton { - icon.name: "edit-copy" - implicitWidth: 28 - implicitHeight: 28 - onClicked: chatModel.copyToClipboard(subBlock.content) - ToolTip.text: "Copy source" - ToolTip.visible: hovered - } - } - - // Collapsed source code - TextEdit { - Layout.fillWidth: true - Layout.margins: 8 - visible: diagramToggle.expanded - text: subBlock.content || "" - wrapMode: TextEdit.Wrap - textFormat: TextEdit.PlainText - font.family: "monospace" - font.pixelSize: Qt.application.font.pixelSize * 0.85 - color: palette.text - selectionColor: palette.highlight - selectedTextColor: palette.highlightedText - readOnly: true - selectByMouse: true - } - } - } - } - - // Fullscreen diagram viewer (pannable + zoomable) - Popup { - id: diagramFullscreen - property string diagramSource: "" - parent: Overlay.overlay - anchors.centerIn: parent - width: parent ? parent.width * 0.95 : 800 - height: parent ? parent.height * 0.95 : 600 - modal: true - dim: true - padding: 0 - - background: Rectangle { - color: "#ffffff" - radius: 8 - border.color: palette.mid - border.width: 1 - } - - Shortcut { - sequence: "Escape" - enabled: diagramFullscreen.visible - onActivated: diagramFullscreen.close() - } - - ToolButton { - anchors.top: parent.top - anchors.right: parent.right - anchors.margins: 8 - z: 10 - icon.name: "window-close" - onClicked: diagramFullscreen.close() - } - - ToolButton { - anchors.top: parent.top - anchors.left: parent.left - anchors.margins: 8 - z: 10 - text: Math.round(diagramZoomImg.zoomFactor * 100) + "%" - font.pixelSize: Qt.application.font.pixelSize * 0.8 - onClicked: { - diagramZoomImg.zoomFactor = 1.0 - diagramFlick.contentX = 0 - diagramFlick.contentY = 0 - } - ToolTip.text: "Reset zoom" - ToolTip.visible: hovered - } - - Flickable { - id: diagramFlick - anchors.fill: parent - anchors.margins: 8 - clip: true - contentWidth: diagramZoomImg.width - contentHeight: diagramZoomImg.height - boundsBehavior: Flickable.StopAtBounds - - Image { - id: diagramZoomImg - source: diagramFullscreen.diagramSource - fillMode: Image.PreserveAspectFit - cache: false - - property real zoomFactor: 1.0 - width: Math.max(diagramFlick.width, diagramFlick.width * zoomFactor) - height: Math.max(diagramFlick.height, diagramFlick.height * zoomFactor) - } - } - - // Overlay for wheel zoom and right-click close (outside Flickable to avoid drag interference) - MouseArea { - anchors.fill: diagramFlick - acceptedButtons: Qt.RightButton - propagateComposedEvents: true - onClicked: function(mouse) { - if (mouse.button === Qt.RightButton) - diagramFullscreen.close() - } - onWheel: function(wheel) { - var factor = wheel.angleDelta.y > 0 ? 1.15 : 0.87 - var newZoom = Math.max(0.25, Math.min(10.0, diagramZoomImg.zoomFactor * factor)) - diagramZoomImg.zoomFactor = newZoom - } - } - - onAboutToShow: { - diagramZoomImg.zoomFactor = 1.0 - diagramFlick.contentX = 0 - diagramFlick.contentY = 0 - } - } - - // --- Process Manager Window --- - Window { - id: processManagerWindow - title: "Ollie \u2014 Detached Processes" - width: 600 - height: 400 - color: palette.window - - property var processes: [] - property int selectedPid: -1 - property string selectedOutput: "" - - Timer { - id: procRefreshTimer - interval: 1500 - running: processManagerWindow.visible - repeat: true - triggeredOnStart: true - onTriggered: { - if (dbus.activeSessionId === "") return - processManagerWindow.processes = dbus.listDetached(dbus.activeSessionId) - if (processManagerWindow.selectedPid > 0) { - processManagerWindow.selectedOutput = - dbus.getDetachedOutput(dbus.activeSessionId, processManagerWindow.selectedPid) - } - } - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 8 - spacing: 8 - - Label { - text: "Session: " + (dbus.activeSessionId || "none") - font.bold: true } ListView { - id: procList - Layout.fillWidth: true - Layout.preferredHeight: 140 - clip: true - model: processManagerWindow.processes - - delegate: ItemDelegate { - width: procList.width - highlighted: modelData.pid === processManagerWindow.selectedPid - - contentItem: RowLayout { - spacing: 8 - - Rectangle { - width: 8; height: 8; radius: 4 - color: modelData.status === "running" ? "#4caf50" : "#9e9e9e" - } - - Label { - text: modelData.command - Layout.fillWidth: true - elide: Text.ElideRight - font.family: "monospace" - } - - Label { - text: "pid " + modelData.pid - opacity: 0.6 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - } - - Label { - text: modelData.status - opacity: 0.6 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - } - - Button { - text: "Term" - visible: modelData.status === "running" - onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "TERM") - } - - Button { - text: "Kill" - visible: modelData.status === "running" - onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "KILL") - } - - Button { - text: "Dismiss" - visible: modelData.status !== "running" - onClicked: dbus.dismissDetached(dbus.activeSessionId, modelData.pid) - } - } - - onClicked: { - processManagerWindow.selectedPid = modelData.pid - processManagerWindow.selectedOutput = - dbus.getDetachedOutput(dbus.activeSessionId, modelData.pid) - } - } - } - - Rectangle { - Layout.fillWidth: true - height: 1 - color: palette.mid - } - - RowLayout { - spacing: 8 - Label { - text: processManagerWindow.selectedPid > 0 - ? "\u2014\u2014\u2014 Output: pid " + processManagerWindow.selectedPid + " \u2014\u2014\u2014" - : "Select a process to view output" - opacity: 0.6 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - Layout.fillWidth: true - } - Button { - text: "Copy" - visible: processManagerWindow.selectedPid > 0 - onClicked: { - outputArea.selectAll() - outputArea.copy() - outputArea.deselect() - } - } - } - - Flickable { - id: outputFlick + id: sessionList Layout.fillWidth: true Layout.fillHeight: true + model: ollie.sessions clip: true - contentWidth: width - contentHeight: outputArea.implicitHeight - boundsBehavior: Flickable.StopAtBounds - property bool atBottom: true + delegate: ItemDelegate { + width: sessionList.width + text: modelData.id || "" + highlighted: (modelData.id === ollie.activeSessionId) + onClicked: ollie.activeSessionId = modelData.id - onContentYChanged: { - var bottom = contentHeight - height - atBottom = (bottom <= 0 || contentY >= bottom - 4) + Rectangle { + width: 8; height: 8; radius: 4 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + color: { + var s = modelData.state || "idle" + if (s === "idle") return "#4caf50" + if (s.startsWith("calling")) return "#ff9800" + return "#2196f3" + } + } } + } + } - onContentHeightChanged: { - if (atBottom) { - contentY = Math.max(0, contentHeight - height) + // Right pane: chat + input + ColumnLayout { + spacing: 0 + + // Header + RowLayout { + Layout.fillWidth: true + Layout.margins: 6 + spacing: 8 + + Rectangle { + width: 10; height: 10; radius: 5 + color: { + if (ollie.activeState === "idle") return "#4caf50" + if (ollie.activeState.startsWith("calling")) return "#ff9800" + return "#2196f3" } } + Label { + text: ollie.activeState + opacity: 0.7 + font.pixelSize: 11 + } + + Item { Layout.fillWidth: true } + + ToolButton { + text: "Stop" + onClicked: ollie.interrupt() + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } + + // Chat display — streaming text, last 8KB + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + TextArea { - id: outputArea - width: outputFlick.width + id: chatArea readOnly: true + wrapMode: TextEdit.NoWrap + text: root.chatTail font.family: "monospace" - font.pixelSize: Qt.application.font.pixelSize * 0.9 - text: processManagerWindow.selectedOutput - wrapMode: TextEdit.Wrap - background: null + font.pixelSize: 12 + onTextChanged: cursorPosition = text.length + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } + + // Input + RowLayout { + Layout.fillWidth: true + Layout.margins: 6 + spacing: 4 + + TextField { + id: promptInput + Layout.fillWidth: true + placeholderText: ollie.activeSessionId ? "Message..." : "No session" + enabled: ollie.activeSessionId !== "" + onAccepted: { + if (text.trim() !== "") { + ollie.submit(text.trim()) + text = "" + } + } + } + + Button { + text: "Send" + enabled: promptInput.text.trim() !== "" && ollie.activeSessionId !== "" + onClicked: promptInput.accepted() } } } diff --git a/gui/main.cpp b/gui/main.cpp index e781734..a55c2c3 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -1,218 +1,39 @@ +// ollie-gui-9p: minimal Qt GUI using 9P for all communication. +// No D-Bus dependency. Uses ollie-9p subprocess for 9P transport. #include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "olliedbusclient.h" -#include "thememanager.h" -#include "chatblockmodel.h" - -class GuiActivator : public QObject -{ - Q_OBJECT - Q_CLASSINFO("D-Bus Interface", "org.ollie.Gui") - -public: - explicit GuiActivator(QQmlApplicationEngine *engine, QObject *parent = nullptr) - : QObject(parent) - , m_engine(engine) - , m_windowCount(0) - { - } - - void setWindowUrl(const QUrl &url) { m_url = url; } - int windowCount() const { return m_windowCount; } - -public slots: - void windowOpened() { ++m_windowCount; } - void windowClosed() { if (m_windowCount > 0) --m_windowCount; } - void Activate() - { - const auto roots = m_engine->rootObjects(); - if (roots.isEmpty()) - return; - auto *window = qobject_cast(roots.last()); - if (!window) - return; - window->show(); - window->raise(); - window->requestActivate(); - } - - void NewWindow() - { - if (m_url.isEmpty()) - return; - m_engine->load(m_url); - } - - void SelectSession(const QString &id) - { - auto *dbus = qobject_cast( - m_engine->rootContext()->contextProperty("dbus").value()); - if (dbus) - dbus->setActiveSessionId(id); - Activate(); - } - -private: - QQmlApplicationEngine *m_engine; - QUrl m_url; - int m_windowCount; -}; - -static QIcon makeBrainIcon() -{ - // Use the theme icon if available (from hicolor scalable/apps/ollie.svg) - if (QIcon::hasThemeIcon("ollie")) - return QIcon::fromTheme("ollie"); - - // Fallback: render FA6 brain glyph from installed font - QFont fa("Font Awesome 6 Free Solid"); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - if (!QFontDatabase::hasFamily("Font Awesome 6 Free Solid")) { -#else - if (!QFontDatabase().families().contains("Font Awesome 6 Free Solid")) { -#endif - fa = QFont("Font Awesome 6 Free"); - fa.setWeight(QFont::Black); - } - - QIcon icon; - for (int size : {16, 22, 32, 48, 64, 128}) { - QPixmap pix(size, size); - pix.fill(Qt::transparent); - QPainter p(&pix); - fa.setPixelSize(size - 2); - p.setFont(fa); - p.setPen(QColor(0xe9, 0x1e, 0x63)); - p.drawText(pix.rect(), Qt::AlignCenter, QString(QChar(0xf5dc))); - p.end(); - icon.addPixmap(pix); - } - return icon; -} +#include "ollie9pclient.h" int main(int argc, char *argv[]) { - // HiDPI support #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); #endif - QApplication::setHighDpiScaleFactorRoundingPolicy( - Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); QApplication app(argc, argv); app.setApplicationName("ollie-gui"); - app.setDesktopFileName("org.ollie.gui"); - app.setOrganizationName("ollie"); - app.setWindowIcon(makeBrainIcon()); - // Don't force org.kde.desktop style — it ignores explicit palette overrides. - // Let the platform theme provide the style, or fall back to Basic which - // properly respects the QML palette {} block. if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) QQuickStyle::setStyle("Basic"); - // Theme manager — reads KDE color schemes, applies palette - auto *theme = new ThemeManager(&app); - QQmlApplicationEngine engine; - // Only one GUI may run per desktop session. A second invocation asks the - // existing process to show and activate its window, then exits. - constexpr auto guiService = "org.ollie.Gui"; - constexpr auto guiPath = "/org/ollie/Gui"; - GuiActivator activator(&engine, &app); - QDBusConnection sessionBus = QDBusConnection::sessionBus(); - sessionBus.registerObject(guiPath, &activator, QDBusConnection::ExportAllSlots); - if (!sessionBus.registerService(guiService)) { - QDBusInterface runningGui(guiService, guiPath, guiService, sessionBus); - runningGui.call(QDBus::NoBlock, QStringLiteral("NewWindow")); - return 0; - } - - // Register types for QML instantiation - qmlRegisterType("org.ollie.gui", 1, 0, "ChatBlockModel"); - - // Register OllieDBusClient as a singleton - auto *dbus = new OllieDBusClient(&app); - auto *chatModel = new ChatBlockModel(&app); - engine.rootContext()->setContextProperty("dbus", dbus); - engine.rootContext()->setContextProperty("theme", theme); - engine.rootContext()->setContextProperty("chatModel", chatModel); - engine.rootContext()->setContextProperty("activator", &activator); - engine.rootContext()->setContextProperty("systemTrayAvailable", - QSystemTrayIcon::isSystemTrayAvailable()); + auto *ollie = new Ollie9pClient(&app); + engine.rootContext()->setContextProperty("ollie", ollie); #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - const QUrl url(QStringLiteral("qrc:/gui/main.qml")); + const QUrl url(QStringLiteral("qrc:/gui/main-9p.qml")); #else const QUrl url(QStringLiteral("qrc:/main.qml")); #endif - QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, - &app, [url](QObject *obj, const QUrl &objUrl) { - if (!obj && url == objUrl) - QCoreApplication::exit(-1); - }, Qt::QueuedConnection); - - activator.setWindowUrl(url); engine.load(url); - - // --- System tray icon (shown when close-to-tray is enabled) --- - auto *trayIcon = new QSystemTrayIcon(&app); - trayIcon->setIcon(app.windowIcon()); - trayIcon->setToolTip("Ollie"); - - auto *trayMenu = new QMenu(); - auto *showAction = trayMenu->addAction("Show"); - trayMenu->addSeparator(); - auto *quitAction = trayMenu->addAction("Quit"); - trayIcon->setContextMenu(trayMenu); - - // Toggle window visibility on tray icon activation - QObject::connect(trayIcon, &QSystemTrayIcon::activated, - [&engine, &activator](QSystemTrayIcon::ActivationReason reason) { - if (reason != QSystemTrayIcon::Trigger) - return; - auto roots = engine.rootObjects(); - if (roots.isEmpty()) return; - auto *window = qobject_cast(roots.last()); - if (!window) return; - if (window->isVisible()) { - window->hide(); - } else { - activator.Activate(); - } - }); - - QObject::connect(showAction, &QAction::triggered, [&activator]() { - activator.Activate(); - }); - - QObject::connect(quitAction, &QAction::triggered, &app, &QApplication::quit); - - // Show/hide tray icon based on setting - auto updateTrayVisibility = [trayIcon, theme]() { - trayIcon->setVisible(theme->closeToTray()); - }; - updateTrayVisibility(); - QObject::connect(theme, &ThemeManager::closeToTrayChanged, updateTrayVisibility); + if (engine.rootObjects().isEmpty()) + return -1; return app.exec(); } - -#include "main.moc" diff --git a/gui/main.qml b/gui/main.qml index c99ba4e..d8816d8 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -1,857 +1,173 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "emojis.js" as Emojis +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 ApplicationWindow { id: root - title: dbus.activeCwd ? "Ollie — " + dbus.activeCwd : "Ollie" - width: 900 - height: 600 visible: true + width: 1000 + height: 600 + title: "Ollie" - Component.onCompleted: activator.windowOpened() + // Chat tail buffer — only keep last 8KB for display + property string chatTail: "" + readonly property int maxTail: 8192 - onClosing: function(close) { - if (theme.closeToTray && systemTrayAvailable) { - close.accepted = false - root.hide() - } + Component.onCompleted: { + // Load initial log content + var log = ollie.readLog() + if (log.length > maxTail) + log = log.slice(-maxTail) + chatTail = log } - Component.onDestruction: { - if (typeof activator !== 'undefined' && activator) { - activator.windowClosed() - } - } - - color: palette.window - font.family: theme.fontFamily || Qt.application.font.family - font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize - - palette { - window: theme.windowColor - windowText: theme.windowTextColor - base: theme.baseColor - alternateBase: theme.alternateBaseColor - text: theme.textColor - button: theme.buttonColor - buttonText: theme.buttonTextColor - highlight: theme.highlightColor - highlightedText: theme.highlightedTextColor - toolTipBase: theme.tooltipBaseColor - toolTipText: theme.tooltipTextColor - link: theme.linkColor - mid: theme.midColor - dark: theme.darkColor - light: theme.lightColor - shadow: theme.shadowColor - placeholderText: theme.placeholderTextColor - } - - // --- 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 - } - - ComboBox { - id: schemeCombo - Layout.fillWidth: true - model: ["System"].concat(theme.availableSchemes) - currentIndex: { - if (theme.mode === "system") return 0 - let idx = theme.availableSchemes.indexOf(theme.mode) - return idx >= 0 ? idx + 1 : 0 - } - onActivated: { - if (currentIndex === 0) { - theme.mode = "system" - } else { - theme.mode = theme.availableSchemes[currentIndex - 1] - } - } - } - - Label { - text: theme.isDark ? "Current: dark" : "Current: light" - opacity: 0.5 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - } - - // --- Font section --- - 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() - } - - // --- Close to tray section --- - Rectangle { - Layout.fillWidth: true - height: 1 - color: palette.mid - Layout.topMargin: 8 - } - - Label { - text: "System Tray" - font.bold: true - } - - CheckBox { - text: "Close to tray instead of quitting" - checked: theme.closeToTray - onToggled: theme.closeToTray = checked - enabled: systemTrayAvailable - } - - Label { - text: systemTrayAvailable - ? "Window will hide to the system tray when closed." - : "System tray is not available." - opacity: 0.5 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - wrapMode: Text.Wrap - Layout.fillWidth: true - visible: theme.closeToTray || !systemTrayAvailable + Connections { + target: ollie + function onChatReceived(text) { + chatTail += text + if (chatTail.length > maxTail * 2) { + chatTail = chatTail.slice(-maxTail) } } - } - - function openNewSessionDialog() { - nameInput.text = "" - newSessionDialog.backendModel = dbus.listBackends() - newSessionDialog.agentModel = dbus.listAgents() - newSessionDialog.cwdField.text = dbus.activeCwd || "" - newSessionDialog.backendCombo.currentIndex = 0 - newSessionDialog.agentCombo.currentIndex = 0 - newSessionDialog.modelCombo.editText = "" - // Populate model list for the default (first) backend - if (newSessionDialog.backendCombo.currentText) - newSessionDialog.modelCombo.model = dbus.listModelsByBackend(newSessionDialog.backendCombo.currentText) - newSessionDialog.open() - } - - Dialog { - id: newSessionDialog - title: "New Session" - modal: true - anchors.centerIn: parent - width: 450 - standardButtons: Dialog.Ok | Dialog.Cancel - - property alias cwdField: cwdInput - property alias backendCombo: backendSelect - property alias agentCombo: agentSelect - property alias modelCombo: modelSelect - property var backendModel: [] - property var agentModel: [] - - onAccepted: { - let cwd = cwdInput.text.trim() - if (cwd === "") cwd = dbus.activeCwd || "" - let backend = backendSelect.currentText - let model = modelSelect.editText.trim() || modelSelect.currentText - let agent = agentSelect.currentText - let name = nameInput.text.trim() - dbus.createSession(cwd, backend, model, name, agent) - } - - GridLayout { - columns: 2 - width: parent.width - columnSpacing: 10 - rowSpacing: 8 - - Label { text: "Name:" } - TextField { - id: nameInput - Layout.fillWidth: true - placeholderText: "(auto-generated)" - } - - Label { text: "Working directory:" } - RowLayout { - Layout.fillWidth: true - TextField { - id: cwdInput - Layout.fillWidth: true - placeholderText: "/home/user/project" - } - Button { - text: "Browse" - onClicked: { - let path = theme.chooseFolder("Choose working directory", cwdInput.text) - if (path !== "") - cwdInput.text = path - } - } - } - - Label { text: "Backend:" } - ComboBox { - id: backendSelect - Layout.fillWidth: true - model: newSessionDialog.backendModel - onCurrentTextChanged: { - if (currentText) - modelSelect.model = dbus.listModelsByBackend(currentText) - } - } - - Label { text: "Agent:" } - ComboBox { - id: agentSelect - Layout.fillWidth: true - model: newSessionDialog.agentModel - currentIndex: 0 - } - - Label { text: "Model:" } - ComboBox { - id: modelSelect - Layout.fillWidth: true - editable: true - editText: "" - } + function onActiveSessionIdChanged() { + chatTail = "" + var log = ollie.readLog() + if (log.length > maxTail) + log = log.slice(-maxTail) + chatTail = log } - } - - function openCwdDialog() { - let path = theme.chooseFolder("Change working directory", - dbus.activeCwd || "") - if (path !== "") - dbus.setConfig(dbus.activeSessionId, "cwd", path) - } - - // --- Session Graph Window --- - Window { - id: graphWindow - title: "Ollie — Session Topology" - width: 700 - height: 500 - visible: false - color: palette.window - - Component.onCompleted: activator.windowOpened() - Component.onDestruction: { - if (typeof activator !== 'undefined' && activator) { - activator.windowClosed() - } - } - - SessionGraph { - id: graphView - anchors.fill: parent - sessions: dbus.sessions - peerListFn: function(sid) { return dbus.peerList(sid) } + function onStreamingDone() { + // Session was killed + chatTail += "\n[session ended]\n" } } SplitView { anchors.fill: parent - orientation: Qt.Horizontal - - // --- Session sidebar --- - Pane { - SplitView.preferredWidth: 220 - SplitView.minimumWidth: 160 - padding: 0 - - ColumnLayout { - anchors.fill: parent - spacing: 0 - - // Toolbar - RowLayout { - Layout.fillWidth: true - Layout.margins: 6 - - Label { - text: "Sessions" - font.bold: true - Layout.fillWidth: true - } - - ToolButton { - icon.name: "list-add" - onClicked: root.openNewSessionDialog() - ToolTip.text: "New session" - ToolTip.visible: hovered - } - - ToolButton { - icon.name: "configure" - onClicked: settingsDialog.open() - ToolTip.text: "Settings" - ToolTip.visible: hovered - } - - ToolButton { - icon.name: "network-workgroup" - onClicked: graphWindow.visible = !graphWindow.visible - ToolTip.text: "Session topology" - ToolTip.visible: hovered - } - } - - // Session list - ListView { - id: sessionList - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - model: dbus.sessions - - delegate: ItemDelegate { - id: sessionDelegate - width: sessionList.width - highlighted: modelData.id === dbus.activeSessionId - - property bool renaming: false - property string sessionState: modelData.state - - Connections { - target: dbus - function onSessionStateUpdated(sessionId, newState) { - if (sessionId === modelData.id) - sessionDelegate.sessionState = newState - } - } - - background: Rectangle { - color: sessionDelegate.highlighted - ? palette.highlight : "transparent" - radius: 4 - } - - contentItem: RowLayout { - spacing: 6 - - Rectangle { - width: 8 - height: 8 - radius: 4 - color: { - let state = sessionDelegate.sessionState - if (state === "idle") return "#4caf50" - if (state.startsWith("calling")) return "#ff9800" - return "#2196f3" - } - } - - Label { - id: sessionLabel - text: modelData.id - Layout.fillWidth: true - elide: Text.ElideRight - font.family: "monospace" - color: sessionDelegate.highlighted - ? palette.highlightedText : palette.text - visible: !sessionDelegate.renaming - } - - TextField { - id: renameField - Layout.fillWidth: true - font.family: "monospace" - visible: sessionDelegate.renaming - onAccepted: { - let newName = text.trim() - if (newName !== "" && newName !== modelData.id) { - dbus.renameSession(modelData.id, newName) - } - sessionDelegate.renaming = false - } - Keys.onEscapePressed: sessionDelegate.renaming = false - onVisibleChanged: { - if (visible) { - text = modelData.id - selectAll() - forceActiveFocus() - } - } - onActiveFocusChanged: { - if (!activeFocus && sessionDelegate.renaming) { - sessionDelegate.renaming = false - } - } - } - - Label { - text: modelData.agent - opacity: 0.6 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - color: sessionDelegate.highlighted - ? palette.highlightedText : palette.text - visible: !sessionDelegate.renaming - } - - ToolButton { - icon.name: "process-stop" - implicitWidth: 24 - implicitHeight: 24 - visible: !sessionDelegate.renaming - onClicked: dbus.killSession(modelData.id) - ToolTip.text: "Kill session" - ToolTip.visible: hovered - } - } - - onClicked: dbus.activeSessionId = modelData.id - onDoubleClicked: sessionDelegate.renaming = true - } - } - } - } - - // --- Main content: tiling pane area --- - Pane { - id: mainContent - SplitView.fillWidth: true - padding: 0 - - property int focusedPaneIndex: 0 - property var panes: [] - property int paneCount: 0 - - // BSP tree: each node is {pane: item} or {split: 'h'|'v', left: node, right: node} - property var bspTree: null - property real gap: 2 - - function layoutTree(node, x, y, w, h) { - if (!node) return - if (node.pane) { - node.pane.x = x - node.pane.y = y - node.pane.width = w - node.pane.height = h - } else if (node.split) { - if (node.split === 'h') { - let lw = (w - gap) / 2 - layoutTree(node.left, x, y, lw, h) - layoutTree(node.right, x + lw + gap, y, w - lw - gap, h) - } else { - let th = (h - gap) / 2 - layoutTree(node.left, x, y, w, th) - layoutTree(node.right, x, y + th + gap, w, h - th - gap) - } - } - } - - function relayout() { - if (bspTree) - layoutTree(bspTree, 0, 0, width, height) - } - - // Find and replace a leaf node in the tree - function splitNode(tree, targetPane, newPane, direction) { - if (!tree) return null - if (tree.pane === targetPane) { - return {split: direction, left: {pane: targetPane}, right: {pane: newPane}} - } - if (tree.split) { - let newLeft = splitNode(tree.left, targetPane, newPane, direction) - if (newLeft) { tree.left = newLeft; return tree } - let newRight = splitNode(tree.right, targetPane, newPane, direction) - if (newRight) { tree.right = newRight; return tree } - } - return null - } - - // Remove a pane from the tree, collapsing its parent split - function removeNode(tree, targetPane) { - if (!tree) return null - if (tree.pane === targetPane) return null // found it - if (tree.split) { - if (tree.left && tree.left.pane === targetPane) return tree.right - if (tree.right && tree.right.pane === targetPane) return tree.left - let newLeft = removeNode(tree.left, targetPane) - if (newLeft !== tree.left) { tree.left = newLeft; return tree } - let newRight = removeNode(tree.right, targetPane) - if (newRight !== tree.right) { tree.right = newRight; return tree } - } - return tree - } - - onWidthChanged: relayout() - onHeightChanged: relayout() - onPaneCountChanged: relayout() - - // Container for panes (absolute positioning) - Item { - id: paneContainer - anchors.fill: parent - - ChatPane { - id: initialPane - sessionId: "" - Component.onCompleted: { - sessionId = dbus.activeSessionId - mainContent.panes = [initialPane] - mainContent.paneCount = 1 - mainContent.bspTree = {pane: initialPane} - mainContent.relayout() - } - isFocused: mainContent.focusedPaneIndex === 0 - onActiveFocusChanged: if (activeFocus) mainContent.focusedPaneIndex = 0 - onEndedDrag: function(gx, gy) { root.handlePaneSwap(initialPane, gx, gy) } - } - } - } - } - - // --- Window shortcuts --- - Shortcut { - sequence: "Ctrl+Q" - onActivated: Qt.quit() - } - - Shortcut { - sequence: "Ctrl+N" - onActivated: activator.NewWindow() - } - - // --- Split pane shortcuts --- - Shortcut { - sequence: "Ctrl+\\" - onActivated: { - let comp = Qt.createComponent("ChatPane.qml") - if (comp.status !== Component.Ready) { - console.warn("ChatPane.qml:", comp.errorString()) - return - } - let focusedPane = mainContent.panes[mainContent.focusedPaneIndex] - if (!focusedPane) return - - let pane = comp.createObject(paneContainer, { - sessionId: dbus.activeSessionId, - isFocused: false - }) - if (pane) { - // Split direction: alternate based on focused pane's aspect ratio - let dir = focusedPane.width >= focusedPane.height ? 'h' : 'v' - mainContent.bspTree = mainContent.splitNode(mainContent.bspTree, focusedPane, pane, dir) - - let panes = mainContent.panes - let insertAt = mainContent.focusedPaneIndex + 1 - panes.splice(insertAt, 0, pane) - mainContent.panes = panes - mainContent.paneCount = panes.length - mainContent.focusedPaneIndex = insertAt - mainContent.relayout() - - pane.activeFocusChanged.connect(function() { - if (pane.activeFocus) { - mainContent.focusedPaneIndex = mainContent.panes.indexOf(pane) - } - }) - pane.endedDrag.connect(function(gx, gy) { - root.handlePaneSwap(pane, gx, gy) - }) - } - } - } - - Shortcut { - sequence: "Ctrl+W" - onActivated: { - if (mainContent.panes.length <= 1) return - let idx = mainContent.focusedPaneIndex - if (idx < 0 || idx >= mainContent.panes.length) - idx = mainContent.panes.length - 1 - let pane = mainContent.panes[idx] - - mainContent.bspTree = mainContent.removeNode(mainContent.bspTree, pane) - - let panes = mainContent.panes - panes.splice(idx, 1) - mainContent.panes = panes - mainContent.paneCount = panes.length - pane.destroy() - mainContent.focusedPaneIndex = Math.min(idx, panes.length - 1) - mainContent.relayout() - } - } - - // Handle pane swap: find target pane at drop coordinates (root-window coords) - function handlePaneSwap(sourcePane, rootX, rootY) { - for (let i = 0; i < mainContent.panes.length; i++) { - let p = mainContent.panes[i] - if (p === sourcePane) continue - let localPt = p.mapFromItem(null, rootX, rootY) - if (localPt.x >= 0 && localPt.x <= p.width && - localPt.y >= 0 && localPt.y <= p.height) { - // Swap sessions - let tmp = sourcePane.sessionId - sourcePane.sessionId = p.sessionId - p.sessionId = tmp - return - } - } - } - - // Sidebar click updates focused pane's session - Connections { - target: dbus - function onActiveSessionIdChanged() { - let idx = mainContent.focusedPaneIndex - if (idx >= 0 && idx < mainContent.panes.length) { - mainContent.panes[idx].sessionId = dbus.activeSessionId - } - } - function onErrorOccurred(message) { - errorDialog.text = message - errorDialog.open() - } - } - - Dialog { - id: errorDialog - title: "Error" - modal: true - anchors.centerIn: parent - standardButtons: Dialog.Ok - property alias text: errorLabel.text - Label { - id: errorLabel - wrapMode: Text.Wrap - width: parent.width - } - } - - // --- Process Manager Window --- - Window { - id: processManagerWindow - title: "Ollie — Detached Processes" - width: 600 - height: 400 - color: palette.window - - property var processes: [] - property int selectedPid: -1 - property string selectedOutput: "" - - Timer { - id: procRefreshTimer - interval: 1500 - running: processManagerWindow.visible - repeat: true - triggeredOnStart: true - onTriggered: { - if (dbus.activeSessionId === "") return - processManagerWindow.processes = dbus.listDetached(dbus.activeSessionId) - if (processManagerWindow.selectedPid > 0) { - processManagerWindow.selectedOutput = - dbus.getDetachedOutput(dbus.activeSessionId, processManagerWindow.selectedPid) - } - } - } + // Left pane: session list ColumnLayout { - anchors.fill: parent - anchors.margins: 8 - spacing: 8 + SplitView.preferredWidth: 200 + SplitView.minimumWidth: 120 + spacing: 0 - Label { - text: "Session: " + (dbus.activeSessionId || "none") - font.bold: true + ToolBar { + Layout.fillWidth: true + RowLayout { + anchors.fill: parent + Label { text: "Sessions"; Layout.fillWidth: true; font.bold: true; leftPadding: 8 } + ToolButton { + text: "↻" + onClicked: ollie.refreshSessions() + } + } } - // Process list ListView { - id: procList - Layout.fillWidth: true - Layout.preferredHeight: 140 - clip: true - model: processManagerWindow.processes - - delegate: ItemDelegate { - width: procList.width - highlighted: modelData.pid === processManagerWindow.selectedPid - - contentItem: RowLayout { - spacing: 8 - - Rectangle { - width: 8; height: 8; radius: 4 - color: modelData.status === "running" ? "#4caf50" : "#9e9e9e" - } - - Label { - text: modelData.command - Layout.fillWidth: true - elide: Text.ElideRight - font.family: "monospace" - } - - Label { - text: "pid " + modelData.pid - opacity: 0.6 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - } - - Label { - text: modelData.status - opacity: 0.6 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - } - - Button { - text: "Term" - visible: modelData.status === "running" - onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "TERM") - } - - Button { - text: "Kill" - visible: modelData.status === "running" - onClicked: dbus.signalDetached(dbus.activeSessionId, modelData.pid, "KILL") - } - - Button { - text: "Dismiss" - visible: modelData.status !== "running" - onClicked: dbus.dismissDetached(dbus.activeSessionId, modelData.pid) - } - } - - onClicked: { - processManagerWindow.selectedPid = modelData.pid - processManagerWindow.selectedOutput = - dbus.getDetachedOutput(dbus.activeSessionId, modelData.pid) - } - } - } - - // Separator - Rectangle { - Layout.fillWidth: true - height: 1 - color: palette.mid - } - - // Output tail - RowLayout { - spacing: 8 - Label { - text: processManagerWindow.selectedPid > 0 - ? "——— Output: pid " + processManagerWindow.selectedPid + " ———" - : "Select a process to view output" - opacity: 0.6 - font.pixelSize: Qt.application.font.pixelSize * 0.85 - Layout.fillWidth: true - } - Button { - text: "Copy" - visible: processManagerWindow.selectedPid > 0 - onClicked: { - outputArea.selectAll() - outputArea.copy() - outputArea.deselect() - } - } - } - - Flickable { - id: outputFlick + id: sessionList Layout.fillWidth: true Layout.fillHeight: true + model: ollie.sessions clip: true - contentWidth: width - contentHeight: outputArea.implicitHeight - boundsBehavior: Flickable.StopAtBounds - property bool atBottom: true + delegate: ItemDelegate { + width: sessionList.width + text: modelData.id || "" + highlighted: (modelData.id === ollie.activeSessionId) + onClicked: ollie.activeSessionId = modelData.id - onContentYChanged: { - let bottom = contentHeight - height - atBottom = (bottom <= 0 || contentY >= bottom - 4) + Rectangle { + width: 8; height: 8; radius: 4 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + color: { + var s = modelData.state || "idle" + if (s === "idle") return "#4caf50" + if (s.startsWith("calling")) return "#ff9800" + return "#2196f3" + } + } } + } + } - onContentHeightChanged: { - if (atBottom) { - contentY = Math.max(0, contentHeight - height) + // Right pane: chat + input + ColumnLayout { + spacing: 0 + + // Header + RowLayout { + Layout.fillWidth: true + Layout.margins: 6 + spacing: 8 + + Rectangle { + width: 10; height: 10; radius: 5 + color: { + if (ollie.activeState === "idle") return "#4caf50" + if (ollie.activeState.startsWith("calling")) return "#ff9800" + return "#2196f3" } } + Label { + text: ollie.activeState + opacity: 0.7 + font.pixelSize: 11 + } + + Item { Layout.fillWidth: true } + + ToolButton { + text: "Stop" + onClicked: ollie.interrupt() + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } + + // Chat display — streaming text, last 8KB + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + TextArea { - id: outputArea - width: outputFlick.width + id: chatArea readOnly: true + wrapMode: TextEdit.NoWrap + text: root.chatTail font.family: "monospace" - font.pixelSize: Qt.application.font.pixelSize * 0.9 - text: processManagerWindow.selectedOutput - wrapMode: TextEdit.Wrap - background: null + font.pixelSize: 12 + onTextChanged: cursorPosition = text.length + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid } + + // Input + RowLayout { + Layout.fillWidth: true + Layout.margins: 6 + spacing: 4 + + TextField { + id: promptInput + Layout.fillWidth: true + placeholderText: ollie.activeSessionId ? "Message..." : "No session" + enabled: ollie.activeSessionId !== "" + onAccepted: { + if (text.trim() !== "") { + ollie.submit(text.trim()) + text = "" + } + } + } + + Button { + text: "Send" + enabled: promptInput.text.trim() !== "" && ollie.activeSessionId !== "" + onClicked: promptInput.accepted() } } } diff --git a/gui/ollie9pclient.cpp b/gui/ollie9pclient.cpp new file mode 100644 index 0000000..d7d68c9 --- /dev/null +++ b/gui/ollie9pclient.cpp @@ -0,0 +1,241 @@ +#include "ollie9pclient.h" + +#include +#include + +// Resolve the 9p binary and server address +static QString ninepBin() +{ + // plan9port's 9p command + QString bin = QStandardPaths::findExecutable("9p"); + if (!bin.isEmpty()) return bin; + if (QFileInfo::exists("/usr/local/plan9/bin/9p")) + return "/usr/local/plan9/bin/9p"; + return "9p"; +} + +static QString serverAddr() +{ + QString ns = qEnvironmentVariable("NAMESPACE"); + if (ns.isEmpty()) { + // Plan 9 default: /tmp/ns.$USER.$DISPLAY + QString user = qEnvironmentVariable("USER"); + QString display = qEnvironmentVariable("DISPLAY"); + ns = "/tmp/ns." + user + "." + display; + } + return "unix!" + ns + "/ollie"; +} + +Ollie9pClient::Ollie9pClient(QObject *parent) + : QObject(parent) +{ + refreshSessions(); +} + +Ollie9pClient::~Ollie9pClient() +{ + stopStreams(); +} + +void Ollie9pClient::setActiveSessionId(const QString &id) +{ + if (m_activeSessionId == id) return; + + stopStreams(); + m_activeSessionId = id; + m_agentId.clear(); + m_activeState = "idle"; + emit activeSessionIdChanged(); + emit activeStateChanged(); + + if (id.isEmpty()) return; + + // Discover agent ID + QByteArray out = run9p({"ls", "session/" + id + "/agent"}); + QString agents = QString::fromUtf8(out).trimmed(); + if (!agents.isEmpty()) { + m_agentId = agents.split('\n').first().trimmed(); + } else { + m_agentId = "0"; + } + + startChatStream(); + startStateStream(); +} + +void Ollie9pClient::refreshSessions() +{ + QByteArray out = run9p({"read", "session/idx"}); + QString raw = QString::fromUtf8(out).trimmed(); + + m_sessions.clear(); + if (!raw.isEmpty()) { + for (const QString &line : raw.split('\n')) { + QStringList parts = line.split('\t'); + if (parts.size() >= 2) { + QVariantMap session; + session["id"] = parts[0]; + session["state"] = parts[1]; + session["model"] = parts.size() > 2 ? parts[2] : ""; + session["agent"] = parts.size() > 3 ? parts[3] : ""; + m_sessions.append(session); + } + } + } + emit sessionsChanged(); + + // Auto-select first session if none active + if (m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) { + setActiveSessionId(m_sessions.first().toMap()["id"].toString()); + } +} + +QString Ollie9pClient::readLog() +{ + if (m_activeSessionId.isEmpty()) return {}; + QByteArray out = run9p({"read", agentPath() + "/log"}); + return QString::fromUtf8(out); +} + +bool Ollie9pClient::submit(const QString &prompt) +{ + if (m_activeSessionId.isEmpty() || prompt.trimmed().isEmpty()) return false; + QProcess proc; + proc.start(ninepBin(), {"-a", serverAddr(), "write", agentPath() + "/prompt"}); + proc.waitForStarted(3000); + proc.write(prompt.toUtf8()); + proc.closeWriteChannel(); + proc.waitForFinished(5000); + return proc.exitCode() == 0; +} + +bool Ollie9pClient::interrupt() +{ + if (m_activeSessionId.isEmpty()) return false; + QProcess proc; + proc.start(ninepBin(), {"-a", serverAddr(), "write", agentPath() + "/ctl"}); + proc.waitForStarted(3000); + proc.write("stop"); + proc.closeWriteChannel(); + proc.waitForFinished(3000); + return proc.exitCode() == 0; +} + +bool Ollie9pClient::kill() +{ + if (m_activeSessionId.isEmpty()) return false; + QProcess proc; + proc.start(ninepBin(), {"-a", serverAddr(), "write", agentPath() + "/ctl"}); + proc.waitForStarted(3000); + proc.write("kill"); + proc.closeWriteChannel(); + proc.waitForFinished(3000); + return proc.exitCode() == 0; +} + +QString Ollie9pClient::getConfig() +{ + if (m_activeSessionId.isEmpty()) return {}; + QByteArray out = run9p({"read", agentPath() + "/cfg"}); + return QString::fromUtf8(out); +} + +// --- Streaming --- + +void Ollie9pClient::startChatStream() +{ + if (m_chatProc) return; + m_chatProc = new QProcess(this); + connect(m_chatProc, &QProcess::readyReadStandardOutput, this, &Ollie9pClient::onChatReadyRead); + connect(m_chatProc, QOverload::of(&QProcess::finished), + this, &Ollie9pClient::onChatFinished); + m_chatProc->start(ninepBin(), {"-a", serverAddr(), "read", agentPath() + "/chat"}); +} + +void Ollie9pClient::startStateStream() +{ + if (m_stateProc) return; + m_stateProc = new QProcess(this); + connect(m_stateProc, &QProcess::readyReadStandardOutput, this, &Ollie9pClient::onStateReadyRead); + connect(m_stateProc, QOverload::of(&QProcess::finished), + this, &Ollie9pClient::onStateFinished); + m_stateProc->start(ninepBin(), {"-a", serverAddr(), "read", agentPath() + "/statewait"}); +} + +void Ollie9pClient::stopStreams() +{ + if (m_chatProc) { + m_chatProc->disconnect(); + m_chatProc->kill(); + m_chatProc->waitForFinished(1000); + m_chatProc->deleteLater(); + m_chatProc = nullptr; + } + if (m_stateProc) { + m_stateProc->disconnect(); + m_stateProc->kill(); + m_stateProc->waitForFinished(1000); + m_stateProc->deleteLater(); + m_stateProc = nullptr; + } +} + +void Ollie9pClient::onChatReadyRead() +{ + QByteArray data = m_chatProc->readAllStandardOutput(); + if (!data.isEmpty()) { + emit chatReceived(QString::fromUtf8(data)); + } +} + +void Ollie9pClient::onStateReadyRead() +{ + QByteArray data = m_stateProc->readAllStandardOutput(); + QString state = QString::fromUtf8(data).trimmed(); + if (!state.isEmpty()) { + // statewait returns one line per state change; take the last one + QStringList lines = state.split('\n', Qt::SkipEmptyParts); + if (!lines.isEmpty()) { + m_activeState = lines.last().trimmed(); + emit activeStateChanged(); + } + } + // statewait exits after one read — restart it for continuous monitoring + m_stateProc->deleteLater(); + m_stateProc = nullptr; + startStateStream(); +} + +void Ollie9pClient::onChatFinished(int, QProcess::ExitStatus) +{ + // Chat stream ended — session was killed or connection lost + m_chatProc->deleteLater(); + m_chatProc = nullptr; + emit streamingDone(); +} + +void Ollie9pClient::onStateFinished(int, QProcess::ExitStatus) +{ + // State process exited unexpectedly (not via onStateReadyRead restart) + if (m_stateProc) { + m_stateProc->deleteLater(); + m_stateProc = nullptr; + } +} + +// --- Helpers --- + +QString Ollie9pClient::agentPath() const +{ + return "session/" + m_activeSessionId + "/agent/" + m_agentId; +} + +QByteArray Ollie9pClient::run9p(const QStringList &args) +{ + QProcess proc; + QStringList fullArgs = {"-a", serverAddr()}; + fullArgs.append(args); + proc.start(ninepBin(), fullArgs); + proc.waitForFinished(10000); + return proc.readAllStandardOutput(); +} diff --git a/gui/ollie9pclient.h b/gui/ollie9pclient.h new file mode 100644 index 0000000..78c00a2 --- /dev/null +++ b/gui/ollie9pclient.h @@ -0,0 +1,70 @@ +#ifndef OLLIE9PCLIENT_H +#define OLLIE9PCLIENT_H + +#include +#include +#include +#if QT_VERSION_MAJOR >= 6 +#include +#endif + +// 9P-based client for ollie. Uses ollie-9p subprocess for transport. +// Streaming files (chat, statewait) use persistent QProcess instances. +class Ollie9pClient : public QObject +{ + Q_OBJECT +#if QT_VERSION_MAJOR >= 6 + QML_ELEMENT + QML_SINGLETON +#endif + + Q_PROPERTY(QVariantList sessions READ sessions NOTIFY sessionsChanged) + Q_PROPERTY(QString activeSessionId READ activeSessionId WRITE setActiveSessionId NOTIFY activeSessionIdChanged) + Q_PROPERTY(QString activeState READ activeState NOTIFY activeStateChanged) + +public: + explicit Ollie9pClient(QObject *parent = nullptr); + ~Ollie9pClient(); + + QVariantList sessions() const { return m_sessions; } + QString activeSessionId() const { return m_activeSessionId; } + void setActiveSessionId(const QString &id); + QString activeState() const { return m_activeState; } + + Q_INVOKABLE void refreshSessions(); + Q_INVOKABLE QString readLog(); + Q_INVOKABLE bool submit(const QString &prompt); + Q_INVOKABLE bool interrupt(); + Q_INVOKABLE bool kill(); + Q_INVOKABLE QString getConfig(); + +signals: + void sessionsChanged(); + void activeSessionIdChanged(); + void activeStateChanged(); + void chatReceived(const QString &text); + void streamingDone(); // chat process exited (session killed) + +private slots: + void onChatReadyRead(); + void onStateReadyRead(); + void onChatFinished(int exitCode, QProcess::ExitStatus status); + void onStateFinished(int exitCode, QProcess::ExitStatus status); + +private: + void startChatStream(); + void startStateStream(); + void stopStreams(); + QString agentPath() const; + QByteArray run9p(const QStringList &args); + + QVariantList m_sessions; + QString m_activeSessionId; + QString m_activeState = "idle"; + QString m_agentId; + + QProcess *m_chatProc = nullptr; + QProcess *m_stateProc = nullptr; +}; + +#endif // OLLIE9PCLIENT_H diff --git a/gui/qml.qrc b/gui/qml.qrc index c03e606..5f6483a 100644 --- a/gui/qml.qrc +++ b/gui/qml.qrc @@ -1,6 +1,5 @@ main.qml - emojis.js diff --git a/plasmoid/plugin/olliedbusclient.cpp b/plasmoid/plugin/olliedbusclient.cpp index f265118..b1d44e7 100644 --- a/plasmoid/plugin/olliedbusclient.cpp +++ b/plasmoid/plugin/olliedbusclient.cpp @@ -571,18 +571,18 @@ 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); + int endOffset = static_cast(offset) + new_text.toUtf8().size(); + // Emit for all panes listening to any session + emit chatAppended(session_id, static_cast(offset), new_text, endOffset); + + // Track offset for the active session (but don't accumulate chatText — + // the per-pane ChatBlockModel handles rendering now). if (session_id != m_activeSessionId) return; - // Append directly from the signal — no need to poll if (offset == m_chatOffset) { - m_chatText += new_text; m_chatOffset += new_text.toUtf8().size(); - emit chatTextChanged(); } else { - // Offset mismatch — do a full fetch fetchChat(); } } diff --git a/plasmoid/plugin/olliedbusclient.h b/plasmoid/plugin/olliedbusclient.h index c8097f9..820502f 100644 --- a/plasmoid/plugin/olliedbusclient.h +++ b/plasmoid/plugin/olliedbusclient.h @@ -75,7 +75,7 @@ signals: void activeModelChanged(); void activeBackendChanged(); void sessionStateUpdated(const QString &sessionId, const QString &newState); - void chatAppended(const QString &sessionId, int offset, const QString &text); + void chatAppended(const QString &sessionId, int offset, const QString &text, int endOffset); void processDetached(const QString &sessionId); void processExited(const QString &sessionId, int pid, int exitCode); void errorOccurred(const QString &message);