// 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() // Focus falls out of the card when its holder goes: the history field // hiding, or a streaming reply's text rebuilt under a click. With the // keyboard grabbed, nothing would then reach Escape. Give it back. Connections { target: card.Window.window function onActiveFocusItemChanged() { const f = card.Window.activeFocusItem; if (!f || f === card.Window.contentItem) Qt.callLater(() => input.forceActiveFocus()); } } Connections { target: card.canvas function onHistoryOpenChanged() { if (!card.canvas.historyOpen) 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 onMovementStarted: stick = false 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 // readOnly, not disabled: a disabled item cannot hold the // focus that Escape needs to reach the card. readOnly: !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 } }