diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-12 11:10:56 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-12 11:10:56 +0100 |
| commit | 13f04c0d3188f9f974986225486c01f91cd18701 (patch) | |
| tree | 3736c597a92f7f357c38db6912742e376282b6f4 | |
| parent | 20dca0e8a84ba5e12caf06cac2c8aa6079daf615 (diff) | |
| download | quickshell-13f04c0d3188f9f974986225486c01f91cd18701.tar.gz quickshell-13f04c0d3188f9f974986225486c01f91cd18701.zip | |
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.qml | 34 | ||||
| -rw-r--r-- | desktop/assistant/Card.qml | 79 | ||||
| -rw-r--r-- | desktop/assistant/Message.qml | 263 | ||||
| -rw-r--r-- | desktop/assistant/Segments.qml | 3 |
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 |
