aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-12 11:10:56 +0100
committerDanilo M. <danix@danix.xyz>2026-11-12 11:10:56 +0100
commit13f04c0d3188f9f974986225486c01f91cd18701 (patch)
tree3736c597a92f7f357c38db6912742e376282b6f4
parent20dca0e8a84ba5e12caf06cac2c8aa6079daf615 (diff)
downloadquickshell-13f04c0d3188f9f974986225486c01f91cd18701.tar.gz
quickshell-13f04c0d3188f9f974986225486c01f91cd18701.zip
feat(assistant): deck-it's chat look on the canvasHEADmaster
The canvas takes the deck-it Assistant tab's look: the backdrop picture faded in behind the chat through a soft elliptic mask (stronger while the chat is empty), and chat bubbles, the question on the right in an accent bubble with the user's avatar, the reply on the left with the assistant's face cropped from the backdrop. Thinking, tool chips and errors sit in the reply bubble; three dots bounce while it has no words yet. The pictures come from deck-it's config ([assistant] background, user_avatar, face), read once at startup with the system python3, so both follow one setting. MultiEffect's mask needs maskSpreadAtMin for a gradient to fade; the default thresholds cut it to a hard oval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--desktop/assistant/Assistant.qml34
-rw-r--r--desktop/assistant/Card.qml79
-rw-r--r--desktop/assistant/Message.qml263
-rw-r--r--desktop/assistant/Segments.qml3
4 files changed, 303 insertions, 76 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index a4fda72..d6863a0 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -293,6 +293,40 @@ Scope {
}
}
+ // deck-it's Assistant tab look, from its config's [assistant] keys: the
+ // backdrop picture, the user's avatar, and where the assistant's face is
+ // in the backdrop ([centre x, centre y, size] in its pixels). Empty: no
+ // backdrop, plain circles.
+ // ponytail: read once at startup; a qs restart picks up a config edit.
+ property string backdrop: ""
+ property string avatar: ""
+ property var face: []
+ function fileUrl(p) { return p ? "file://" + p.split("/").map(encodeURIComponent).join("/") : ""; }
+
+ Process {
+ running: true
+ command: ["/usr/bin/python3", "-c", `
+import json, os, tomllib
+p = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "deck-it", "config.toml")
+try:
+ a = tomllib.load(open(p, "rb")).get("assistant", {})
+except (OSError, tomllib.TOMLDecodeError):
+ a = {}
+f = a.get("face")
+ok = isinstance(f, list) and len(f) == 3 and all(isinstance(v, (int, float)) for v in f)
+print(json.dumps({"bg": os.path.expanduser(a.get("background") or ""),
+ "avatar": os.path.expanduser(a.get("user_avatar") or ""),
+ "face": f if ok else []}))`]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const c = JSON.parse(text);
+ root.backdrop = c.bg; root.avatar = c.avatar; root.face = c.face;
+ } catch (e) {}
+ }
+ }
+ }
+
// A file whose name says nothing (a script with no suffix) is "other"
// until file(1) has read it. One run for all of them; the daemon makes
// its own call on the content when the question is sent.
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index 437f0ad..2f9af69 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -11,6 +11,8 @@
import Quickshell
import QtQuick
+import QtQuick.Effects
+import QtQuick.Shapes
import ".."
import "Files.js" as Files
import "Presets.js" as Presets
@@ -45,6 +47,82 @@ Rectangle {
border.width: 1
border.color: Qt.alpha(Theme.text, 0.12)
+ // deck-it's Assistant tab backdrop (Assistant.qml, backdrop): the
+ // picture's subject in the right part of the tall card, desaturated and
+ // faded out toward the transcript through an elliptic mask; stronger
+ // while the chat is empty. First child, so everything draws over it.
+ Item {
+ id: backdrop
+ anchors.fill: parent
+ visible: card.tall && card.canvas.backdrop !== "" && art.status === Image.Ready
+ opacity: list.count === 0 ? 0.24 : 0.07
+ Behavior on opacity { NumberAnimation { duration: 600 } }
+
+ // 108% of the card's height, top-aligned; the face (or, without
+ // one, the picture's centre) sits 74% across, under the mask.
+ // ponytail: decoded at full size while the card is open; the face
+ // avatars share this decode through Qt's pixmap cache.
+ Image {
+ id: art
+ readonly property real k: art.implicitHeight > 0 ? card.height * 1.08 / art.implicitHeight : 0
+ readonly property real fx: card.canvas.face.length ? card.canvas.face[0] : art.implicitWidth / 2
+ visible: false
+ source: card.canvas.fileUrl(card.canvas.backdrop)
+ height: card.height * 1.08
+ width: art.implicitWidth * art.k
+ x: card.width * 0.74 - art.fx * art.k
+ }
+ Item {
+ id: mask
+ anchors.fill: parent
+ visible: false
+ layer.enabled: true
+ Shape {
+ // A circle scaled into deck-it's ellipse (27% x 56% at 74%, 40%).
+ width: parent.width
+ height: parent.height
+ transform: Scale {
+ origin { x: mask.width * 0.74; y: mask.height * 0.40 }
+ yScale: mask.width > 0 ? mask.height * 0.56 / (mask.width * 0.27) : 1
+ }
+ ShapePath {
+ strokeWidth: -1
+ fillGradient: RadialGradient {
+ centerX: mask.width * 0.74; centerY: mask.height * 0.40
+ focalX: centerX; focalY: centerY
+ centerRadius: mask.width * 0.27
+ GradientStop { position: 0.3; color: "black" }
+ GradientStop { position: 1.0; color: "transparent" }
+ }
+ PathRectangle { x: 0; y: -mask.height; width: mask.width; height: mask.height * 3 }
+ }
+ }
+ }
+ MultiEffect {
+ x: art.x
+ width: art.width
+ height: art.height
+ source: art
+ autoPaddingEnabled: false
+ saturation: -0.25
+ brightness: -0.1
+ maskEnabled: true
+ maskSource: maskView
+ // The gradient's alpha as a soft edge; the default is a hard cut.
+ maskThresholdMin: 0.0
+ maskSpreadAtMin: 1.0
+ }
+ // The mask in the picture's own coordinates, since the effect is.
+ ShaderEffectSource {
+ id: maskView
+ visible: false
+ width: art.width
+ height: art.height
+ sourceItem: mask
+ sourceRect: Qt.rect(art.x, 0, art.width, art.height)
+ }
+ }
+
// Keys reach a focused item, never the window (AGENTS.md); the card is
// the ancestor of every text field, so their Escape bubbles here.
Keys.onEscapePressed: card.canvas.back()
@@ -178,6 +256,7 @@ Rectangle {
delegate: Message {
width: list.width
+ canvas: card.canvas
running: card.canvas.runningTool
}
diff --git a/desktop/assistant/Message.qml b/desktop/assistant/Message.qml
index 0aa686a..c2e1b13 100644
--- a/desktop/assistant/Message.qml
+++ b/desktop/assistant/Message.qml
@@ -9,12 +9,16 @@
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
+import Quickshell.Widgets
import QtQuick
import ".."
-// One turn: the question, then the reply with its thinking line, the tool
-// chips, and an error or stopped mark. Both texts go through Segments, so
-// the question renders as markdown too.
+// 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
@@ -28,26 +32,48 @@ Column {
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
- spacing: 6
-
- Rectangle {
- width: msg.width
- height: question.height + 16
- radius: 8
- color: Qt.alpha(Theme.surface, 0.7)
- Segments {
- id: question
- anchors { left: parent.left; right: parent.right; top: parent.top; margins: 8 }
- text: msg.user
+ 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
+ width: msg.width - msg.av - 8
spacing: 6
+ layoutDirection: Qt.RightToLeft
visible: msg.files !== "[]"
Repeater {
model: JSON.parse(msg.files)
@@ -70,75 +96,162 @@ Column {
}
}
- // 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
+ 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
}
- }
- Text {
- width: msg.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: msg.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++
+ 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 {
- 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 }
+ 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 }
}
}
}
}
- Segments {
- width: msg.width
- text: msg.reply
- visible: msg.reply !== ""
- }
+ // 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 } }
- Text {
- visible: msg.error !== "" || msg.stopped
- width: msg.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 }
+ // 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
+ }
}
}
diff --git a/desktop/assistant/Segments.qml b/desktop/assistant/Segments.qml
index cd216df..3b6384c 100644
--- a/desktop/assistant/Segments.qml
+++ b/desktop/assistant/Segments.qml
@@ -21,6 +21,7 @@ import "Md.js" as Md
Column {
id: seg
property string text
+ property color color: Theme.text // prose; the question bubble sets its own
spacing: 8
Repeater {
@@ -42,7 +43,7 @@ Column {
textFormat: TextEdit.MarkdownText
text: visible ? Notify.sanitize(part.modelData.text) : ""
wrapMode: TextEdit.Wrap
- color: Theme.text
+ color: seg.color
selectionColor: Qt.alpha(Theme.accent, 0.4)
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
// Web and mail links only: a model-written file: or custom