1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
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
}
}
}
}
|