// 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 Quickshell import QtQuick import ".." import "Files.js" as Files import "Presets.js" as Presets import "Ops.js" as Ops // The card in its three states (Assistant.qml, mode): entry is the input // line alone; file adds the staged chips and what can be done with them; // chat adds the header and the transcript. A panel (history, picker) takes // the full height whatever the state. All state lives in the canvas, which // outlives this card, so closing and reopening loses nothing. Rectangle { id: card property var canvas property var turnModel property real fullHeight: 600 // the chat state's height, set by the window readonly property string mode: card.canvas.mode readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen readonly property bool tall: card.mode === "chat" || card.panel // Bottom-up: the input, then the actions (file state), then the chips. height: card.tall ? card.fullHeight : footer.height + 28 + (chips.visible ? chips.height + 10 : 0) + (asks.visible ? asks.height + 10 : 0) + (dos.visible ? dos.height + 10 : 0) + (status.visible ? status.height + 10 : 0) + (actions.visible ? actions.height + 10 : 0) Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } clip: true 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 every text field, so their Escape bubbles here. Keys.onEscapePressed: card.canvas.back() // Ctrl+N and Ctrl+H do what the New and History buttons do. Keys.onPressed: event => { if (!(event.modifiers & Qt.ControlModifier)) return; if (event.key === Qt.Key_N && card.canvas.connected) { card.canvas.fresh(); card.canvas.historyOpen = false; input.forceActiveFocus(); } else if (event.key === Qt.Key_H) { card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory(); } else return; event.accepted = true; } // 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: a panel's 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(); } function onPickerOpenChanged() { if (!card.canvas.pickerOpen) input.forceActiveFocus(); } function onChattingChanged() { input.forceActiveFocus(); } } // --- header (chat) --- Item { id: header visible: card.mode === "chat" 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.swapping ? `loading ${card.canvas.swapping}…` : 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.fresh(); card.canvas.historyOpen = false; input.forceActiveFocus(); } } } } // --- transcript (chat) --- ListView { id: list visible: card.mode === "chat" anchors { top: header.bottom; topMargin: 8 bottom: chips.visible ? chips.top : 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 } } } // --- staged files (file, chat) --- Flow { id: chips visible: card.canvas.staged.length > 0 anchors { bottom: asks.visible ? asks.top : dos.visible ? dos.top : status.visible ? status.top : actions.visible ? actions.top : footer.top bottomMargin: 10 left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 } spacing: 6 Repeater { model: card.canvas.staged Rectangle { id: chip required property var modelData required property int index readonly property bool ok: Files.chats(modelData.kind) width: label.implicitWidth + cross.implicitWidth + 24 height: label.implicitHeight + 10 radius: height / 2 color: Qt.alpha(Theme.surface, 0.9) border.width: 1 border.color: Qt.alpha(chip.ok ? Theme.text : Theme.red, 0.25) Text { id: label anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } text: `${chip.modelData.kind} · ${chip.modelData.name}` color: chip.ok ? Theme.text : Theme.red font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } Text { id: cross anchors { right: parent.right; rightMargin: 9; verticalCenter: parent.verticalCenter } text: "×" color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } MouseArea { anchors { fill: parent; margins: -4 } cursorShape: Qt.PointingHandCursor onClicked: card.canvas.unstage(chip.index) } } } } } // --- what to do with them (file) --- // Ask: canned questions matching the staged kinds (Presets.js). A click // sends one; Shift+click puts it in the input to edit first. // One action as a tile: its icon over its label, the tiles of a group // sharing the card's width equally. component Tile: Rectangle { id: tile property string icon property string label property bool on: true signal picked(var mouse) implicitHeight: 62 radius: 8 opacity: on ? 1 : 0.4 // Opaque when lit: the glow sits beneath the tile, and a translucent // fill let it wash out the icon and the label. color: area.pressed && on ? Qt.tint(Theme.surface, Qt.alpha(Theme.accent, 0.35)) : area.containsMouse && on ? Qt.tint(Theme.surface, Qt.alpha(Theme.accent, 0.18)) : Qt.alpha(Theme.surface, 0.8) border.width: 1 border.color: Qt.alpha(Theme.text, 0.12) Glow { anchors.fill: parent; z: -1; radius: parent.radius; on: area.containsMouse && tile.on } Column { anchors.centerIn: parent width: parent.width - 12 spacing: 4 Text { width: parent.width horizontalAlignment: Text.AlignHCenter text: tile.icon color: Theme.accent font { family: Theme.iconFamily; pixelSize: Theme.fontSize + 2 } } Text { width: parent.width horizontalAlignment: Text.AlignHCenter text: tile.label elide: Text.ElideRight color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } } MouseArea { id: area anchors.fill: parent hoverEnabled: true cursorShape: tile.on ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: mouse => { if (tile.on) tile.picked(mouse); } } } // A captioned grid of tiles; columns follow the width, about 150 px each. component Group: Column { id: group property string caption property var items: [] property bool on: true signal picked(var item, var mouse) spacing: 6 Text { text: group.caption color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5; capitalization: Font.AllUppercase } } Grid { id: grid width: group.width spacing: 8 columns: Math.max(1, Math.floor((width + spacing) / (150 + spacing))) Repeater { model: group.items Tile { required property var modelData width: (grid.width - (grid.columns - 1) * grid.spacing) / grid.columns icon: modelData.icon ?? Presets.FALLBACK label: modelData.label on: group.on && (modelData.op !== "md" || card.canvas.connected) onPicked: mouse => group.picked(modelData, mouse) } } } } // Ask: canned questions matching the staged kinds (Presets.js). A click // sends one; Shift+click puts it in the input to edit first. Group { id: asks readonly property var offered: Presets.offered(Presets.list(Prefs.presets), card.canvas.staged) visible: card.mode === "file" && offered.length > 0 && !card.canvas.opRunning anchors { bottom: dos.visible ? dos.top : status.visible ? status.top : actions.top; bottomMargin: 10 left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 } caption: "Ask" items: offered on: card.canvas.connected && !card.canvas.blocked onPicked: (p, mouse) => { if (mouse.modifiers & Qt.ShiftModifier) { input.text = Presets.prompt(p); input.cursorPosition = input.text.length; input.forceActiveFocus(); } else { card.canvas.ask(Presets.prompt(p)); } } } // Do: local operations (Ops.js, run by fileops or the daemon's write). // Compress… swaps the group for its formats. Hidden while one runs. property bool compressing: false // Only a different set of files resets it: refine() re-assigns staged // when file(1) answers, which is not a change of files. readonly property string stagedPaths: card.canvas.staged.map(f => f.path).join("\n") onStagedPathsChanged: card.compressing = false Group { id: dos readonly property var offered: card.compressing ? Ops.COMPRESS.concat([{ label: "Back", op: "back", icon: "" }]) : Ops.offered(card.canvas.staged) visible: card.mode === "file" && offered.length > 0 && !card.canvas.opRunning anchors { bottom: status.visible ? status.top : actions.top; bottomMargin: 10 left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 } caption: card.compressing ? "Compress as" : "Do" items: offered onPicked: o => { if (o.op === "compress") card.compressing = true; else if (o.op === "back") card.compressing = false; else { card.compressing = false; card.canvas.runOp(o); } } } // What the operation is doing, or what it made. Row { id: status readonly property var c: card.canvas visible: card.mode === "file" && (c.opRunning || c.opError !== "" || c.opOut.length > 0) anchors { bottom: actions.top; bottomMargin: 10; left: parent.left; leftMargin: 14 } spacing: 10 Text { anchors.verticalCenter: parent.verticalCenter // Bounded: an error can run to 300 characters. width: Math.min(implicitWidth, card.width - 28 - status.spacing * 3 - 220) elide: Text.ElideRight text: status.c.opRunning ? (status.c.opDaemon && status.c.opChars ? `${status.c.opLabel}… ${(status.c.opChars / 1000).toFixed(1)}k characters` : `Working on ${status.c.opLabel}…`) : status.c.opError !== "" ? status.c.opError : status.c.opOut.length === 1 ? `Saved ${status.c.opOut[0].slice(status.c.opOut[0].lastIndexOf("/") + 1)}` : `Saved ${status.c.opOut.length} files` color: status.c.opError !== "" && !status.c.opRunning ? Theme.red : Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } Button { visible: status.c.opRunning text: "Stop" danger: true onClicked: status.c.stopOp() } Button { visible: !status.c.opRunning && status.c.opOut.length === 1 text: "Open" onClicked: Quickshell.execDetached(["xdg-open", status.c.opOut[0]]) } Button { visible: !status.c.opRunning && status.c.opOut.length > 0 text: "Folder" onClicked: Quickshell.execDetached(["xdg-open", status.c.outDir]) } } Row { id: actions visible: card.mode === "file" anchors { bottom: footer.top; bottomMargin: 10; left: parent.left; leftMargin: 14 } spacing: 10 Button { text: "Chat about it" enabled: card.canvas.connected && !card.canvas.blocked onClicked: card.canvas.chatting = true } Text { anchors.verticalCenter: parent.verticalCenter visible: card.canvas.blocked text: "chat supports text, images and PDF" color: Theme.red font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } } // --- 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 } } // The paperclip, nf-fa-paperclip, checked in Inconsolata Nerd Font. Button { id: attach visible: card.mode !== "entry" width: visible ? implicitWidth : 0 anchors { bottom: parent.bottom; left: parent.left } height: box.height text: "" font.family: Theme.iconFamily onClicked: { card.canvas.historyOpen = false; card.canvas.pickerOpen = !card.canvas.pickerOpen; } } // nf-fa-microphone, checked in Inconsolata Nerd Font. Every state, // entry included: SUPER+Home opens the card and the mic is one click away. Button { id: mic anchors { bottom: parent.bottom; left: attach.right; leftMargin: attach.visible ? 8 : 0 } height: box.height icon: "" text: card.canvas.recording ? `${Math.floor(card.canvas.micSecs / 60)}:${String(card.canvas.micSecs % 60).padStart(2, "0")}` : "" danger: card.canvas.recording enabled: card.canvas.connected && (card.canvas.recording || (card.canvas.hears && !card.canvas.busy && !card.canvas.opRunning && !card.canvas.blocked)) onClicked: { if (card.canvas.recording) card.canvas.endRecording(true); else card.canvas.record(); input.forceActiveFocus(); } } Rectangle { id: box anchors { bottom: parent.bottom; left: mic.right; leftMargin: 8 right: history.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; right: parent.right; top: parent.top; margins: 8 } visible: input.text === "" elide: Text.ElideRight text: !card.canvas.connected ? "assistant not running" : card.canvas.recording ? "Listening… click the mic to send" : card.canvas.state === "transcribing" ? "Transcribing…" : card.mode === "entry" ? "How can I help you?" : card.canvas.staged.length ? `Ask about ${card.canvas.staged[0].name}…` : "Message the assistant" color: Theme.overlay font: input.font } } // History from every state; in chat the header has it too. Button { id: history visible: card.mode !== "chat" width: visible ? implicitWidth : 0 anchors { bottom: parent.bottom; right: send.left; rightMargin: visible ? 8 : 0 } height: box.height text: "History" onClicked: card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory() } 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 && (card.canvas.busy || !card.canvas.blocked) onClicked: { if (card.canvas.opRunning) card.canvas.stopOp(); else if (card.canvas.busy) card.canvas.send({ type: "stop" }); else if (card.canvas.ask(input.text)) input.text = ""; input.forceActiveFocus(); } } } // --- panels, over the transcript (under the header in chat) --- Item { id: panelArea anchors { top: header.visible ? header.bottom : parent.top; topMargin: header.visible ? 8 : 14 bottom: chips.visible ? chips.top : footer.top bottomMargin: 8; left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 } History { anchors.fill: parent visible: card.canvas.historyOpen canvas: card.canvas } Picker { anchors.fill: parent visible: card.canvas.pickerOpen canvas: card.canvas } } }