1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
|
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 2 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
import Quickshell.Widgets
import QtQuick
import ".."
// One turn as chat bubbles, deck-it's Assistant tab look: the question on
// the right with the user's avatar, the reply on the left with the
// assistant's (its face cropped from the backdrop, Assistant.qml). The reply
// bubble holds the thinking line, the tool chips, the reply and an error or
// stopped mark. Both texts go through Segments, so the question renders as
// markdown too.
Column {
id: msg
required property int index
required property string user
required property string reply
required property string think
required property string tools
required property string files // JSON: [{name, kind, pages, truncated}]
required property string error
required property bool live
required property bool stopped
required property bool expanded
required property var model // the row, for setting expanded
property var canvas
property string running: "" // the tool running now, if any
readonly property int av: 32
// ponytail: bubbles fit the raw text's widest line, measured unformatted;
// a reply with a code block or a table, or open reasoning, takes the
// full width rather than be measured.
readonly property real meMax: msg.width * 0.85 - msg.av - 8
readonly property real botMax: msg.width * 0.90 - msg.av - 8
spacing: 10
Row {
anchors.right: parent.right
spacing: 8
layoutDirection: Qt.RightToLeft
visible: msg.user !== ""
Avatar { source: msg.canvas ? msg.canvas.fileUrl(msg.canvas.avatar) : "" }
Rectangle {
width: Math.min(msg.meMax, qProbe.implicitWidth + 32)
height: question.height + 20
radius: 16
bottomRightRadius: 4
color: Qt.rgba(Theme.accent.r * 0.85 + Theme.text.r * 0.15,
Theme.accent.g * 0.85 + Theme.text.g * 0.15,
Theme.accent.b * 0.85 + Theme.text.b * 0.15, 1)
Segments {
id: question
anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 14; rightMargin: 14; topMargin: 10 }
text: msg.user
color: Theme.crust
}
}
}
// The files that went with the question, as the daemon read them.
Flow {
width: msg.width - msg.av - 8
spacing: 6
layoutDirection: Qt.RightToLeft
visible: msg.files !== "[]"
Repeater {
model: JSON.parse(msg.files)
Rectangle {
required property var modelData
width: f.implicitWidth + 16
height: f.implicitHeight + 6
radius: height / 2
color: Qt.alpha(Theme.surface, 0.6)
Text {
id: f
anchors.centerIn: parent
text: parent.modelData.name
+ (parent.modelData.pages ? ` · ${parent.modelData.pages} pages` : "")
+ (parent.modelData.truncated ? " · truncated" : "")
color: parent.modelData.truncated ? Theme.yellow : Theme.subtext
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
}
}
}
}
Row {
spacing: 8
visible: msg.live || msg.think !== "" || msg.tools !== "" || msg.reply !== ""
|| msg.error !== "" || msg.stopped
Avatar {
id: face
readonly property var f: msg.canvas ? msg.canvas.face : []
source: f.length ? msg.canvas.fileUrl(msg.canvas.backdrop) : ""
crop: f
}
Rectangle {
id: bubble
readonly property bool wide: msg.expanded || /```|^\|/m.test(msg.reply)
width: bubble.wide ? msg.botMax
: Math.min(msg.botMax, Math.max(rProbe.implicitWidth, 120) + 30)
height: inner.height + 20
radius: 16
bottomLeftRadius: 4
color: Qt.alpha(Theme.surface, 0.88)
border.width: 1
border.color: Theme.surfaceAlt
Column {
id: inner
anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 14; rightMargin: 14; topMargin: 10 }
spacing: 6
// Three dots bouncing in turn while the reply has no words yet.
Row {
id: dots
visible: msg.live && msg.think === "" && msg.tools === "" && msg.reply === ""
height: 16
spacing: 5
Repeater {
model: 3
Rectangle {
required property int index
width: 7
height: 7
radius: 3.5
color: Theme.subtext
y: 8
opacity: 1 - (y - 2) / 12
SequentialAnimation on y {
running: dots.visible
loops: Animation.Infinite
PauseAnimation { duration: index * 150 }
NumberAnimation { to: 2; duration: 180; easing.type: Easing.InOutSine }
NumberAnimation { to: 8; duration: 180; easing.type: Easing.InOutSine }
PauseAnimation { duration: 840 - index * 150 }
}
}
}
}
// Collapsed by default; a click shows the reasoning as plain text.
Text {
visible: msg.think !== ""
text: (msg.expanded ? "▾ " : "▸ ") + (msg.live && msg.reply === "" ? "thinking…" : "thinking")
color: Theme.overlay
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: msg.model.expanded = !msg.expanded
}
}
Text {
width: inner.width
visible: msg.expanded && msg.think !== ""
text: msg.think
textFormat: Text.PlainText
wrapMode: Text.Wrap
color: Theme.subtext
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
}
Flow {
width: inner.width
spacing: 6
visible: msg.tools !== ""
Repeater {
model: msg.tools ? msg.tools.split(",") : []
Rectangle {
required property string modelData
readonly property bool lit: msg.live && msg.running === modelData
property int elapsed: 0
width: chip.implicitWidth + 14
height: chip.implicitHeight + 6
radius: height / 2
color: Qt.alpha(Theme.surface, 0.9)
opacity: lit ? 1 : 0.55
// A lit chip alone looks frozen on a clone that takes a
// minute, so the running tool counts seconds.
onLitChanged: if (!lit) elapsed = 0
Timer {
running: parent.lit
interval: 1000
repeat: true
onTriggered: parent.elapsed++
}
Text {
id: chip
anchors.centerIn: parent
text: parent.modelData + (parent.lit ? " · " + parent.elapsed + "s" : "")
color: parent.lit ? Theme.text : Theme.subtext
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
}
}
}
}
Segments {
width: inner.width
text: msg.reply
visible: msg.reply !== ""
}
Text {
visible: msg.error !== "" || msg.stopped
width: inner.width
text: msg.error !== "" ? msg.error : "stopped"
color: msg.error !== "" ? Theme.red : Theme.overlay
wrapMode: Text.Wrap
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
}
}
}
}
// Unformatted twins of the texts, for the bubbles' widths.
Text { id: qProbe; visible: false; text: msg.user; textFormat: Text.PlainText; font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } }
Text { id: rProbe; visible: false; text: msg.reply; textFormat: Text.PlainText; font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } }
// A round avatar: a picture filling it, or with crop ([centre x, centre
// y, size] in the picture's pixels) that square of it. No picture: a
// plain circle.
component Avatar: ClippingRectangle {
id: a
property string source: ""
property var crop: []
width: msg.av
height: msg.av
radius: msg.av / 2
color: Theme.surfaceAlt
Image {
readonly property real k: a.crop.length ? a.width / a.crop[2] : 0
source: a.source
visible: status === Image.Ready
mipmap: true
fillMode: a.crop.length ? Image.Stretch : Image.PreserveAspectCrop
width: a.crop.length ? implicitWidth * k : a.width
height: a.crop.length ? implicitHeight * k : a.height
x: a.crop.length ? -(a.crop[0] - a.crop[2] / 2) * k : 0
y: a.crop.length ? -(a.crop[1] - a.crop[2] / 2) * k : 0
}
}
}
|