From b7f33e54c93c5e3c764aa083c2f354fb8751e166 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 14:11:17 +0200 Subject: 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 --- desktop/assistant/Card.qml | 201 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 desktop/assistant/Card.qml (limited to 'desktop/assistant/Card.qml') 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. +// +// 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 + } +} -- cgit v1.2.3