aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/assistant/Card.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 14:11:17 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 14:11:17 +0200
commitb7f33e54c93c5e3c764aa083c2f354fb8751e166 (patch)
tree6b9b66af1f0a5897137d13df66b19b504270da21 /desktop/assistant/Card.qml
parent72d3ff1f0c8c2cadf315214f31312895154cd20e (diff)
downloadquickshell-b7f33e54c93c5e3c764aa083c2f354fb8751e166.tar.gz
quickshell-b7f33e54c93c5e3c764aa083c2f354fb8751e166.zip
feat(assistant): the canvas, a view over the assistant daemon
The socket, the turn model and the window live in Assistant.qml, outside the window's loader, so closing the card loses nothing and a reply keeps streaming while it is closed. Tokens reach the model on a 50 ms tick rather than per token. Prose goes through Notify.sanitize, because model output can quote a search result and a remote image in it would be fetched on render. Code blocks get a copy button once their fence closes. The history panel lists conversations and searches them through the daemon's full-text index. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/assistant/Card.qml')
-rw-r--r--desktop/assistant/Card.qml201
1 files changed, 201 insertions, 0 deletions
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
new file mode 100644
index 0000000..543edd1
--- /dev/null
+++ b/desktop/assistant/Card.qml
@@ -0,0 +1,201 @@
+// 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 QtQuick
+import ".."
+
+// The card: header, transcript, input, and the history panel over the
+// transcript. All state lives in the canvas (Assistant.qml), which outlives
+// this card, so closing and reopening loses nothing.
+Rectangle {
+ id: card
+ property var canvas
+ property var turnModel
+
+ color: Qt.alpha(Theme.base, 0.82)
+ radius: 14
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.12)
+
+ // Keys reach a focused item, never the window (AGENTS.md); the card is
+ // the ancestor of both text fields, so their Escape bubbles here.
+ Keys.onEscapePressed: card.canvas.back()
+
+ // Clicks on the card must not reach the close-on-click catcher behind it.
+ MouseArea { anchors.fill: parent }
+
+ Component.onCompleted: input.forceActiveFocus()
+
+ // --- header ---
+
+ Item {
+ id: header
+ anchors { top: parent.top; left: parent.left; right: parent.right; margins: 14 }
+ height: 32
+
+ Text {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: !card.canvas.connected ? "assistant not running"
+ : card.canvas.model || "assistant"
+ color: card.canvas.connected ? Theme.text : Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+
+ Row {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 8
+
+ // How full the model's context is, as llamachat shows it: amber
+ // at 75%, red at 90%.
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: card.canvas.ctxMax > 0
+ readonly property real used: card.canvas.ctxUsed / Math.max(1, card.canvas.ctxMax)
+ text: `${card.canvas.ctxUsed} / ${card.canvas.ctxMax}`
+ color: used >= 0.9 ? Theme.red : used >= 0.75 ? Theme.yellow : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Button {
+ text: "History"
+ onClicked: card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory()
+ }
+ Button {
+ text: "New"
+ enabled: card.canvas.connected
+ onClicked: { card.canvas.send({ type: "new" }); card.canvas.historyOpen = false; input.forceActiveFocus(); }
+ }
+ }
+ }
+
+ // --- transcript ---
+
+ ListView {
+ id: list
+ anchors {
+ top: header.bottom; topMargin: 8; bottom: footer.top; bottomMargin: 8
+ left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
+ }
+ clip: true
+ spacing: 14
+ boundsBehavior: Flickable.StopAtBounds
+ model: card.turnModel
+
+ // Follow the bottom while a reply streams, unless the user scrolled
+ // up to read; scrolling back to the end resumes following.
+ property bool stick: true
+ onMovementEnded: stick = atYEnd
+ onContentHeightChanged: if (stick) Qt.callLater(positionViewAtEnd)
+ Component.onCompleted: positionViewAtEnd()
+
+ delegate: Message {
+ width: list.width
+ running: card.canvas.runningTool
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: list.count === 0
+ text: card.canvas.connected ? "Ask anything." : "Start it with assistant.sh start."
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize }
+ }
+ }
+
+ // --- input ---
+
+ Item {
+ id: footer
+ anchors { bottom: parent.bottom; left: parent.left; right: parent.right; margins: 14 }
+ height: notice.height + box.height + (notice.visible ? 6 : 0)
+
+ Text {
+ id: notice
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ visible: card.canvas.notice !== ""
+ height: visible ? implicitHeight : 0
+ text: card.canvas.notice
+ color: Theme.red
+ wrapMode: Text.Wrap
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ Rectangle {
+ id: box
+ anchors { bottom: parent.bottom; left: parent.left; right: send.left; rightMargin: 8 }
+ // Grows with the text up to six lines, then scrolls.
+ height: Math.min(input.contentHeight, 6 * input.font.pixelSize * 1.4) + 16
+ radius: 8
+ color: Qt.alpha(Theme.surface, 0.9)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, input.activeFocus ? 0.3 : 0.15)
+
+ Flickable {
+ id: scroller
+ anchors { fill: parent; margins: 8 }
+ contentHeight: input.contentHeight
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ function follow(r) {
+ if (r.y < contentY) contentY = r.y;
+ else if (r.y + r.height > contentY + height) contentY = r.y + r.height - height;
+ }
+
+ TextEdit {
+ id: input
+ width: scroller.width
+ wrapMode: TextEdit.Wrap
+ selectByMouse: true
+ enabled: card.canvas.connected
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ onCursorRectangleChanged: scroller.follow(cursorRectangle)
+
+ // Enter sends, Shift+Enter is a newline: an unaccepted
+ // key goes on to the TextEdit, which inserts it.
+ function submit(event) {
+ if (event.modifiers & Qt.ShiftModifier) { event.accepted = false; return; }
+ if (card.canvas.ask(text)) text = "";
+ }
+ Keys.onReturnPressed: event => submit(event)
+ Keys.onEnterPressed: event => submit(event)
+ }
+ }
+
+ Text {
+ anchors { left: parent.left; top: parent.top; margins: 8 }
+ visible: input.text === ""
+ text: "Message the assistant"
+ color: Theme.overlay
+ font: input.font
+ }
+ }
+
+ Button {
+ id: send
+ anchors { bottom: parent.bottom; right: parent.right }
+ height: box.height
+ text: card.canvas.busy ? "Stop" : "Send"
+ danger: card.canvas.busy
+ enabled: card.canvas.connected
+ onClicked: {
+ if (card.canvas.busy) card.canvas.send({ type: "stop" });
+ else if (card.canvas.ask(input.text)) input.text = "";
+ input.forceActiveFocus();
+ }
+ }
+ }
+
+ History {
+ anchors.fill: list
+ visible: card.canvas.historyOpen
+ canvas: card.canvas
+ }
+}