aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 14:11:17 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 14:11:17 +0200
commitb7f33e54c93c5e3c764aa083c2f354fb8751e166 (patch)
tree6b9b66af1f0a5897137d13df66b19b504270da21
parent72d3ff1f0c8c2cadf315214f31312895154cd20e (diff)
downloadquickshell-b7f33e54c93c5e3c764aa083c2f354fb8751e166.tar.gz
quickshell-b7f33e54c93c5e3c764aa083c2f354fb8751e166.zip
feat(assistant): the canvas, a view over the assistant daemon
The socket, the turn model and the window live in Assistant.qml, outside the window's loader, so closing the card loses nothing and a reply keeps streaming while it is closed. Tokens reach the model on a 50 ms tick rather than per token. Prose goes through Notify.sanitize, because model output can quote a search result and a remote image in it would be fetched on render. Code blocks get a copy button once their fence closes. The history panel lists conversations and searches them through the daemon's full-text index. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
-rw-r--r--desktop/assistant/Assistant.qml208
-rw-r--r--desktop/assistant/Card.qml201
-rw-r--r--desktop/assistant/History.qml130
-rw-r--r--desktop/assistant/Message.qml107
-rw-r--r--desktop/assistant/README.md47
-rw-r--r--desktop/assistant/Segments.qml90
6 files changed, 783 insertions, 0 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
new file mode 100644
index 0000000..a04a24b
--- /dev/null
+++ b/desktop/assistant/Assistant.qml
@@ -0,0 +1,208 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import QtQuick
+import ".."
+
+// The assistant canvas: a view over desktop-assistant's socket. The daemon
+// owns the model, the tools and the history; this keeps the conversation it
+// was told about, so the card reopens where it was, and sends what is typed.
+// Protocol: desktop-assistant, docs/superpowers/specs/2026-10-16-text-daemon-design.md.
+Scope {
+ id: root
+
+ property string monitor: "DP-1"
+ property bool open: false
+ property bool historyOpen: false
+
+ readonly property bool connected: link.item?.connected ?? false
+ property string state: "idle"
+ property string model: ""
+ property int ctxUsed: 0
+ property int ctxMax: 0
+ property var convId: null
+ property string notice: "" // an error with no turn to hang it on
+ property string runningTool: ""
+ property var convs: []
+ property var results: []
+ readonly property bool busy: root.state !== "idle"
+
+ // One row per turn. tools is a comma-joined string: a ListModel role
+ // holding an array becomes a nested model, which a Repeater can read but
+ // setProperty cannot append to.
+ ListModel { id: turns }
+ readonly property alias turnsModel: turns // read by the load check harness
+
+ // Tokens arrive faster than markdown is worth re-rendering, so they
+ // collect here and reach the model on a 50 ms tick.
+ property string bufReply: ""
+ property string bufThink: ""
+
+ readonly property var screenObj:
+ Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
+
+ function toggle() { if (root.open) root.close(); else root.open = true; }
+ function close() { root.open = false; root.historyOpen = false; }
+ // Escape: the history panel first, then the card.
+ function back() { if (root.historyOpen) root.historyOpen = false; else root.close(); }
+
+ function send(cmd) {
+ if (!link.item) return;
+ link.item.write(JSON.stringify(cmd) + "\n");
+ link.item.flush();
+ }
+
+ function ask(text) {
+ if (!root.connected || text.trim() === "") return false;
+ root.notice = "";
+ root.send({ type: "ask", text: text });
+ return true;
+ }
+
+ function showHistory() {
+ root.historyOpen = true;
+ root.send({ type: "list" });
+ }
+
+ function row(t, live) {
+ return { user: t.user ?? "", reply: t.reply ?? "", think: t.think ?? "",
+ tools: (t.tools ?? []).join(","), error: "", live: live,
+ stopped: false, expanded: false };
+ }
+
+ function load(id, list, pending) {
+ root.bufReply = root.bufThink = "";
+ turns.clear();
+ for (const t of list) turns.append(root.row(t, false));
+ if (pending) turns.append(root.row(pending, true));
+ root.convId = id;
+ root.notice = "";
+ }
+
+ function last() { return turns.count ? turns.get(turns.count - 1) : null; }
+
+ function flush() {
+ const t = root.last();
+ if (!t || !t.live) { root.bufReply = root.bufThink = ""; return; }
+ if (root.bufThink) turns.setProperty(turns.count - 1, "think", t.think + root.bufThink);
+ if (root.bufReply) turns.setProperty(turns.count - 1, "reply", t.reply + root.bufReply);
+ root.bufReply = root.bufThink = "";
+ }
+
+ function handle(line) {
+ let e;
+ try { e = JSON.parse(line); } catch (err) { return; }
+ const i = turns.count - 1;
+ switch (e.type) {
+ case "hello":
+ root.state = e.state;
+ root.model = e.model ?? "";
+ root.ctxUsed = e.ctx_used;
+ root.ctxMax = e.ctx_max;
+ root.load(e.id, e.turns, e.pending);
+ break;
+ case "conversation":
+ root.load(e.id, e.turns, null);
+ break;
+ case "user":
+ turns.append(root.row({ user: e.text }, true));
+ break;
+ case "think": root.bufThink += e.delta; break;
+ case "token": root.bufReply += e.delta; break;
+ case "tool":
+ root.flush();
+ if (!e.done && i >= 0) {
+ const t = turns.get(i).tools;
+ turns.setProperty(i, "tools", t ? t + "," + e.name : e.name);
+ }
+ root.runningTool = e.done ? "" : e.name;
+ break;
+ case "done":
+ root.flush();
+ if (i >= 0 && turns.get(i).live) {
+ turns.setProperty(i, "live", false);
+ turns.setProperty(i, "stopped", e.stopped === true);
+ }
+ if (!e.stopped) root.convId = e.id;
+ root.runningTool = "";
+ break;
+ case "error":
+ root.flush();
+ if (i >= 0 && turns.get(i).live) {
+ turns.setProperty(i, "error", e.message);
+ turns.setProperty(i, "live", false);
+ } else {
+ root.notice = e.message;
+ }
+ root.runningTool = "";
+ break;
+ case "state": root.state = e.state; break;
+ case "ctx": root.ctxUsed = e.used; root.ctxMax = e.max; break;
+ case "conversations": root.convs = e.items; break;
+ case "results": root.results = e.items; break;
+ }
+ }
+
+ Timer {
+ interval: 50
+ repeat: true
+ running: root.bufReply !== "" || root.bufThink !== ""
+ onTriggered: root.flush()
+ }
+
+ // The daemon may start after this shell, or restart: keep trying with a
+ // fresh Socket each time. Quickshell 0.3.1 keeps the QLocalSocket of a
+ // failed connect, so setting `connected` again on the same Socket never
+ // retries. See AGENTS.md.
+ LazyLoader {
+ id: link
+ active: true
+ Socket {
+ path: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/desktop-assistant.sock`
+ connected: true
+ parser: SplitParser { onRead: line => root.handle(line) }
+ }
+ }
+
+ Timer {
+ interval: 2000
+ repeat: true
+ running: !root.connected
+ onTriggered: { link.active = false; link.active = true; }
+ }
+
+ LazyLoader {
+ active: root.open
+
+ PanelWindow {
+ screen: root.screenObj
+ anchors { top: true; left: true; right: true; bottom: true }
+ color: Qt.alpha(Theme.crust, 0.35)
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "quickshell-assistant"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ MouseArea { anchors.fill: parent; onClicked: root.close() }
+
+ Card {
+ anchors.centerIn: parent
+ width: Math.min(760, parent.width - 48)
+ height: Math.round(parent.height * 0.7)
+ canvas: root
+ turnModel: turns
+ }
+ }
+ }
+}
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
new file mode 100644
index 0000000..543edd1
--- /dev/null
+++ b/desktop/assistant/Card.qml
@@ -0,0 +1,201 @@
+// 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
+ }
+}
diff --git a/desktop/assistant/History.qml b/desktop/assistant/History.qml
new file mode 100644
index 0000000..114ab2b
--- /dev/null
+++ b/desktop/assistant/History.qml
@@ -0,0 +1,130 @@
+// 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 }
+ }
+ }
+}
diff --git a/desktop/assistant/Message.qml b/desktop/assistant/Message.qml
new file mode 100644
index 0000000..c724c2e
--- /dev/null
+++ b/desktop/assistant/Message.qml
@@ -0,0 +1,107 @@
+// 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 }
+ }
+}
diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md
new file mode 100644
index 0000000..0f80993
--- /dev/null
+++ b/desktop/assistant/README.md
@@ -0,0 +1,47 @@
+# 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`.
diff --git a/desktop/assistant/Segments.qml b/desktop/assistant/Segments.qml
new file mode 100644
index 0000000..2b68b64
--- /dev/null
+++ b/desktop/assistant/Segments.qml
@@ -0,0 +1,90 @@
+// 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 }
+ }
+ }
+ }
+ }
+}