diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 14:07:04 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 14:07:04 +0200 |
| commit | 043bddefebe49a5446c6ff6a0e28eace45d32681 (patch) | |
| tree | da9334a37d953301d48e1adc2e4fd278d20fa55e /docs/superpowers/plans/2026-10-16-assistant-canvas.md | |
| parent | 5471f053a2f97b534fb71f68f1f9765f9fe27bba (diff) | |
| download | quickshell-043bddefebe49a5446c6ff6a0e28eace45d32681.tar.gz quickshell-043bddefebe49a5446c6ff6a0e28eace45d32681.zip | |
docs: plan the assistant canvas
Five tasks: the markdown splitter, the canvas components, hosting them in
the desktop shell, the switch-over (voice overlay out, hypr-theme bind,
autostart and blur), and the user's visual check. Every file was run in a
scratch harness against the real daemon before planning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans/2026-10-16-assistant-canvas.md')
| -rw-r--r-- | docs/superpowers/plans/2026-10-16-assistant-canvas.md | 1279 |
1 files changed, 1279 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-16-assistant-canvas.md b/docs/superpowers/plans/2026-10-16-assistant-canvas.md new file mode 100644 index 0000000..6efeceb --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-assistant-canvas.md @@ -0,0 +1,1279 @@ +# 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 <its dir>`. 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. <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. + +// 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. <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. + +.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: <model making the commit> <noreply@anthropic.com>" +``` + +--- + +### 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. <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 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. <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 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. <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 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. <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 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. <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 + } + } + } +} +``` + +- [ ] **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 `<scratchpad>/harness` with links that mirror `desktop/`, so `import ".."` inside the canvas resolves to the harness: + +```bash +H=<scratchpad>/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=<scratchpad>/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 <scratchpad>/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 <scratchpad>/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: <model making the commit> <noreply@anthropic.com>" +``` + +--- + +### 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 <this dir> 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: <model making the commit> <noreply@anthropic.com>" +``` + +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: <model making the commit> <noreply@anthropic.com>" +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: <model making the commit> <noreply@anthropic.com>" +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. |
