aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/assistant/Assistant.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/assistant/Assistant.qml')
-rw-r--r--desktop/assistant/Assistant.qml208
1 files changed, 208 insertions, 0 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
new file mode 100644
index 0000000..a04a24b
--- /dev/null
+++ b/desktop/assistant/Assistant.qml
@@ -0,0 +1,208 @@
+// 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 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"
+
+ // 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.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) {
+ const t = turns.get(i).tools;
+ turns.setProperty(i, "tools", t ? t + "," + e.name : e.name);
+ }
+ root.runningTool = e.done ? "" : 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;
+ root.runningTool = "";
+ break;
+ case "error":
+ root.flush();
+ if (i >= 0 && turns.get(i).live) {
+ turns.setProperty(i, "error", e.message);
+ turns.setProperty(i, "live", false);
+ } else {
+ root.notice = e.message;
+ }
+ root.runningTool = "";
+ break;
+ case "state": root.state = e.state; 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
+ }
+ }
+ }
+}