// 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 ".." import "Files.js" as Files // 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" // The daemon's socket; a check against a scratch daemon points it elsewhere. property string socketPath: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/desktop-assistant.sock` property bool open: false property bool historyOpen: false property bool pickerOpen: false property string pickDir: "" // where the picker opened last; "" is home // Files waiting to go with the next question: [{path, name, kind}]. property var staged: [] // Chat about it: the chat layout before any turn exists. property bool chatting: false // When the conversation last moved, for the resume rule in show(). property double lastActive: 0 // entry: one line. file: files staged, nothing asked yet. chat: turns. readonly property string mode: turns.count || root.chatting ? "chat" : root.staged.length ? "file" : "entry" // The Do row's operation: one at a time, either fileops (a Process) or // the daemon's write (To .md). Its result stays on the card. property string opLabel: "" property bool opRunning: false property bool opDaemon: false property int opChars: 0 property var opOut: [] property string opError: "" // Results folder: $(xdg-user-dir DOCUMENTS)/Assistant, read once. property string outDir: (Quickshell.env("HOME") || "") + "/Documents/Assistant" readonly property string fileops: decodeURIComponent(Qt.resolvedUrl("fileops").toString().replace(/^file:\/\//, "")) readonly property string mdPrompt: "Rewrite this document as Markdown: keep its wording; " + "use headings, lists and tables where the original has them; transcribe scanned " + "pages. Output only the Markdown." // A staged kind chat does not take holds the question back. readonly property bool blocked: root.staged.some(f => !Files.chats(f.kind)) readonly property bool connected: link.item?.connected ?? false property string state: "idle" property string model: "" // The preferred model being loaded on open, shown in the header until // the router reports it loaded. Set from shell.qml. property string swapping: "" 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 = ""; if (root.opRunning && root.opDaemon) { root.opRunning = false; root.opError = "assistant gone"; } } // 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.show(); } // A conversation idle for 10 minutes is over: opening starts a new one, // the old one staying in History. ponytail: a constant, not a setting. function show() { // Never while a reply runs: new would stop it, and a stopped turn is // not saved. if (turns.count && !root.busy && Date.now() - root.lastActive > 10 * 60 * 1000) root.fresh(); root.open = true; } function close() { root.open = false; root.historyOpen = false; root.pickerOpen = false; } // Escape: the picker, then the history panel, then the card. function back() { if (root.pickerOpen) root.pickerOpen = false; else if (root.historyOpen) root.historyOpen = false; else root.close(); } function fresh() { root.send({ type: "new" }); root.chatting = false; } // A drop is a new task: a new conversation with these files staged. function attach(paths) { const files = paths.filter(p => p !== "").map(Files.file); if (!files.length) return; if (root.opRunning) root.stopOp(); // a drop is a new task root.fresh(); root.clearOp(); root.staged = files; root.historyOpen = root.pickerOpen = false; root.open = true; root.refine(); } function stage(path) { if (root.staged.some(f => f.path === path)) return; root.staged = root.staged.concat([Files.file(path)]); root.refine(); } function clearOp() { if (root.opRunning) return; root.opLabel = root.opError = ""; root.opOut = []; root.opChars = 0; } function runOp(o) { if (root.opRunning || !root.staged.length) return; root.clearOp(); root.opLabel = o.label; root.opRunning = true; const paths = root.staged.map(f => f.path); if (o.op === "md") { if (!root.connected) { root.opRunning = false; root.opError = "assistant not running"; return; } root.opDaemon = true; const name = root.staged[0].name.replace(/\.[^.]*$/, "") + ".md"; root.send({ type: "write", files: paths, prompt: root.mdPrompt, dir: root.outDir, name: name }); } else { root.opDaemon = false; // A Process per run: a stopped run's late answer must not be // read as the next run's. root.opProc = opRun.createObject(root, { command: [root.fileops, o.op, root.outDir].concat(paths) }); } } property var opProc: null function stopOp() { if (!root.opRunning) return; if (root.opDaemon) root.send({ type: "stop" }); else if (root.opProc) { root.opProc.running = false; // fileops kills its tool, removes the partial root.opProc = null; } root.opRunning = false; root.opError = "stopped"; } Component { id: opRun Process { id: proc running: true stdout: StdioCollector { onStreamFinished: { if (root.opProc !== proc) { proc.destroy(); return; } // stopped, or superseded root.opProc = null; root.opRunning = false; try { const r = JSON.parse(text.trim().split("\n").pop()); if (r.ok) root.opOut = r.out; else root.opError = r.error; } catch (e) { root.opError = "fileops gave no answer"; } proc.destroy(); } } } } Process { running: true command: ["xdg-user-dir", "DOCUMENTS"] stdout: StdioCollector { // With no documents folder set, xdg-user-dir answers $HOME itself. onStreamFinished: { const d = text.trim(); if (d.startsWith("/") && d !== Quickshell.env("HOME")) root.outDir = d + "/Assistant"; } } } // A file whose name says nothing (a script with no suffix) is "other" // until file(1) has read it. One run for all of them; the daemon makes // its own call on the content when the question is sent. function refine() { const unknown = root.staged.filter(f => f.kind === "other").map(f => f.path); if (!unknown.length) return; sniff.paths = unknown; sniff.running = false; // a running Process ignores running = true sniff.running = true; } Process { id: sniff property var paths: [] command: ["file", "-b", "--mime-type", "--"].concat(paths) stdout: StdioCollector { onStreamFinished: { const mimes = text.trim().split("\n"); root.staged = root.staged.map(f => { const i = sniff.paths.indexOf(f.path); return i < 0 || f.kind !== "other" ? f : Object.assign({}, f, { kind: Files.fromMime(mimes[i] ?? "") }); }); } } } function unstage(i) { const s = root.staged.slice(); s.splice(i, 1); root.staged = s; } function send(cmd) { if (!link.item) return; link.item.write(JSON.stringify(cmd) + "\n"); link.item.flush(); } // The chips stay until the daemon's user event takes them: a refused // file (unreadable, too big, a model without vision) comes back as an // error, and the chips must still be there to fix it. chatting keeps the // chat layout meanwhile, so the card does not shrink while a PDF is read. function ask(text) { if (!root.connected || root.blocked || root.opRunning || (text.trim() === "" && !root.staged.length)) return false; root.notice = ""; root.chatting = true; root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) }); return true; } function showHistory() { root.pickerOpen = false; 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, // JSON for the same reason tools is a string. files: JSON.stringify(t.files ?? []), 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); // A restart or reload counts as active: the conversation it // reconnects to is not stale just because this process is new. if (e.pending || e.turns.length) root.lastActive = Date.now(); break; case "conversation": root.load(e.id, e.turns, null); // Opened from History on purpose: it counts as active now. if (e.turns.length) root.lastActive = Date.now(); // Emptied (New, or the current one deleted) with nothing staged: // back to the entry line. else if (!root.staged.length) root.chatting = false; break; case "user": turns.append(root.row({ user: e.text, files: e.files }, true)); root.staged = []; root.lastActive = Date.now(); 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; root.lastActive = Date.now(); break; case "progress": if (root.opDaemon) root.opChars = e.chars; break; case "written": if (root.opDaemon && root.opRunning) { root.opRunning = false; root.opOut = [e.path]; } break; case "error": // A write's outcome. An older daemon answers it with a plain // error and no turn; while nothing else runs, that is the write's. if (root.opDaemon && root.opRunning && (e.turn || root.state === "idle")) { root.opRunning = false; root.opError = e.message; break; } // 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; // ctx carries the model since the router can swap it after hello; an // older daemon leaves it out, and the hello's name stands. case "ctx": root.ctxUsed = e.used; root.ctxMax = e.max; if (e.model) root.model = e.model; 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: root.socketPath 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) fullHeight: Math.round(parent.height * 0.7) canvas: root turnModel: turns } } } }