diff --git a/.config/quickshell/modules/common/functions/string_utils.js b/.config/quickshell/modules/common/functions/string_utils.js index 97b830bb1..d36066235 100644 --- a/.config/quickshell/modules/common/functions/string_utils.js +++ b/.config/quickshell/modules/common/functions/string_utils.js @@ -17,20 +17,70 @@ function shellSingleQuoteEscape(str) { } function splitMarkdownBlocks(markdown) { - const regex = /```(\w+)?\n([\s\S]*?)```/g; + const regex = /```(\w+)?\n([\s\S]*?)```|([\s\S]*?)<\/think>/g; let result = []; let lastIndex = 0; let match; while ((match = regex.exec(markdown)) !== null) { if (match.index > lastIndex) { - result.push({ type: "text", content: markdown.slice(lastIndex, match.index) }); + const text = markdown.slice(lastIndex, match.index); + if (text.trim()) { + result.push({ type: "text", content: text }); + } + } + if (match[0].startsWith('```')) { + if (match[2] && match[2].trim()) { + result.push({ type: "code", lang: match[1] || "", content: match[2], completed: true }); + } + } else if (match[0].startsWith('')) { + if (match[3] && match[3].trim()) { + result.push({ type: "think", content: match[3], completed: true }); + } } - result.push({ type: "code", lang: match[1] || "", content: match[2] }); lastIndex = regex.lastIndex; } + // Handle any remaining text after the last match if (lastIndex < markdown.length) { - result.push({ type: "text", content: markdown.slice(lastIndex) }); + const text = markdown.slice(lastIndex); + // Check for unfinished block + const thinkStart = text.indexOf(''); + const codeStart = text.indexOf('```'); + if ( + thinkStart !== -1 && + (codeStart === -1 || thinkStart < codeStart) + ) { + const beforeThink = text.slice(0, thinkStart); + if (beforeThink.trim()) { + result.push({ type: "text", content: beforeThink }); + } + const thinkContent = text.slice(thinkStart + 7); + if (thinkContent.trim()) { + result.push({ type: "think", content: thinkContent, completed: false }); + } + } else if (codeStart !== -1) { + const beforeCode = text.slice(0, codeStart); + if (beforeCode.trim()) { + result.push({ type: "text", content: beforeCode }); + } + // Try to detect language after ``` + const codeLangMatch = text.slice(codeStart + 3).match(/^(\w+)?\n/); + let lang = ""; + let codeContentStart = codeStart + 3; + if (codeLangMatch) { + lang = codeLangMatch[1] || ""; + codeContentStart += codeLangMatch[0].length; + } else if (text[codeStart + 3] === '\n') { + codeContentStart += 1; + } + const codeContent = text.slice(codeContentStart); + if (codeContent.trim()) { + result.push({ type: "code", lang, content: codeContent, completed: false }); + } + } else if (text.trim()) { + result.push({ type: "text", content: text }); + } } + // console.log(JSON.stringify(result, null, 2)); return result; } diff --git a/.config/quickshell/modules/sidebarLeft/AiChat.qml b/.config/quickshell/modules/sidebarLeft/AiChat.qml index 07b6a0d66..7635b291d 100644 --- a/.config/quickshell/modules/sidebarLeft/AiChat.qml +++ b/.config/quickshell/modules/sidebarLeft/AiChat.qml @@ -80,6 +80,11 @@ Item { description: qsTr("Markdown test"), execute: () => { Ai.addMessage(` + +A longer think block to test revealing animation +OwO wem ipsum dowo sit amet, consekituwet awipiscing ewit, sed do eiuwsmod tempow inwididunt ut wabowe et dowo mawa. Ut enim ad minim weniam, quis nostwud exeucitation uwuwamcow bowowis nisi ut awiquip ex ea commowo consequat. Duuis aute iwuwe dowo in wepwependewit in wowuptate velit esse ciwwum dowo eu fugiat nuwa pawiatuw. Excepteuw sint occaecat cupidatat non pwowoident, sunt in cuwpa qui officia desewunt mowit anim id est wabowum. Meouw! >w< +Mowe uwu wem ipsum! + ## ✏️ Markdown test ### Formatting @@ -118,7 +123,6 @@ int main(int argc, char* argv[]) { - Simple inline: $\\frac{1}{2} = \\frac{2}{4}$ - Complex inline: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$ - Another complex inline: \\\\[\\int_0^\\infty \\frac{1}{x^2} dx = \\infty\\\\] - `, Ai.interfaceRole); } diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml index 77def3827..9da0b4cc8 100644 --- a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml +++ b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml @@ -23,9 +23,6 @@ Rectangle { property real messagePadding: 7 property real contentSpacing: 3 - property real codeBlockBackgroundRounding: Appearance.rounding.small - property real codeBlockHeaderPadding: 3 - property real codeBlockComponentSpacing: 2 property bool enableMouseSelection: false property bool renderMarkdown: true @@ -44,7 +41,6 @@ Rectangle { const segments = messageContentColumnLayout.children .map(child => child.segment) .filter(segment => (segment)); - // console.log("Segments: " + JSON.stringify(segments)) // Reconstruct markdown const newContent = segments.map(segment => { @@ -238,11 +234,7 @@ Rectangle { spacing: 0 Repeater { model: ScriptModel { - values: { - const result = StringUtils.splitMarkdownBlocks(root.messageData.content) - // console.log(JSON.stringify(result)) - return result - } + values: StringUtils.splitMarkdownBlocks(root.messageData.content) } delegate: Loader { Layout.fillWidth: true @@ -255,8 +247,12 @@ Rectangle { property var enableMouseSelection: root.enableMouseSelection property bool thinking: root.messageData.thinking property bool done: root.messageData.done + property bool completed: modelData.completed ?? false - source: modelData.type === "code" ? "MessageCodeBlock.qml" : "MessageTextBlock.qml" + source: modelData.type === "code" ? "MessageCodeBlock.qml" : + modelData.type === "think" ? "MessageThinkBlock.qml" : + "MessageTextBlock.qml" + } } } diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml b/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml index 2cbf4ae77..024103459 100644 --- a/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml +++ b/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml @@ -12,7 +12,6 @@ import QtQuick.Layouts import Quickshell.Io import Quickshell import Quickshell.Widgets -import Quickshell.Wayland import Quickshell.Hyprland import Qt5Compat.GraphicalEffects import org.kde.syntaxhighlighting @@ -26,6 +25,10 @@ ColumnLayout { property var segmentLang: parent?.segmentLang ?? "plaintext" property var messageData: parent?.messageData ?? {} + property real codeBlockBackgroundRounding: Appearance.rounding.small + property real codeBlockHeaderPadding: 3 + property real codeBlockComponentSpacing: 2 + spacing: codeBlockComponentSpacing anchors.left: parent.left anchors.right: parent.right @@ -161,7 +164,7 @@ ColumnLayout { id: codeTextArea Layout.fillWidth: true readOnly: !editing - // selectByMouse: enableMouseSelection || editing + selectByMouse: enableMouseSelection || editing renderType: Text.NativeRendering font.family: Appearance.font.family.monospace font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/MessageThinkBlock.qml b/.config/quickshell/modules/sidebarLeft/aiChat/MessageThinkBlock.qml new file mode 100644 index 000000000..95566ceea --- /dev/null +++ b/.config/quickshell/modules/sidebarLeft/aiChat/MessageThinkBlock.qml @@ -0,0 +1,196 @@ +pragma ComponentBehavior: Bound + +import "root:/" +import "root:/services" +import "root:/modules/common/" +import "root:/modules/common/widgets" +import "../" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects + +Item { + id: root + // These are needed on the parent loader + property bool editing: parent?.editing ?? false + property bool renderMarkdown: parent?.renderMarkdown ?? true + property bool enableMouseSelection: parent?.enableMouseSelection ?? false + property string segmentContent: parent?.segmentContent ?? ({}) + property var messageData: parent?.messageData ?? {} + property bool done: parent?.done ?? true + property bool completed: parent?.completed ?? false + + property real thinkBlockBackgroundRounding: Appearance.rounding.small + property real thinkBlockHeaderPaddingVertical: 3 + property real thinkBlockHeaderPaddingHorizontal: 10 + property real thinkBlockComponentSpacing: 2 + + property var collapseAnimation: messageTextBlock.implicitHeight > 40 ? Appearance.animation.elementMoveEnter : Appearance.animation.elementMoveFast + property bool collapsed: root.completed || !root.done + + Layout.fillWidth: true + implicitHeight: collapsed ? header.implicitHeight : columnLayout.implicitHeight + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: root.width + height: root.height + radius: thinkBlockBackgroundRounding + } + } + + Behavior on implicitHeight { + enabled: root.done ?? false + NumberAnimation { + duration: collapseAnimation.duration + easing.type: collapseAnimation.type + easing.bezierCurve: collapseAnimation.bezierCurve + } + } + + ColumnLayout { + id: columnLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 0 + + Rectangle { // Header background + id: header + color: Appearance.m3colors.m3surfaceContainerHighest + Layout.fillWidth: true + implicitHeight: thinkBlockTitleBarRowLayout.implicitHeight + thinkBlockHeaderPaddingVertical * 2 + + MouseArea { // Click to reveal + id: headerMouseArea + enabled: root.done + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + root.collapsed = !root.collapsed + } + } + + RowLayout { // Header content + id: thinkBlockTitleBarRowLayout + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: thinkBlockHeaderPaddingHorizontal + anchors.rightMargin: thinkBlockHeaderPaddingHorizontal + spacing: 10 + + MaterialSymbol { + Layout.fillWidth: false + Layout.topMargin: 7 + Layout.bottomMargin: 7 + Layout.leftMargin: 3 + text: "linked_services" + } + StyledText { + id: thinkBlockLanguage + Layout.fillWidth: false + Layout.alignment: Qt.AlignLeft + text: root.done ? "Chain of Thought" : "Thinking..." + } + Item { Layout.fillWidth: true } + Button { // Expand button + id: expandButton + visible: root.done + implicitWidth: 22 + implicitHeight: 22 + + PointingHandInteraction{} + onClicked: { + root.collapsed = !root.collapsed + } + + background: Rectangle { + anchors.fill: parent + radius: Appearance.rounding.full + color: (headerMouseArea.pressed) ? Appearance.colors.colLayer2Active + : (headerMouseArea.containsMouse ? Appearance.colors.colLayer2Hover + : Appearance.transparentize(Appearance.colors.colLayer2, 1)) + + Behavior on color { + ColorAnimation { + duration: collapseAnimation.duration + easing.type: collapseAnimation.type + easing.bezierCurve: collapseAnimation.bezierCurve + } + + } + + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + text: "keyboard_arrow_down" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + iconSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer2 + rotation: root.collapsed ? 0 : 180 + Behavior on rotation { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + } + + } + + } + + } + + Item { + id: content + Layout.fillWidth: true + implicitHeight: collapsed ? 0 : contentBackground.implicitHeight + thinkBlockComponentSpacing + clip: true + + Behavior on implicitHeight { + enabled: root.done ?? false + NumberAnimation { + duration: collapseAnimation.duration + easing.type: collapseAnimation.easing + easing.bezierCurve: collapseAnimation.bezierCurve + } + } + + Rectangle { + id: contentBackground + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: messageTextBlock.implicitHeight + color: Appearance.colors.colLayer2 + + // Load data for the message at the correct scope + property bool editing: root.editing + property bool renderMarkdown: root.renderMarkdown + property bool enableMouseSelection: root.enableMouseSelection + property string segmentContent: root.segmentContent + property var messageData: root.messageData + property bool done: root.done + + MessageTextBlock { + id: messageTextBlock + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + } + } + } + } +} \ No newline at end of file