diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index 87de46c..1ea211b 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -12,6 +12,9 @@ #include #include #include +#include +#include +#include static KSyntaxHighlighting::Repository *s_repo = nullptr; @@ -465,3 +468,75 @@ void ChatBlockModel::copyToClipboard(const QString &text) const { QGuiApplication::clipboard()->setText(text); } + +void ChatBlockModel::executeInTerminal(const QString &code, const QString &language) const +{ + // Determine interpreter/shebang + QString shebang = QStringLiteral("#!/bin/bash"); + QString ext = QStringLiteral(".sh"); + + QString lang = language.toLower().trimmed(); + if (lang == "python" || lang == "python3" || lang == "py") { + shebang = QStringLiteral("#!/usr/bin/env python3"); + ext = QStringLiteral(".py"); + } else if (lang == "perl") { + shebang = QStringLiteral("#!/usr/bin/env perl"); + ext = QStringLiteral(".pl"); + } else if (lang == "ruby" || lang == "rb") { + shebang = QStringLiteral("#!/usr/bin/env ruby"); + ext = QStringLiteral(".rb"); + } else if (lang == "lua") { + shebang = QStringLiteral("#!/usr/bin/env lua"); + ext = QStringLiteral(".lua"); + } else if (lang == "node" || lang == "javascript" || lang == "js") { + shebang = QStringLiteral("#!/usr/bin/env node"); + ext = QStringLiteral(".js"); + } else if (lang == "php") { + shebang = QStringLiteral("#!/usr/bin/env php"); + ext = QStringLiteral(".php"); + } else if (lang == "zsh") { + shebang = QStringLiteral("#!/usr/bin/env zsh"); + ext = QStringLiteral(".zsh"); + } else if (lang == "fish") { + shebang = QStringLiteral("#!/usr/bin/env fish"); + ext = QStringLiteral(".fish"); + } + + // Write temp script + QTemporaryFile tmp; + tmp.setFileTemplate(QStringLiteral("/tmp/ollie-exec-XXXXXX") + ext); + tmp.setAutoRemove(false); // keep it so terminal can read it + if (!tmp.open()) + return; + + QString script = shebang + QStringLiteral("\n") + code + QStringLiteral("\n"); + tmp.write(script.toUtf8()); + tmp.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); + QString scriptPath = tmp.fileName(); + tmp.close(); + + // Launch in terminal — try konsole, then x-terminal-emulator, then xterm + QStringList terminals = { + QStringLiteral("konsole"), + QStringLiteral("x-terminal-emulator"), + QStringLiteral("xterm") + }; + + for (const QString &term : terminals) { + QString exe = QStandardPaths::findExecutable(term); + if (exe.isEmpty()) + continue; + + QStringList args; + if (term == "konsole") { + args << QStringLiteral("--hold") << QStringLiteral("-e") << scriptPath; + } else if (term == "xterm") { + args << QStringLiteral("-hold") << QStringLiteral("-e") << scriptPath; + } else { + args << QStringLiteral("-e") << scriptPath; + } + + QProcess::startDetached(exe, args); + return; + } +} diff --git a/gui/chatblockmodel.h b/gui/chatblockmodel.h index f5d660a..b1e8fb7 100644 --- a/gui/chatblockmodel.h +++ b/gui/chatblockmodel.h @@ -63,6 +63,7 @@ public: Q_INVOKABLE QString highlightedHtml(const QString &code, const QString &language) const; Q_INVOKABLE QString prettyJson(const QString &text) const; Q_INVOKABLE void copyToClipboard(const QString &text) const; + Q_INVOKABLE void executeInTerminal(const QString &code, const QString &language) const; Q_INVOKABLE bool loadEarlier(); // returns true if more blocks were loaded signals: diff --git a/gui/main-kf5.qml b/gui/main-kf5.qml index e07bb0a..23f36d8 100644 --- a/gui/main-kf5.qml +++ b/gui/main-kf5.qml @@ -385,6 +385,8 @@ ApplicationWindow { // Guard: true while we're programmatically setting contentY property bool _settingY: false + // Guard: true while content is streaming (height growing) + property bool _contentGrowing: false onContentYChanged: { if (_settingY) return @@ -392,8 +394,8 @@ ApplicationWindow { parent.atBottom = (bottom <= 0 || contentY >= bottom - 4) // Load earlier messages when scrolled near top - // Only if user actually scrolled (not during content updates) - if (contentY < 100 && contentHeight > height && !parent.atBottom) { + // Only on user scroll — not during content growth or programmatic scroll + if (!_contentGrowing && contentY < 100 && contentHeight > height && !parent.atBottom) { var oldHeight = contentHeight if (chatModel.loadEarlier()) { _settingY = true @@ -404,19 +406,45 @@ ApplicationWindow { } } } - // Debounce scroll-to-bottom: batch rapid contentHeight changes - property bool _scrollPending: false + + // Timer-based debounce: coalesce all contentHeight changes within + // a single frame (~16ms) to avoid rapid scroll/restore flickering + Timer { + id: scrollTimer + interval: 16 + repeat: false + onTriggered: { + chatFlick._contentGrowing = false + if (chatFlick.parent.atBottom) { + chatFlick._settingY = true + chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height) + chatFlick._settingY = false + } + } + } + onContentHeightChanged: { - if (parent.atBottom && !_scrollPending) { - _scrollPending = true - Qt.callLater(function() { - chatFlick._scrollPending = false - if (chatFlick.parent.atBottom) { - chatFlick._settingY = true - chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height) - chatFlick._settingY = false - } - }) + if (parent.atBottom) { + _contentGrowing = true + scrollTimer.restart() + } + } + + // Direct wheel scroll without inertia + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: { + var dy = wheel.angleDelta.y + chatFlick._settingY = true + chatFlick.contentY = Math.max(0, + Math.min(chatFlick.contentY - dy, + chatFlick.contentHeight - chatFlick.height)) + chatFlick._settingY = false + // Update atBottom + var bottom = chatFlick.contentHeight - chatFlick.height + chatFlick.parent.atBottom = (bottom <= 0 || chatFlick.contentY >= bottom - 4) + wheel.accepted = true } } @@ -624,56 +652,56 @@ ApplicationWindow { ToolTip.text: "Interrupt" ToolTip.visible: hovered } - } - // Emoji completion popup - ListModel { id: emojiModel } + // Emoji completion popup + ListModel { id: emojiModel } - Popup { - id: emojiPopup - y: -height - 4 - x: 8 - width: 280 - height: Math.min(emojiList.contentHeight + 12, 240) - padding: 6 - visible: false - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + Popup { + id: emojiPopup + y: -height - 4 + x: 8 + width: 280 + height: Math.min(emojiList.contentHeight + 12, 240) + padding: 6 + visible: false + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - background: Rectangle { - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 6 - } + 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 + ListView { + id: emojiList + anchors.fill: parent + model: emojiModel + clip: true + currentIndex: 0 - delegate: ItemDelegate { - width: emojiList.width - height: 32 - highlighted: index === emojiList.currentIndex + delegate: ItemDelegate { + width: emojiList.width + height: 32 + highlighted: index === emojiList.currentIndex - contentItem: RowLayout { - spacing: 8 - Label { - text: model.emoji - font.pixelSize: 18 + contentItem: RowLayout { + spacing: 8 + Label { + text: model.emoji + font.pixelSize: 18 + } + Label { + text: ":" + model.code + ":" + Layout.fillWidth: true + font.family: "monospace" + opacity: 0.8 + } } - Label { - text: ":" + model.code + ":" - Layout.fillWidth: true - font.family: "monospace" - opacity: 0.8 - } - } - onClicked: { - promptInput.applyEmoji(model.emoji, model.code) + onClicked: { + promptInput.applyEmoji(model.emoji, model.code) + } } } } @@ -770,7 +798,7 @@ ApplicationWindow { id: assistantBubble anchors.top: parent.top anchors.left: parent.left - width: Math.min(assistantContent.implicitWidth + 24, parent.width * 0.95) + 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 @@ -1046,6 +1074,7 @@ ApplicationWindow { 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 @@ -1056,7 +1085,7 @@ ApplicationWindow { anchors.fill: parent spacing: 0 - // Header with language + copy button + // Header with language + copy/exec buttons RowLayout { Layout.fillWidth: true Layout.margins: 4 @@ -1068,6 +1097,21 @@ ApplicationWindow { Layout.fillWidth: true } + 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) + ToolTip.text: "Execute in terminal" + ToolTip.visible: hovered + } + ToolButton { icon.name: "edit-copy" implicitWidth: 32 diff --git a/gui/main.qml b/gui/main.qml index fe0e6f2..b391395 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -387,6 +387,8 @@ ApplicationWindow { // Guard: true while we're programmatically setting contentY property bool _settingY: false + // Guard: true while content is streaming (height growing) + property bool _contentGrowing: false onContentYChanged: { if (_settingY) return @@ -394,8 +396,8 @@ ApplicationWindow { parent.atBottom = (bottom <= 0 || contentY >= bottom - 4) // Load earlier messages when scrolled near top - // Only if user actually scrolled (not during content updates) - if (contentY < 100 && contentHeight > height && !parent.atBottom) { + // Only on user scroll — not during content growth or programmatic scroll + if (!_contentGrowing && contentY < 100 && contentHeight > height && !parent.atBottom) { let oldHeight = contentHeight if (chatModel.loadEarlier()) { _settingY = true @@ -406,19 +408,45 @@ ApplicationWindow { } } } - // Debounce scroll-to-bottom: batch rapid contentHeight changes - property bool _scrollPending: false + + // Timer-based debounce: coalesce all contentHeight changes within + // a single frame (~16ms) to avoid rapid scroll/restore flickering + Timer { + id: scrollTimer + interval: 16 + repeat: false + onTriggered: { + chatFlick._contentGrowing = false + if (chatFlick.parent.atBottom) { + chatFlick._settingY = true + chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height) + chatFlick._settingY = false + } + } + } + onContentHeightChanged: { - if (parent.atBottom && !_scrollPending) { - _scrollPending = true - Qt.callLater(function() { - chatFlick._scrollPending = false - if (chatFlick.parent.atBottom) { - chatFlick._settingY = true - chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height) - chatFlick._settingY = false - } - }) + if (parent.atBottom) { + _contentGrowing = true + scrollTimer.restart() + } + } + + // Direct wheel scroll without inertia + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: function(wheel) { + let dy = wheel.angleDelta.y + chatFlick._settingY = true + chatFlick.contentY = Math.max(0, + Math.min(chatFlick.contentY - dy, + chatFlick.contentHeight - chatFlick.height)) + chatFlick._settingY = false + // Update atBottom + let bottom = chatFlick.contentHeight - chatFlick.height + chatFlick.parent.atBottom = (bottom <= 0 || chatFlick.contentY >= bottom - 4) + wheel.accepted = true } } @@ -628,56 +656,55 @@ ApplicationWindow { ToolTip.text: "Interrupt" ToolTip.visible: hovered } - } + // Emoji completion popup + ListModel { id: emojiModel } - // Emoji completion popup - ListModel { id: emojiModel } + Popup { + id: emojiPopup + y: -height - 4 + x: 8 + width: 280 + height: Math.min(emojiList.contentHeight + 12, 240) + padding: 6 + visible: false + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - Popup { - id: emojiPopup - y: -height - 4 - x: 8 - width: 280 - height: Math.min(emojiList.contentHeight + 12, 240) - padding: 6 - visible: false - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + background: Rectangle { + color: palette.base + border.color: palette.mid + border.width: 1 + radius: 6 + } - 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 - ListView { - id: emojiList - anchors.fill: parent - model: emojiModel - clip: true - currentIndex: 0 + delegate: ItemDelegate { + width: emojiList.width + height: 32 + highlighted: index === emojiList.currentIndex - delegate: ItemDelegate { - width: emojiList.width - height: 32 - highlighted: index === emojiList.currentIndex - - contentItem: RowLayout { - spacing: 8 - Label { - text: model.emoji - font.pixelSize: 18 + contentItem: RowLayout { + spacing: 8 + Label { + text: model.emoji + font.pixelSize: 18 + } + Label { + text: ":" + model.code + ":" + Layout.fillWidth: true + font.family: "monospace" + opacity: 0.8 + } } - Label { - text: ":" + model.code + ":" - Layout.fillWidth: true - font.family: "monospace" - opacity: 0.8 - } - } - onClicked: { - promptInput.applyEmoji(model.emoji, model.code) + onClicked: { + promptInput.applyEmoji(model.emoji, model.code) + } } } } @@ -713,7 +740,7 @@ ApplicationWindow { id: userBubble anchors.top: parent.top anchors.right: parent.right - width: Math.min(userContent.implicitWidth + 24, parent.width * 0.85) + 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 @@ -776,7 +803,7 @@ ApplicationWindow { id: assistantBubble anchors.top: parent.top anchors.left: parent.left - width: Math.min(assistantContent.implicitWidth + 24, parent.width * 0.95) + 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 @@ -1054,6 +1081,7 @@ ApplicationWindow { 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 @@ -1064,7 +1092,7 @@ ApplicationWindow { anchors.fill: parent spacing: 0 - // Header with language + copy button + // Header with language + copy/exec buttons RowLayout { Layout.fillWidth: true Layout.margins: 4 @@ -1076,6 +1104,21 @@ ApplicationWindow { Layout.fillWidth: true } + ToolButton { + icon.name: "media-playback-start" + implicitWidth: 32 + implicitHeight: 32 + visible: { + let lang = (subBlock.language || "").toLowerCase() + return ["bash", "sh", "zsh", "fish", "python", "python3", + "py", "perl", "ruby", "rb", "lua", "node", + "javascript", "js", "php"].indexOf(lang) >= 0 + } + onClicked: chatModel.executeInTerminal(subBlock.content, subBlock.language) + ToolTip.text: "Execute in terminal" + ToolTip.visible: hovered + } + ToolButton { icon.name: "edit-copy" implicitWidth: 32