add markdown parsing + latex + tree-sitter highlighting for codeblocks in llm responses

This commit is contained in:
2026-08-24 23:55:51 +02:00
parent aae3757daf
commit e18875a752
53 changed files with 5043 additions and 618 deletions
@@ -70,7 +70,7 @@ Item {
fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: Tokens.spacing.medium
spacing: 0
verticalLayoutDirection: VerticalFadeListView.BottomToTop
add: Transition {
@@ -127,26 +127,38 @@ Item {
visible: !root.chatData
}
IconButton {
id: scrollToBottom
Item {
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.extraLarge
anchors.horizontalCenter: parent.horizontalCenter
font.pointSize: Tokens.font.size.large
icon: "arrow_downward"
isRound: true
padding: Tokens.padding.extraSmall
implicitHeight: scrollToBottom.implicitHeight
implicitWidth: scrollToBottom.implicitWidth
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0
type: IconButton.Filled
visible: scale > 0
Behavior on scale {
Anim {}
}
onClicked: {
list.positionViewAtBeginning();
Elevation {
anchors.fill: parent
level: 2
radius: scrollToBottom.radius
}
IconButton {
id: scrollToBottom
anchors.centerIn: parent
font.pointSize: Tokens.font.size.large
icon: "arrow_downward"
isRound: true
padding: Tokens.padding.extraSmall
type: IconButton.Tonal
onClicked: {
list.positionViewAtBeginning();
}
}
}
@@ -12,10 +12,11 @@ Item {
required property ChatGeneration current
required property TextEdit edit
required property bool hovered
readonly property bool isUser: message.role === ChatMessage.Role.User
required property bool isUser
required property ChatMessage message
required property LlmSegment segment
implicitHeight: root.current.content !== "" ? editButton.implicitHeight : 0
implicitHeight: editButton.implicitHeight
implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small
ButtonRow {
@@ -66,8 +67,7 @@ Item {
visible: opacity > 0
Behavior on opacity {
Anim {
}
Anim {}
}
IconButton {
@@ -78,8 +78,7 @@ Item {
visible: !root.isUser
Behavior on scale {
Anim {
}
Anim {}
}
onClicked: {
@@ -94,8 +93,7 @@ Item {
scale: root.edit.readOnly ? 1 : 0
Behavior on scale {
Anim {
}
Anim {}
}
onClicked: Quickshell.clipboardText = root.current.content
@@ -107,8 +105,11 @@ Item {
icon: root.edit.readOnly ? "edit" : "check"
inactiveColor: Colors.palette.m3tertiary
inactiveOnColor: Colors.palette.m3onTertiary
visible: root.isUser
onClicked: root.edit.readOnly = !root.edit.readOnly
onClicked: {
root.edit.readOnly = !root.edit.readOnly;
}
}
}
}
@@ -0,0 +1,157 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
CustomClippingRect {
id: root
required property string language
required property string code
property bool copied: false
property color codeColor: Colors.palette.m3onSurfaceVariant
property color codeBackgroundColor: Colors.palette.m3surfaceContainerLow
property color codeHeaderColor: Colors.palette.m3outline
property color codeAccentColor: Colors.palette.m3primary
// Highlighter spans for the current code; refreshed when the code or
// its language changes.
property var codeSpans: []
function refresh() {
codeSpans = CodeHighlighter.highlight(root.code, root.language);
}
function roleColor(kind) {
switch (kind) {
case "comment":
return Colors.palette.m3outline;
case "string":
return Colors.palette.m3tertiary;
case "string.key":
return Colors.palette.m3secondary;
case "number":
case "constant":
return Colors.palette.m3tertiaryFixed;
case "keyword":
return root.codeAccentColor;
case "type":
return Colors.palette.m3secondary;
case "function":
case "method":
return Colors.palette.m3onSurface;
case "macro":
case "preproc":
return Colors.palette.m3secondaryContainer;
case "operator":
case "property":
return root.codeColor;
case "label":
case "attribute":
return Colors.palette.m3tertiaryFixedDim;
default:
return root.codeColor;
}
}
function escapeHtml(text) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Wraps the code in <font color> tags following the highlighter spans.
function highlightedHtml(code, spans) {
let out;
if (!spans.length) {
out = escapeHtml(code);
} else {
out = "";
let pos = 0;
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
if (span.start > pos)
out += escapeHtml(code.slice(pos, span.start));
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(span.start, span.start + span.length)) + "</font>";
pos = span.start + span.length;
}
if (pos < code.length)
out += escapeHtml(code.slice(pos));
}
// RichText collapses HTML whitespace: break newlines, and protect
// line-leading indentation with nbsp (internal spaces stay normal
// so long lines can still wrap).
return out
.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, "&nbsp;"))
.replace(/\n/g, "<br>");
}
implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeText.implicitHeight + codeText.anchors.margins * 2
color: root.codeBackgroundColor
radius: Tokens.rounding.medium
onLanguageChanged: refresh()
onCodeChanged: refresh()
CustomText {
id: codeText
anchors.left: parent.left
anchors.top: headerRow.bottom
anchors.right: parent.right
anchors.margins: Tokens.padding.small
color: root.codeColor
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
textFormat: Text.RichText
text: root.highlightedHtml(root.code, root.codeSpans)
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
RowLayout {
id: headerRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Tokens.padding.small
anchors.leftMargin: Tokens.padding.small
anchors.rightMargin: Tokens.padding.small
spacing: Tokens.spacing.small
CustomText {
Layout.alignment: Qt.AlignVCenter
color: root.codeHeaderColor
font.pointSize: Tokens.font.size.small
text: root.language
visible: root.language.length > 0
}
Item {
Layout.fillWidth: true
}
IconButton {
icon: root.copied ? "check" : "content_copy"
inactiveColor: "transparent"
inactiveOnColor: root.codeHeaderColor
type: IconButton.Text
onClicked: {
Quickshell.clipboardText = root.code;
root.copied = true;
copyResetTimer.restart();
}
Timer {
id: copyResetTimer
interval: 1500
onTriggered: root.copied = false
}
}
}
}
@@ -0,0 +1,120 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Components
import qs.Services
Item {
id: root
required property bool isUser
required property LlmSegment segment
required property bool hovered
required property Repeater repeater
required property int index
required property ChatMessage message
required property ChatGeneration current
// Widest the bubble may grow to; assistant bubbles always use it so
// the markdown blocks have a deterministic width to wrap against.
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
signal edit(text: string)
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
CustomRect {
id: bubble
radius: Tokens.rounding.medium
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + Tokens.padding.medium * 2
anchors.right: root.isUser ? parent.right : undefined
// User messages stay a plain editable text field.
TextEditBase {
id: msgText
visible: root.isUser
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
animateCursor: false
color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary
font.pointSize: Tokens.font.size.smaller
readOnly: true
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
text: root.isUser ? root.segment.text : ""
textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap
Keys.onPressed: event => {
if (event.key == Qt.Key_Return) {
if (!(event.modifiers & Qt.ShiftModifier)) {
readOnly = true;
event.accepted = true;
}
} else if (event.key == Qt.Key_Escape) {
text = root.segment.text;
readOnly = true;
event.accepted = true;
}
}
onEditingFinished: {
const old = root.segment.text;
if (old !== text)
root.edit(text);
readOnly = true;
}
onReadOnlyChanged: {
if (readOnly) {
animateCursor = false;
root.forceActiveFocus();
textFormat = CustomText.MarkdownText;
} else {
var raw = root.segment.text;
textFormat = CustomText.PlainText;
text = raw;
forceActiveFocus();
cursorPosition = text.length;
animateCursor = true;
}
}
}
// Assistant content: parsed markdown blocks.
MarkdownBlocks {
id: blocks
visible: !root.isUser
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
blocks: root.segment.markdown
}
}
Actions {
id: actionsRow
anchors.left: parent.left
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
anchors.top: bubble.bottom
visible: {
return (root.segment.type === LlmSegment.Type.Content) && root.segment.status !== LlmSegment.Status.Running && root.repeater.count === (root.index + 1);
}
anchors.topMargin: Tokens.spacing.medium
edit: msgText
hovered: root.hovered
isUser: root.isUser
segment: root.segment
current: root.current
message: root.message
}
}
@@ -0,0 +1,88 @@
import QtQuick
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Column {
id: root
// Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown).
required property var blocks
spacing: Tokens.spacing.small
Repeater {
id: blockRep
model: root.blocks
delegate: DelegateChooser {
role: "type"
DelegateChoice {
roleValue: LlmMarkdown.Type.Code
delegate: CodeBlockView {
required property var modelData
language: modelData.language
code: modelData.code
anchors.right: parent.right
anchors.left: parent.left
}
}
DelegateChoice {
roleValue: LlmMarkdown.Type.Math
delegate: MathBlockView {
required property var modelData
latex: modelData.latex
anchors.right: parent.right
anchors.left: parent.left
}
}
DelegateChoice {
roleValue: LlmMarkdown.Type.Heading
delegate: CustomText {
required property var modelData
color: Colors.palette.m3onSurface
anchors.right: parent.right
anchors.left: parent.left
text: modelData.text
textFormat: Text.MarkdownText
font.bold: true
font.pointSize: {
if (modelData.level <= 1)
return Tokens.font.size.larger;
if (modelData.level === 2)
return Tokens.font.size.normal;
return Tokens.font.size.smaller;
}
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
}
DelegateChoice {
roleValue: LlmMarkdown.Type.Text
delegate: CustomText {
required property var modelData
color: Colors.palette.m3onSurface
anchors.right: parent.right
anchors.left: parent.left
text: modelData.text
textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
}
}
}
}
@@ -0,0 +1,50 @@
import QtQuick
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
required property string latex
property color mathColor: Colors.palette.m3onSurface
property real fontSize: Tokens.font.size.large
implicitWidth: Math.max(fallbackText.implicitWidth, root.width)
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
LlmMathText {
id: math
latex: root.latex
color: root.mathColor
fontPointSize: root.fontSize
devicePixelRatio: Screen.devicePixelRatio
}
Image {
id: equationImage
anchors.centerIn: parent
source: math.imageUrl
asynchronous: true
visible: math.ok
fillMode: Image.PreserveAspectFit
smooth: true
width: Math.min(math.width, root.width)
height: math.width > 0 ? width * (math.height / math.width) : 0
}
CustomText {
id: fallbackText
anchors.centerIn: parent
color: root.mathColor
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
text: root.latex
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
visible: !math.ok
}
}
@@ -1,6 +1,8 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import ZShell.Config
import ZShell.Llm
import qs.Components
@@ -14,6 +16,7 @@ MouseArea {
readonly property bool isUser: modelData.role === ChatMessage.Role.User
required property ChatMessage modelData
property bool reasoningExpanded: false
readonly property var blocks: blockify(current.segments)
property real savedContentY: -1
function handleReasoningToggle(expanded: bool): void {
@@ -34,14 +37,36 @@ MouseArea {
}
}
anchors.left: parent.left
anchors.leftMargin: root.isUser ? 0 : Tokens.spacing.extraSmall
anchors.right: parent.right
anchors.rightMargin: root.isUser ? Tokens.spacing.extraSmall : 0
function blockify(segs) {
const blocks = [];
for (let i = 0; i < segs.length; i++) {
const seg = segs[i];
if (seg.type === LlmSegment.Type.Content) {
blocks.push({
kind: "content",
id: i,
segments: [seg]
});
} else {
const last = blocks.length ? blocks[blocks.length - 1] : null;
if (last && last.kind === "process") {
last.segments.push(seg);
} else {
blocks.push({
kind: "process",
id: i,
segments: [seg]
});
}
}
}
return blocks;
}
implicitWidth: ListView.view.width
hoverEnabled: true
// Explicit, synchronous height — sum of children, no Layout polish involved.
implicitHeight: reasoningLoader.implicitHeight + (reasoningLoader.active ? Tokens.spacing.medium : 0) + bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
preventStealing: true
implicitHeight: layout.implicitHeight + Tokens.padding.medium
Binding {
property: "contentY"
@@ -59,106 +84,61 @@ MouseArea {
type: Anim.DefaultEffects
}
Loader {
id: reasoningLoader
Column {
id: layout
readonly property bool shouldBeActive: root.current.reasoningActive || root.current.content === ""
active: !root.isUser
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.rightMargin: Tokens.spacing.extraLarge
anchors.top: parent.top
sourceComponent: Reasoning {
current: root.current
Repeater {
id: segmentRep
onExpandedChanged: {
root.handleReasoningToggle(expanded);
model: ScriptModel {
values: root.blocks
objectProp: "id"
}
}
}
delegate: DelegateChooser {
role: "kind"
CustomRect {
id: bubble
DelegateChoice {
roleValue: "process"
anchors.left: root.isUser ? undefined : parent.left
anchors.right: root.isUser ? parent.right : undefined
anchors.top: reasoningLoader.active ? reasoningLoader.bottom : parent.top
anchors.topMargin: reasoningLoader.active ? Tokens.spacing.medium : 0
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitHeight: root.current.content !== "" ? msgText.contentHeight + Tokens.padding.medium * 2 : 0
implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge)
radius: Tokens.rounding.medium
visible: !root.current.reasoningActive
delegate: ProcessBlock {
required property int index
required property var modelData
TextEditBase {
id: msgText
segments: modelData.segments
isActive: index === root.blocks.length - 1
width: root.width
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
animateCursor: false
color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary
font.pointSize: Tokens.font.size.smaller
readOnly: true
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
text: root.current.content
textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap
Keys.onPressed: event => {
if (event.key == Qt.Key_Return) {
if (!(event.modifiers & Qt.ShiftModifier)) {
readOnly = true;
event.accepted = true;
onExpandedChanged: root.handleReasoningToggle(expanded)
}
} else if (event.key == Qt.Key_Escape) {
text = root.current.content;
readOnly = true;
event.accepted = true;
}
}
onEditingFinished: {
const old = root.current.content;
if (old !== text) {
root.modelData.edit(text);
if (root.isUser)
root.modelData.generate();
}
readOnly = true;
}
onReadOnlyChanged: {
if (readOnly) {
animateCursor = false;
root.forceActiveFocus();
textFormat = CustomText.MarkdownText;
} else {
var raw = root.current.content;
textFormat = CustomText.PlainText;
text = raw;
forceActiveFocus();
cursorPosition = text.length;
animateCursor = true;
DelegateChoice {
roleValue: "content"
delegate: ContentBubble {
required property var modelData
isUser: root.isUser
segment: modelData.segments[0]
width: root.width
repeater: segmentRep
message: root.modelData
current: root.current
hovered: root.containsMouse
onEdit: text => {
root.modelData.edit(text);
if (root.isUser)
root.modelData.generate();
}
}
}
}
}
}
Actions {
id: actionsRow
anchors.left: parent.left
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
anchors.top: bubble.bottom
anchors.topMargin: Tokens.spacing.medium
current: root.current
edit: msgText
hovered: root.containsMouse
message: root.modelData
}
}
@@ -0,0 +1,149 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
required property var segments
required property bool isActive
property bool expanded: false
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin
LoadingIndicator {
id: spinnerReasoning
anchors.centerIn: expandBtn
implicitSize: collapsedText.implicitHeight
opacity: root.isActive ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {}
}
}
IconButton {
id: expandBtn
anchors.left: parent.left
anchors.verticalCenter: collapsedText.verticalCenter
font.pointSize: Tokens.font.size.large
anchors.topMargin: 0
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
opacity: root.isActive ? 0 : 1
rotation: root.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {}
}
onClicked: root.expanded = !root.expanded
}
CustomText {
id: collapsedText
anchors.left: expandBtn.right
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 0
color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small
text: {
if (root.isActive) {
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
return qsTr("Using %1...").arg(root.lastSegment.name);
return qsTr("Thinking...");
}
return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1));
}
Behavior on opacity {
Anim {}
}
}
CustomClippingRect {
id: expandedRect
anchors.left: parent.left
anchors.right: parent.right
anchors.top: collapsedText.bottom
anchors.topMargin: Tokens.spacing.medium
color: Colors.palette.m3surfaceContainerLow
implicitHeight: root.expanded ? expandedContent.contentHeight + expandedContent.anchors.margins * 2 : 0
opacity: root.expanded ? 1 : 0
radius: Tokens.rounding.medium
visible: opacity > 0
Behavior on implicitHeight {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on opacity {
Anim {}
}
ListView {
id: expandedContent
anchors.fill: parent
anchors.margins: Tokens.padding.medium
model: root.segments
spacing: Tokens.spacing.medium
delegate: Item {
id: segment
required property LlmSegment modelData
required property int index
implicitWidth: ListView.view.width
implicitHeight: header.implicitHeight + content.implicitHeight + content.anchors.topMargin
RowLayout {
id: header
anchors.left: parent.left
anchors.right: parent.right
spacing: Tokens.spacing.small
MaterialIcon {
text: segment.modelData.type === LlmSegment.Type.Reasoning ? "cognition" : "build"
fill: 1
}
CustomText {
Layout.fillWidth: true
text: segment.modelData.type === LlmSegment.Type.Reasoning ? qsTr("Thought for %1s").arg((segment.modelData.elapsedMs / 1000).toFixed(1)) : qsTr("Used %1 for %2s").arg(segment.modelData.name).arg((segment.modelData.elapsedMs / 1000).toFixed(1))
}
}
CustomText {
id: content
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.small
anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.small
anchors.right: parent.right
color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website")
}
}
}
}
}
@@ -1,107 +0,0 @@
import QtQuick
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Services
Item {
id: root
required property ChatGeneration current
property bool expanded: false
readonly property bool isReasoning: root.current.reasoningActive || root.current.content === ""
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin
LoadingIndicator {
id: spinnerReasoning
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.verticalCenter: parent.verticalCenter
implicitSize: collapsedText.implicitHeight
opacity: root.isReasoning ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {
}
}
}
IconButton {
id: expandBtn
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
anchors.verticalCenter: collapsedText.verticalCenter
font.pointSize: Tokens.font.size.large
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
opacity: root.isReasoning ? 0 : 1
rotation: root.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {
}
}
onClicked: root.expanded = !root.expanded
}
CustomText {
id: collapsedText
anchors.left: root.current.reasoningActive ? spinnerReasoning.right : expandBtn.right
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 0
color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small
text: root.current.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((root.current.reasoningElapsedMs / 1000).toFixed(1))
Behavior on opacity {
Anim {
}
}
}
CustomClippingRect {
id: expandedRect
anchors.left: parent.left
anchors.right: parent.right
anchors.top: collapsedText.bottom
anchors.topMargin: Tokens.spacing.medium
color: Colors.palette.m3surfaceContainerLow
implicitHeight: root.expanded ? expandedText.implicitHeight + expandedText.anchors.margins * 2 : -anchors.topMargin
implicitWidth: expandedText.implicitWidth + expandedText.anchors.margins * 2
opacity: root.expanded ? 1 : 0
radius: Tokens.rounding.medium
visible: opacity > 0
Behavior on implicitHeight {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on opacity {
Anim {
}
}
CustomText {
id: expandedText
anchors.fill: parent
anchors.margins: Tokens.padding.medium
color: Colors.palette.m3onSurface
font.pointSize: Tokens.font.size.small
text: root.current.reasoning
textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap
}
}
}