# Assistant Canvas Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** A Luma-style conversation card on SUPER+Home, inside the desktop shell, as a pure view over desktop-assistant's socket; it replaces the voice overlay in `assistant/` and stands in for llamachat while it is tested. **Architecture:** `desktop/assistant/` holds the canvas. `Assistant.qml` owns the socket (retried with a fresh `Socket` in a `LazyLoader`), a `ListModel` of turns fed by the daemon's events, and a full-screen overlay window loaded only while open. `Card.qml` lays out header, transcript, input and the history panel; `Message.qml` draws one turn; `Segments.qml` renders markdown with a copy button per code block, using `Md.js` to split fences from prose; `History.qml` lists and searches conversations. **Tech Stack:** Quickshell 0.3.1 QML (`Quickshell.Io.Socket`, `SplitParser`, `LazyLoader`, `PanelWindow`, `IpcHandler`), QtQuick `TextEdit` with `MarkdownText`, QtQuick.Controls `TextField`, node for the splitter test, Hyprland Lua config in hypr-theme. **Spec:** `docs/superpowers/specs/2026-10-16-assistant-canvas-design.md`; the protocol is desktop-assistant's `docs/superpowers/specs/2026-10-16-text-daemon-design.md`. **Conventions:** - Read this repo's `AGENTS.md` first. The process is `qs`: never `pkill -f`, never `pkill -x qs` (it kills every component). Stop a harness started for a check with `qs kill -p `. A detached `qs` dies with the agent's tool call: run checks as harness-owned background commands and read their output. - No em dashes, no home paths or personal data in committed files (gitleaks hooks enforce it). GPLv2-only header in every source file. Commit subjects use `feat(assistant): ...` style. Commits are GPG-signed by default; end messages with a `Co-Authored-By:` trailer naming the model that makes the commit. Work on master. - For anything visual, ask the user: the agent never screenshots the card. - `reload.sh` and `assistant.sh start` are run by the user from their terminal, never from an agent's tool call. **Verified while planning:** every file below was run in a scratch harness: a shell that loads `Assistant.qml` and instantiates a `Card` inside a 1x1 click-through window (so every component resolves, nothing shows and the keyboard is not taken), against the real daemon with a scratch `XDG_DATA_HOME`. It logged no QML warnings; the canvas wrote and flushed `list`, `ask` and `search` on the socket; a streamed reply landed in the turn model as `{"reply":"```bash\necho hi\n```\ndone", "think": "...", "live": false}`; `search` returned the conversation with a `**echo**` snippet. With the daemon down the only log lines are `quickshell.io.socket: ... ServerNotFoundError`, once per 2 s retry. `node test_md.js` prints `ok`. **Decided while planning:** - The canvas window is a full-screen overlay with a 35% crust dim; a click outside the card closes it. - The turn model's `tools` role is a comma-joined string (an array role becomes a nested model `setProperty` cannot append to). A chip is lit while its tool runs. - Tokens are batched on a 50 ms tick. - Errors with no live turn (for example `bad id`) show as a red line above the input. - History delete is a two-click arm, like the clipboard's clear-all. - `Assistant.qml` exposes `turnsModel`, read only by the load-check harness. --- ## File structure | File | Repo | Status | Responsibility | | --- | --- | --- | --- | | `desktop/assistant/Md.js` | quickshell | create | prose / fenced code splitter | | `desktop/assistant/test_md.js` | quickshell | create | node check for the splitter | | `desktop/assistant/Assistant.qml` | quickshell | create | socket, turn model, window | | `desktop/assistant/Card.qml` | quickshell | create | header, transcript, input | | `desktop/assistant/Message.qml` | quickshell | create | one turn | | `desktop/assistant/Segments.qml` | quickshell | create | markdown + code blocks | | `desktop/assistant/History.qml` | quickshell | create | list and search | | `desktop/assistant/README.md` | quickshell | create | component notes | | `desktop/shell.qml` | quickshell | modify | host the canvas, IPC target | | `assistant/` | quickshell | delete | the voice overlay | | `AGENTS.md`, `desktop/README.md`, `desktop/modules/ai/README.md` | quickshell | modify | references | | `sections/autostart.lua`, `sections/keybindings.lua`, `sections/decorations.lua` | hypr-theme | modify | daemon start, bind, blur | --- ### Task 1: The markdown splitter **Files:** - Create: `desktop/assistant/test_md.js`, `desktop/assistant/Md.js` - [ ] **Step 1: Write the failing test** Create `desktop/assistant/test_md.js`: ```js // 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. // Run: node desktop/assistant/test_md.js // Md.js is a QML library, so its .pragma line is stripped before node // evaluates it. const fs = require("fs"); const assert = require("assert"); const src = fs.readFileSync(__dirname + "/Md.js", "utf8") .replace(/^\.pragma library$/m, ""); const { split } = new Function(src + "\nreturn { split };")(); const prose = text => ({ kind: "prose", text }); const code = (lang, text, open = false) => ({ kind: "code", lang, text, open }); assert.deepStrictEqual(split(""), []); assert.deepStrictEqual(split("Just **prose**.\n\nTwo paragraphs."), [prose("Just **prose**.\n\nTwo paragraphs.")]); // One fence, with a language, between prose. assert.deepStrictEqual(split("Run:\n```bash\nls -l\n```\nDone."), [prose("Run:"), code("bash", "ls -l"), prose("Done.")]); // Two fences, no language, blank lines kept inside code. assert.deepStrictEqual(split("```\na\n\nb\n```\n```\nc\n```"), [code("", "a\n\nb"), code("", "c")]); // Streaming: an unclosed fence is open code. assert.deepStrictEqual(split("Here:\n```py\nprint(1)\npri"), [prose("Here:"), code("py", "print(1)\npri", true)]); assert.deepStrictEqual(split("```"), [code("", "", true)]); // Inline backticks are not a fence. assert.deepStrictEqual(split("Use `ls` or ``a`b``."), [prose("Use `ls` or ``a`b``.")]); // A fence in a list item: its indent is stripped from the code. assert.deepStrictEqual(split("1. Install:\n ```sh\n make\n make install\n ```\n2. Go"), [prose("1. Install:"), code("sh", "make\n make install"), prose("2. Go")]); // A shorter or different closing run does not close the fence. assert.deepStrictEqual(split("````\n```\n~~~\n````"), [code("", "```\n~~~")]); // Tilde fences. assert.deepStrictEqual(split("~~~js\nx\n~~~"), [code("js", "x")]); // A closing fence with text after it is content, not a close. assert.deepStrictEqual(split("```\n``` not\n```"), [code("", "``` not")]); console.log("ok"); ``` - [ ] **Step 2: Run it to make sure it fails** Run: `node desktop/assistant/test_md.js` Expected: `Error: ENOENT: no such file or directory, open '.../desktop/assistant/Md.js'` - [ ] **Step 3: Write the splitter** Create `desktop/assistant/Md.js`: ```js // 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. .pragma library // Splits a message into prose and fenced code, so each code block can carry // its own copy button: Qt's markdown renders fences but gives no per-block // hook. A fence opens with ``` or ~~~ (three or more) after any indent, as // inside a list item, and closes with a run of the same character at least // as long. The fence's indent is stripped from its lines. A fence still open // at the end, which is normal while a reply streams, is a code segment with // open set. function split(text) { const out = []; const prose = []; let code = null; const flush = () => { const t = prose.join("\n").trim(); if (t) out.push({ kind: "prose", text: t }); prose.length = 0; }; for (const line of text.split("\n")) { if (code) { const close = /^\s*(`{3,}|~{3,})\s*$/.exec(line); if (close && close[1][0] === code.fence[0] && close[1].length >= code.fence.length) { out.push({ kind: "code", lang: code.lang, text: code.lines.join("\n"), open: false }); code = null; } else { code.lines.push(line.startsWith(code.indent) ? line.slice(code.indent.length) : line.trimStart()); } continue; } const open = /^(\s*)(`{3,}|~{3,})\s*([^`\s]*)/.exec(line); if (open) { flush(); code = { indent: open[1], fence: open[2], lang: open[3], lines: [] }; } else { prose.push(line); } } if (code) out.push({ kind: "code", lang: code.lang, text: code.lines.join("\n"), open: true }); else flush(); return out; } ``` - [ ] **Step 4: Run it** Run: `node desktop/assistant/test_md.js` Expected: `ok` - [ ] **Step 5: Commit** ```bash git add desktop/assistant/Md.js desktop/assistant/test_md.js git commit -m "feat(assistant): split messages into prose and fenced code Qt's markdown renders a fence but gives no per-block hook, so a copy button per code block needs the fences found first. An unclosed fence is open code, which is every fence while a reply streams; a fence inside a list item keeps its indent out of the copied text. Co-Authored-By: " ``` --- ### Task 2: The canvas components **Files:** - Create: `desktop/assistant/Segments.qml`, `Message.qml`, `History.qml`, `Card.qml`, `Assistant.qml`, `README.md` - [ ] **Step 1: Create `desktop/assistant/Segments.qml`:** ```qml // 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 QtQuick import ".." import "Md.js" as Md // Markdown with a copy button per code block. Prose is a read-only TextEdit, // selectable unlike Text. It goes through Notify.sanitize, which blocks // remote images: model output can quote a search result, and an image URL // in it would be fetched the moment it rendered. Column { id: seg property string text spacing: 8 Repeater { model: Md.split(seg.text) Item { id: part required property var modelData width: seg.width height: modelData.kind === "code" ? block.height : prose.height TextEdit { id: prose visible: part.modelData.kind === "prose" width: part.width height: visible ? implicitHeight : 0 readOnly: true selectByMouse: true textFormat: TextEdit.MarkdownText text: visible ? Notify.sanitize(part.modelData.text) : "" wrapMode: TextEdit.Wrap color: Theme.text selectionColor: Qt.alpha(Theme.accent, 0.4) font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } onLinkActivated: link => Qt.openUrlExternally(link) HoverHandler { cursorShape: prose.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor } } Rectangle { id: block visible: part.modelData.kind === "code" width: part.width height: visible ? code.height + 16 : 0 radius: 8 color: Qt.alpha(Theme.crust, 0.6) TextEdit { id: code anchors { left: parent.left; right: copy.left; top: parent.top; margins: 8 } readOnly: true selectByMouse: true textFormat: TextEdit.PlainText text: part.modelData.text ?? "" wrapMode: TextEdit.Wrap color: Theme.text selectionColor: Qt.alpha(Theme.accent, 0.4) font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 3 } } // Copy only a finished block: a half-streamed one is not the // command the model meant. Button { id: copy anchors { right: parent.right; top: parent.top; margins: 6 } visible: !part.modelData.open width: visible ? implicitWidth : 0 text: copied.running ? "Copied" : (part.modelData.lang || "Copy") onClicked: { Quickshell.execDetached(["wl-copy", "--", part.modelData.text]); copied.restart(); } Timer { id: copied; interval: 1200 } } } } } } ``` - [ ] **Step 2: Create `desktop/assistant/Message.qml`:** ```qml // 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 QtQuick import ".." // One turn: the question, then the reply with its thinking line, the tool // chips, and an error or stopped mark. Both texts go through Segments, so // the question renders as markdown too. Column { id: msg required property int index required property string user required property string reply required property string think required property string tools required property string error required property bool live required property bool stopped required property bool expanded required property var model // the row, for setting expanded property string running: "" // the tool running now, if any spacing: 6 Rectangle { width: msg.width height: question.height + 16 radius: 8 color: Qt.alpha(Theme.surface, 0.7) Segments { id: question anchors { left: parent.left; right: parent.right; top: parent.top; margins: 8 } text: msg.user } } // Collapsed by default; a click shows the reasoning as plain text. Text { visible: msg.think !== "" text: (msg.expanded ? "▾ " : "▸ ") + (msg.live && msg.reply === "" ? "thinking…" : "thinking") color: Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: msg.model.expanded = !msg.expanded } } Text { width: msg.width visible: msg.expanded && msg.think !== "" text: msg.think textFormat: Text.PlainText wrapMode: Text.Wrap color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } Flow { width: msg.width spacing: 6 visible: msg.tools !== "" Repeater { model: msg.tools ? msg.tools.split(",") : [] Rectangle { required property string modelData readonly property bool on: msg.live && msg.running === modelData width: chip.implicitWidth + 14 height: chip.implicitHeight + 6 radius: height / 2 color: Qt.alpha(Theme.surface, 0.9) opacity: on ? 1 : 0.55 Text { id: chip anchors.centerIn: parent text: parent.modelData color: parent.on ? Theme.text : Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } } } } } Segments { width: msg.width text: msg.reply visible: msg.reply !== "" } Text { visible: msg.error !== "" || msg.stopped width: msg.width text: msg.error !== "" ? msg.error : "stopped" color: msg.error !== "" ? Theme.red : Theme.overlay wrapMode: Text.Wrap font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } } ``` - [ ] **Step 3: Create `desktop/assistant/History.qml`:** ```qml // 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 QtQuick import QtQuick.Controls import ".." // The history panel, over the transcript. Empty search: every conversation, // newest first. Text in the field: the daemon's full-text results, with the // matched words in bold from its snippet markers. Rectangle { id: panel property var canvas color: Qt.alpha(Theme.base, 0.97) radius: 8 onVisibleChanged: if (visible) { field.text = ""; field.forceActiveFocus(); } MouseArea { anchors.fill: parent } // keep clicks off the transcript below TextField { id: field anchors { top: parent.top; left: parent.left; right: parent.right } height: 32 placeholderText: "Search conversations" color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } background: Rectangle { radius: 6 color: Qt.alpha(Theme.surface, 0.9) border.width: 1 border.color: Qt.alpha(Theme.text, 0.15) } onTextChanged: debounce.restart() } // One query per pause in typing, not per key. Timer { id: debounce interval: 150 onTriggered: field.text.trim() === "" ? panel.canvas.send({ type: "list" }) : panel.canvas.send({ type: "search", q: field.text }) } ListView { id: rows anchors { top: field.bottom; topMargin: 8; left: parent.left; right: parent.right; bottom: parent.bottom } clip: true spacing: 4 boundsBehavior: Flickable.StopAtBounds model: field.text.trim() === "" ? panel.canvas.convs : panel.canvas.results delegate: Rectangle { id: r required property var modelData width: rows.width height: col.height + 12 radius: 6 color: hover.hovered ? Qt.alpha(Theme.surface, 0.9) : "transparent" border.width: r.modelData.id === panel.canvas.convId ? 1 : 0 border.color: Qt.alpha(Theme.accent, 0.5) HoverHandler { id: hover } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { panel.canvas.send({ type: "open", id: r.modelData.id }); panel.canvas.historyOpen = false; } } Column { id: col anchors { left: parent.left; right: del.left; verticalCenter: parent.verticalCenter; margins: 8 } spacing: 2 Text { width: col.width text: r.modelData.title elide: Text.ElideRight color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } Text { width: col.width // A list row has its date; a search row its snippet. text: r.modelData.snippet !== undefined // Backticks go: a match inside a code span would // show its ** markers literally. ? r.modelData.snippet.replace(/\s+/g, " ").replace(/`/g, "") : new Date(r.modelData.updated * 1000).toLocaleString(Qt.locale(), Locale.ShortFormat) textFormat: r.modelData.snippet !== undefined ? Text.MarkdownText : Text.PlainText elide: Text.ElideRight maximumLineCount: 1 color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } } // Two clicks: the first arms it for 3 seconds, as the clipboard's // clear-all does. Button { id: del anchors { right: parent.right; rightMargin: 6; verticalCenter: parent.verticalCenter } text: armed.running ? "Sure?" : "Delete" danger: true onClicked: { if (!armed.running) { armed.restart(); return; } armed.stop(); panel.canvas.send({ type: "delete", id: r.modelData.id }); } Timer { id: armed; interval: 3000 } } } Text { anchors.centerIn: parent visible: rows.count === 0 text: field.text.trim() === "" ? "No conversations yet." : "Nothing found." color: Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } } } ``` - [ ] **Step 4: Create `desktop/assistant/Card.qml`:** ```qml // 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 QtQuick import ".." // The card: header, transcript, input, and the history panel over the // transcript. All state lives in the canvas (Assistant.qml), which outlives // this card, so closing and reopening loses nothing. Rectangle { id: card property var canvas property var turnModel color: Qt.alpha(Theme.base, 0.82) radius: 14 border.width: 1 border.color: Qt.alpha(Theme.text, 0.12) // Keys reach a focused item, never the window (AGENTS.md); the card is // the ancestor of both text fields, so their Escape bubbles here. Keys.onEscapePressed: card.canvas.back() // Clicks on the card must not reach the close-on-click catcher behind it. MouseArea { anchors.fill: parent } Component.onCompleted: input.forceActiveFocus() // --- header --- Item { id: header anchors { top: parent.top; left: parent.left; right: parent.right; margins: 14 } height: 32 Text { anchors { left: parent.left; verticalCenter: parent.verticalCenter } text: !card.canvas.connected ? "assistant not running" : card.canvas.model || "assistant" color: card.canvas.connected ? Theme.text : Theme.red font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } Row { anchors { right: parent.right; verticalCenter: parent.verticalCenter } spacing: 8 // How full the model's context is, as llamachat shows it: amber // at 75%, red at 90%. Text { anchors.verticalCenter: parent.verticalCenter visible: card.canvas.ctxMax > 0 readonly property real used: card.canvas.ctxUsed / Math.max(1, card.canvas.ctxMax) text: `${card.canvas.ctxUsed} / ${card.canvas.ctxMax}` color: used >= 0.9 ? Theme.red : used >= 0.75 ? Theme.yellow : Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } Button { text: "History" onClicked: card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory() } Button { text: "New" enabled: card.canvas.connected onClicked: { card.canvas.send({ type: "new" }); card.canvas.historyOpen = false; input.forceActiveFocus(); } } } } // --- transcript --- ListView { id: list anchors { top: header.bottom; topMargin: 8; bottom: footer.top; bottomMargin: 8 left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 } clip: true spacing: 14 boundsBehavior: Flickable.StopAtBounds model: card.turnModel // Follow the bottom while a reply streams, unless the user scrolled // up to read; scrolling back to the end resumes following. property bool stick: true onMovementEnded: stick = atYEnd onContentHeightChanged: if (stick) Qt.callLater(positionViewAtEnd) Component.onCompleted: positionViewAtEnd() delegate: Message { width: list.width running: card.canvas.runningTool } Text { anchors.centerIn: parent visible: list.count === 0 text: card.canvas.connected ? "Ask anything." : "Start it with assistant.sh start." color: Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize } } } // --- input --- Item { id: footer anchors { bottom: parent.bottom; left: parent.left; right: parent.right; margins: 14 } height: notice.height + box.height + (notice.visible ? 6 : 0) Text { id: notice anchors { top: parent.top; left: parent.left; right: parent.right } visible: card.canvas.notice !== "" height: visible ? implicitHeight : 0 text: card.canvas.notice color: Theme.red wrapMode: Text.Wrap font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } Rectangle { id: box anchors { bottom: parent.bottom; left: parent.left; right: send.left; rightMargin: 8 } // Grows with the text up to six lines, then scrolls. height: Math.min(input.contentHeight, 6 * input.font.pixelSize * 1.4) + 16 radius: 8 color: Qt.alpha(Theme.surface, 0.9) border.width: 1 border.color: Qt.alpha(Theme.text, input.activeFocus ? 0.3 : 0.15) Flickable { id: scroller anchors { fill: parent; margins: 8 } contentHeight: input.contentHeight clip: true boundsBehavior: Flickable.StopAtBounds function follow(r) { if (r.y < contentY) contentY = r.y; else if (r.y + r.height > contentY + height) contentY = r.y + r.height - height; } TextEdit { id: input width: scroller.width wrapMode: TextEdit.Wrap selectByMouse: true enabled: card.canvas.connected color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } onCursorRectangleChanged: scroller.follow(cursorRectangle) // Enter sends, Shift+Enter is a newline: an unaccepted // key goes on to the TextEdit, which inserts it. function submit(event) { if (event.modifiers & Qt.ShiftModifier) { event.accepted = false; return; } if (card.canvas.ask(text)) text = ""; } Keys.onReturnPressed: event => submit(event) Keys.onEnterPressed: event => submit(event) } } Text { anchors { left: parent.left; top: parent.top; margins: 8 } visible: input.text === "" text: "Message the assistant" color: Theme.overlay font: input.font } } Button { id: send anchors { bottom: parent.bottom; right: parent.right } height: box.height text: card.canvas.busy ? "Stop" : "Send" danger: card.canvas.busy enabled: card.canvas.connected onClicked: { if (card.canvas.busy) card.canvas.send({ type: "stop" }); else if (card.canvas.ask(input.text)) input.text = ""; input.forceActiveFocus(); } } } History { anchors.fill: list visible: card.canvas.historyOpen canvas: card.canvas } } ``` - [ ] **Step 5: Create `desktop/assistant/Assistant.qml`:** ```qml // 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" // 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 } } } } ``` - [ ] **Step 6: Create `desktop/assistant/README.md`:** ```markdown # assistant The assistant canvas: a centred card on DP-1, on SUPER+Home, for typed conversations with the local text assistant. It is only a view. desktop-assistant (the repo next to this one) is the daemon that owns the model, the tools and the history; this connects to its socket, `$XDG_RUNTIME_DIR/desktop-assistant.sock`, draws what it is told and sends what is typed. The protocol is specified in that repo, `docs/superpowers/specs/2026-10-16-text-daemon-design.md`. qs -p ./desktop ipc call assistant toggle | File | Does | | --- | --- | | `Assistant.qml` | the socket, the turn model, the window | | `Card.qml` | header, transcript, input, the history panel's place | | `Message.qml` | one turn: question, thinking line, tool chips, reply | | `Segments.qml` | markdown with a copy button per code block | | `History.qml` | conversation list and full-text search | | `Md.js` | splits a message into prose and fenced code | Keys: Enter sends, Shift+Enter is a newline, Escape closes the history panel and then the card. Stop replaces Send while a reply streams. Notes: - **The state outlives the card.** The socket and the turn model live in `Assistant.qml`, outside the window's loader, so closing the card loses nothing, and a reply keeps streaming into it while it is closed. - **Tokens are batched.** They collect in a buffer and reach the model on a 50 ms tick, so markdown is re-rendered twenty times a second at most rather than per token. - **Prose goes through `Notify.sanitize`**, which blocks remote images. Model output can quote a search result, and an image URL in it would be fetched the moment it rendered. - **A code block's copy button appears once the fence closes.** A half-streamed block is not the command the model meant. - **The daemon may be down.** The card says so and disables the input; the socket retries every 2 s with a fresh `Socket` (see AGENTS.md), logging a `ServerNotFoundError` warning each time. - **`tools` is a comma-joined string role.** A ListModel role holding an array becomes a nested model, which `setProperty` cannot append to. - **Search snippets** mark matches with `**`, rendered as bold; backticks are stripped first, since a match inside a code span would show the markers literally. Test the splitter: `node desktop/assistant/test_md.js`. ``` - [ ] **Step 7: Build the load-check harness in the session's scratchpad** Not in the repo. Make a directory `/harness` with links that mirror `desktop/`, so `import ".."` inside the canvas resolves to the harness: ```bash H=/harness; R=~/Programming/GIT/quickshell mkdir -p $H ln -sf $R/shared/Theme.qml $H/Theme.qml ln -sf $R/shared/Glow.qml $H/Glow.qml ln -sf $R/shared/Notify.qml $H/Notify.qml ln -sf $R/desktop/Button.qml $H/Button.qml ln -sfn $R/desktop/assistant $H/assistant ``` and write `$H/shell.qml`: ```qml import Quickshell import Quickshell.Io import Quickshell.Wayland import QtQuick import "assistant" // Prototype harness: loads the canvas, drives it over the real socket, and // instantiates a Card inside a 1x1 window so every component is resolved // without putting anything on screen or taking the keyboard. ShellRoot { Assistant { id: a } IpcHandler { target: "assistant" function toggle() { a.toggle(); } } PanelWindow { anchors { top: true; left: true } implicitWidth: 1 implicitHeight: 1 color: "transparent" mask: Region {} exclusionMode: ExclusionMode.Ignore WlrLayershell.keyboardFocus: WlrKeyboardFocus.None Card { width: 760; height: 700; canvas: a; turnModel: a.turnsModel } } property int step: 0 Timer { interval: 500; repeat: true; running: true onTriggered: { if (!a.connected) return; if (step === 0) { a.send({ type: "list" }); step = 1; } else if (step === 1) { a.ask("Reply with one fenced bash code block containing `echo hi`, then the word done."); step = 2; } else if (step === 2 && !a.busy && a.convId !== null) { console.log("PROTO turns=" + a.turnsModel.count + " last=" + JSON.stringify(a.turnsModel.get(a.turnsModel.count - 1))); a.send({ type: "search", q: "echo" }); step = 3; } else if (step === 3) { console.log("PROTO results=" + JSON.stringify(a.results) + " convs=" + JSON.stringify(a.convs)); step = 4; } } } } ``` - [ ] **Step 8: Run the daemon against a scratch history** No other daemon may be running: `pgrep -af 'python3 (-u )?\S*assistant\.py'` must print nothing (ask the user otherwise). Then, as a harness-owned background command: `cd ~/Programming/GIT/desktop-assistant && XDG_DATA_HOME=/data exec /usr/bin/python3 -u assistant.py` Expected in its output: `ready on .../desktop-assistant.sock`. - [ ] **Step 9: Run the harness** As a harness-owned background command: `exec qs -p /harness`. Wait (with an until-loop on its output, not a sleep) for a `PROTO results=` line or any `WARN`/`ERROR` line other than the portal one. Expected: - no `ReferenceError`, `TypeError`, `Cannot assign` or `is not a type` lines; - `PROTO turns=1 last={...}` whose `reply` holds a ```` ```bash ```` fence with `echo hi` and whose `live` is false; - `PROTO results=[{"id":1,...,"snippet":"...**echo**..."}]`. - [ ] **Step 10: Stop both** `qs kill -p /harness`, then the daemon by its PID (`kill -TERM $(pgrep -f '^/usr/bin/python3 -u assistant\.py$')`). Expected: the daemon logs `exit: SystemExit 0`; `pgrep -cx qs` shows only the user's own components (the count from before Step 9). - [ ] **Step 11: Commit** ```bash git add desktop/assistant git commit -m "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: " ``` --- ### Task 3: Host it in the desktop shell **Files:** - Modify: `desktop/shell.qml` - [ ] **Step 1: Import the directory** After the line `import "island"`, add `import "assistant"`. - [ ] **Step 2: Add the canvas** After the `Island { ... }` block, add: ```qml // The assistant canvas on SUPER+Home, a view over desktop-assistant's // socket. See assistant/README.md. Assistant { id: assistant } ``` - [ ] **Step 3: Add the IPC target** After the existing `IpcHandler { target: "drawer" ... }` block, add: ```qml // qs -p ipc call assistant toggle -> SUPER+Home IpcHandler { target: "assistant" function toggle() { assistant.toggle(); } } ``` - [ ] **Step 4: Commit** ```bash git add desktop/shell.qml git commit -m "feat(desktop): host the assistant canvas Inside the desktop process rather than its own shell: it needs no keepalive window of its own and restarts with the rest of the desktop. Co-Authored-By: " ``` The running desktop shell does not pick this up by hot reload: `assistant/` is a new directory import (AGENTS.md, Reloading). It takes effect in Task 4. --- ### Task 4: Switch over **Files:** - Delete: `assistant/` (quickshell) - Modify: `AGENTS.md`, `desktop/README.md`, `desktop/modules/ai/README.md` (quickshell); `sections/autostart.lua`, `sections/keybindings.lua`, `sections/decorations.lua` (hypr-theme) - [ ] **Step 1: Retire the voice overlay** Run: `git rm -r assistant/` - [ ] **Step 2: Update the quickshell docs** - `AGENTS.md`, the tree under "What this is": delete the line ` assistant/ overlay for the voice assistant, fed by its event socket`, and after the `island/` lines under `desktop/` add ` assistant/ the assistant canvas on SUPER+Home, a view over desktop-assistant`. In the same file, also change the opening paragraph's "the drawer, which is the control center, and the island" to "the drawer, which is the control center, the island, and the assistant canvas". - `AGENTS.md`, the `Socket` retry note: change `` (`assistant/Assistant.qml`) `` to `` (`desktop/assistant/Assistant.qml`) ``. - `desktop/README.md`: after "The same process also hosts the island, see `island/README.md`." add "and the assistant canvas on SUPER+Home, see `assistant/README.md`." - `desktop/modules/ai/README.md`: replace the sentence "`assistant.sh status` fails when either the assistant or its overlay is down, so a dead overlay reads as stopped and Start restarts it." with "`assistant.sh status` fails when the daemon is down." Check: `grep -rn "assistant/Assistant.qml\|overlay for the voice" AGENTS.md desktop` prints only the new `desktop/assistant/Assistant.qml` reference. - [ ] **Step 3: Commit (quickshell)** ```bash git add -A assistant AGENTS.md desktop/README.md desktop/modules/ai/README.md git commit -m "chore(assistant): retire the voice overlay desktop-assistant is a text daemon now (its voice version is the tag voice-v1), and the canvas in desktop/assistant/ is its front end. Co-Authored-By: " git push ``` - [ ] **Step 4: hypr-theme, autostart** (`~/Programming/GIT/hypr-theme/sections/autostart.lua`) Show the user this diff and get a yes before writing it; this is the live Hyprland config. - delete the line ` -- hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/assistant")`; - replace the three lines starting ` -- local voice assistant, see` with: ```lua -- local text assistant daemon, see ~/Programming/GIT/desktop-assistant; -- the quickshell desktop's canvas (SUPER+Home) is its front end. Its log -- holds questions, so it lives in the runtime dir: private, gone at reboot. hl.exec_cmd("python3 -u ~/Programming/GIT/desktop-assistant/assistant.py >> \"$XDG_RUNTIME_DIR/desktop-assistant.log\" 2>&1") ``` `llamachat --daemon` stays. - [ ] **Step 5: hypr-theme, keybind** (`sections/keybindings.lua`) Replace the line binding `mainMod .. " + Home"` to `llamachat --toggle` with: ```lua hl.bind(mainMod .. " + Home", hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop ipc call assistant toggle"), { description = "Desktop: Assistant" }) ``` - [ ] **Step 6: hypr-theme, blur** (`sections/decorations.lua`) After the `blur-island` rule, add: ```lua -- Frosted glass for the quickshell assistant canvas. hl.layer_rule({ name = "blur-assistant", match = { namespace = "^(quickshell-assistant)$" }, blur = true, xray = false, ignore_alpha = 0.1, }) ``` - [ ] **Step 7: Reload Hyprland and commit (hypr-theme)** Run: `hyprctl reload` → expected `ok`. Then: ```bash cd ~/Programming/GIT/hypr-theme git add sections/autostart.lua sections/keybindings.lua sections/decorations.lua git commit -m "feat: SUPER+Home opens the assistant canvas desktop-assistant is a text daemon behind the quickshell desktop's canvas, which takes llamachat's place on the bind while it is tested. llamachat still starts, unbound. The daemon starts at login, the voice overlay line goes, and the canvas gets a blur rule for its layer namespace. Co-Authored-By: " git push ``` - [ ] **Step 8: Hand over to the user** Ask the user to run, from their own terminal: ```sh ~/Programming/GIT/desktop-assistant/assistant.sh start ~/Programming/GIT/quickshell/reload.sh desktop ``` The first starts the daemon now (autostart only covers the next login). The second restarts the desktop shell, which a new directory import requires. --- ### Task 5: The user's visual check Ask the user to go through these and report; fix what they report before closing the task. Each fix is its own commit. 1. SUPER+Home opens the card centred on DP-1 over a dimmed, frosted screen; SUPER+Home again, Escape, or a click outside closes it. 2. The header shows the model name and `used / max`; the input has focus when the card opens. 3. A question with a code block streams: the thinking line reads `▸ thinking…` while reasoning, the reply appears in place, and the code block's copy button appears once the fence closes and copies the code (paste it somewhere). 4. Clicking `▸ thinking` expands the reasoning; it stays expanded while the card is closed and reopened. 5. Shift+Enter inserts a newline; Enter sends. Stop during a reply ends it with a `stopped` line. 6. "How many unread mails do I have?" shows a `mail` chip, lit while it runs. 7. History lists the conversations; typing filters them with bold matches; clicking one opens it; Delete asks "Sure?" and removes it; Escape closes the panel before the card. 8. New clears the transcript. Closing the card mid-reply and reopening it shows the reply still streaming. 9. `assistant.sh stop`: the header reads "assistant not running" in red and the input disables; `assistant.sh start` brings it back within 2 s.