gui: fix flickering, add code exec button, fix bubble width, improve scroll

- Replace Qt.callLater scroll debounce with Timer (16ms) to coalesce
  rapid contentHeight changes and eliminate scroll flickering
- Add execute button (play icon) on code blocks for scripted languages
  (bash, python, perl, ruby, etc.) — launches in terminal
- Fix assistant bubble width: always use 95% of available width
- Add minimum width (280px) to code fence blocks
- Move emoji popup into prompt RowLayout so it appears near input
- Disable wheel scroll inertia (direct contentY) while keeping flick
  inertia intact
- Apply all fixes to both KF5 and KF6 QML files
This commit is contained in:
Levi Neely 2026-07-16 13:20:08 +02:00
parent 2929bb6b04
commit 1d646f7fab
4 changed files with 281 additions and 118 deletions

View File

@ -12,6 +12,9 @@
#include <QJsonDocument>
#include <QJsonParseError>
#include <QPalette>
#include <QTemporaryFile>
#include <QProcess>
#include <QStandardPaths>
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;
}
}

View File

@ -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:

View File

@ -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

View File

@ -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