diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 14:11:17 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 14:11:17 +0200 |
| commit | b7f33e54c93c5e3c764aa083c2f354fb8751e166 (patch) | |
| tree | 6b9b66af1f0a5897137d13df66b19b504270da21 /desktop/assistant/Assistant.qml | |
| parent | 72d3ff1f0c8c2cadf315214f31312895154cd20e (diff) | |
| download | quickshell-b7f33e54c93c5e3c764aa083c2f354fb8751e166.tar.gz quickshell-b7f33e54c93c5e3c764aa083c2f354fb8751e166.zip | |
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 <noreply@anthropic.com>
Diffstat (limited to 'desktop/assistant/Assistant.qml')
| -rw-r--r-- | desktop/assistant/Assistant.qml | 208 |
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 + } + } + } +} |
