// 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 Quickshell.Io import Quickshell.Wayland import QtQuick import ".." // The assistant canvas: a view over desktop-assistant's socket. The daemon // owns the model, the tools and the history; this keeps the conversation it // was told about, so the card reopens where it was, and sends what is typed. // Protocol: desktop-assistant, docs/superpowers/specs/2026-10-16-text-daemon-design.md. Scope { id: root property string monitor: "DP-1" property bool open: false property bool historyOpen: false readonly property bool connected: link.item?.connected ?? false property string state: "idle" property string model: "" property int ctxUsed: 0 property int ctxMax: 0 property var convId: null property string notice: "" // an error with no turn to hang it on property string runningTool: "" property var convs: [] property var results: [] readonly property bool busy: root.state !== "idle" // A daemon gone mid-reply sends no idle; its hello on return redraws. onConnectedChanged: if (!root.connected) { root.state = "idle"; root.runningTool = ""; } // One row per turn. tools is a comma-joined string: a ListModel role // holding an array becomes a nested model, which a Repeater can read but // setProperty cannot append to. ListModel { id: turns } readonly property alias turnsModel: turns // read by the load check harness // Tokens arrive faster than markdown is worth re-rendering, so they // collect here and reach the model on a 50 ms tick. property string bufReply: "" property string bufThink: "" readonly property var screenObj: Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] function toggle() { if (root.open) root.close(); else root.open = true; } function close() { root.open = false; root.historyOpen = false; } // Escape: the history panel first, then the card. function back() { if (root.historyOpen) root.historyOpen = false; else root.close(); } function send(cmd) { if (!link.item) return; link.item.write(JSON.stringify(cmd) + "\n"); link.item.flush(); } function ask(text) { if (!root.connected || text.trim() === "") return false; root.notice = ""; root.send({ type: "ask", text: text }); return true; } function showHistory() { root.historyOpen = true; root.send({ type: "list" }); } function row(t, live) { return { user: t.user ?? "", reply: t.reply ?? "", think: t.think ?? "", tools: (t.tools ?? []).join(","), error: "", live: live, stopped: false, expanded: false }; } function load(id, list, pending) { root.bufReply = root.bufThink = ""; turns.clear(); for (const t of list) turns.append(root.row(t, false)); if (pending) turns.append(root.row(pending, true)); root.convId = id; root.notice = ""; } function last() { return turns.count ? turns.get(turns.count - 1) : null; } function flush() { const t = root.last(); if (!t || !t.live) { root.bufReply = root.bufThink = ""; return; } if (root.bufThink) turns.setProperty(turns.count - 1, "think", t.think + root.bufThink); if (root.bufReply) turns.setProperty(turns.count - 1, "reply", t.reply + root.bufReply); root.bufReply = root.bufThink = ""; } function handle(line) { let e; try { e = JSON.parse(line); } catch (err) { return; } const i = turns.count - 1; switch (e.type) { case "hello": root.state = e.state; root.runningTool = e.state === "tool" ? e.tool : ""; root.model = e.model ?? ""; root.ctxUsed = e.ctx_used; root.ctxMax = e.ctx_max; root.load(e.id, e.turns, e.pending); break; case "conversation": root.load(e.id, e.turns, null); break; case "user": turns.append(root.row({ user: e.text }, true)); break; case "think": root.bufThink += e.delta; break; case "token": root.bufReply += e.delta; break; case "tool": root.flush(); if (!e.done && i >= 0 && turns.get(i).live) { const t = turns.get(i).tools; turns.setProperty(i, "tools", t ? t + "," + e.name : e.name); } break; case "done": root.flush(); if (i >= 0 && turns.get(i).live) { turns.setProperty(i, "live", false); turns.setProperty(i, "stopped", e.stopped === true); } if (!e.stopped) root.convId = e.id; break; case "error": // Only an error marked turn ended the reply. A command error, // perhaps another client's, leaves a streaming reply alone. if (e.turn && i >= 0 && turns.get(i).live) { root.flush(); turns.setProperty(i, "error", e.message); turns.setProperty(i, "live", false); } else { root.notice = e.message; } break; case "state": root.state = e.state; root.runningTool = e.state === "tool" ? e.tool : ""; break; case "ctx": root.ctxUsed = e.used; root.ctxMax = e.max; break; case "conversations": root.convs = e.items; break; case "results": root.results = e.items; break; } } Timer { interval: 50 repeat: true running: root.bufReply !== "" || root.bufThink !== "" onTriggered: root.flush() } // The daemon may start after this shell, or restart: keep trying with a // fresh Socket each time. Quickshell 0.3.1 keeps the QLocalSocket of a // failed connect, so setting `connected` again on the same Socket never // retries. See AGENTS.md. LazyLoader { id: link active: true Socket { path: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/desktop-assistant.sock` connected: true parser: SplitParser { onRead: line => root.handle(line) } } } Timer { interval: 2000 repeat: true running: !root.connected onTriggered: { link.active = false; link.active = true; } } LazyLoader { active: root.open PanelWindow { screen: root.screenObj anchors { top: true; left: true; right: true; bottom: true } color: Qt.alpha(Theme.crust, 0.35) exclusionMode: ExclusionMode.Ignore WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "quickshell-assistant" WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive MouseArea { anchors.fill: parent; onClicked: root.close() } Card { anchors.centerIn: parent width: Math.min(760, parent.width - 48) height: Math.round(parent.height * 0.7) canvas: root turnModel: turns } } } }