diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 14:11:17 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 14:11:17 +0200 |
| commit | b7f33e54c93c5e3c764aa083c2f354fb8751e166 (patch) | |
| tree | 6b9b66af1f0a5897137d13df66b19b504270da21 /desktop/assistant | |
| parent | 72d3ff1f0c8c2cadf315214f31312895154cd20e (diff) | |
| download | quickshell-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>
Diffstat (limited to 'desktop/assistant')
| -rw-r--r-- | desktop/assistant/Assistant.qml | 208 | ||||
| -rw-r--r-- | desktop/assistant/Card.qml | 201 | ||||
| -rw-r--r-- | desktop/assistant/History.qml | 130 | ||||
| -rw-r--r-- | desktop/assistant/Message.qml | 107 | ||||
| -rw-r--r-- | desktop/assistant/README.md | 47 | ||||
| -rw-r--r-- | desktop/assistant/Segments.qml | 90 |
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 } + } + } + } + } +} |
