From f5da8d8ee2cb4ace1fa6841437b4798e7799c100 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 15:23:41 +0200 Subject: feat(assistant): entry line, file state, staged files and a picker The card has three states: an entry line, a file state for staged files with Chat about it, and the chat. SUPER+Home resumes a conversation active in the last 10 minutes and otherwise starts a new one. The paperclip opens a picker drawn in the card, since a system dialog would open beneath the overlay that holds the keyboard. Button gains a font alias for the paperclip glyph, and the canvas a socketPath property so a check can point it at a scratch daemon. Co-Authored-By: Claude Haiku 5.5 --- desktop/Button.qml | 1 + desktop/assistant/Assistant.qml | 80 ++++++++++++++++--- desktop/assistant/Card.qml | 170 ++++++++++++++++++++++++++++++++++++---- desktop/assistant/Message.qml | 27 +++++++ desktop/assistant/Picker.qml | 143 +++++++++++++++++++++++++++++++++ desktop/assistant/README.md | 23 +++++- 6 files changed, 415 insertions(+), 29 deletions(-) create mode 100644 desktop/assistant/Picker.qml diff --git a/desktop/Button.qml b/desktop/Button.qml index 3e0d63f..b93a926 100644 --- a/desktop/Button.qml +++ b/desktop/Button.qml @@ -14,6 +14,7 @@ import QtQuick Rectangle { id: btn property alias text: label.text + property alias font: label.font property bool danger: false property bool enabled: true signal clicked diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml index d143219..b038dd6 100644 --- a/desktop/assistant/Assistant.qml +++ b/desktop/assistant/Assistant.qml @@ -14,6 +14,7 @@ import Quickshell.Io import Quickshell.Wayland import QtQuick import ".." +import "Files.js" as Files // The assistant canvas: a view over desktop-assistant's socket. The daemon // owns the model, the tools and the history; this keeps the conversation it @@ -23,8 +24,25 @@ Scope { id: root property string monitor: "DP-1" + // The daemon's socket; a check against a scratch daemon points it elsewhere. + property string socketPath: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/desktop-assistant.sock` property bool open: false property bool historyOpen: false + property bool pickerOpen: false + property string pickDir: "" // where the picker opened last; "" is home + + // Files waiting to go with the next question: [{path, name, kind}]. + property var staged: [] + // Chat about it: the chat layout before any turn exists. + property bool chatting: false + // When the conversation last moved, for the resume rule in show(). + property double lastActive: 0 + + // entry: one line. file: files staged, nothing asked yet. chat: turns. + readonly property string mode: turns.count || root.chatting ? "chat" + : root.staged.length ? "file" : "entry" + // A staged kind chat does not take holds the question back. + readonly property bool blocked: root.staged.some(f => !Files.chats(f.kind)) readonly property bool connected: link.item?.connected ?? false property string state: "idle" @@ -55,10 +73,46 @@ Scope { 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 toggle() { if (root.open) root.close(); else root.show(); } + + // A conversation idle for 10 minutes is over: opening starts a new one, + // the old one staying in History. ponytail: a constant, not a setting. + function show() { + if (turns.count && Date.now() - root.lastActive > 10 * 60 * 1000) root.fresh(); + root.open = true; + } + + function close() { root.open = false; root.historyOpen = false; root.pickerOpen = false; } + // Escape: the picker, then the history panel, then the card. + function back() { + if (root.pickerOpen) root.pickerOpen = false; + else if (root.historyOpen) root.historyOpen = false; + else root.close(); + } + + function fresh() { + root.send({ type: "new" }); + root.chatting = false; + } + + // A drop is a new task: a new conversation with these files staged. + function attach(paths) { + const files = paths.filter(p => p !== "").map(Files.file); + if (!files.length) return; + root.fresh(); + root.staged = files; + root.historyOpen = root.pickerOpen = false; + root.open = true; + } + + function stage(path) { + if (!root.staged.some(f => f.path === path)) root.staged = root.staged.concat([Files.file(path)]); + } + function unstage(i) { + const s = root.staged.slice(); + s.splice(i, 1); + root.staged = s; + } function send(cmd) { if (!link.item) return; @@ -67,9 +121,10 @@ Scope { } function ask(text) { - if (!root.connected || text.trim() === "") return false; + if (!root.connected || root.blocked || (text.trim() === "" && !root.staged.length)) return false; root.notice = ""; - root.send({ type: "ask", text: text }); + root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) }); + root.staged = []; return true; } @@ -81,6 +136,8 @@ Scope { function row(t, live) { return { user: t.user ?? "", reply: t.reply ?? "", think: t.think ?? "", tools: (t.tools ?? []).join(","), error: "", live: live, + // JSON for the same reason tools is a string. + files: JSON.stringify(t.files ?? []), stopped: false, expanded: false }; } @@ -115,12 +172,16 @@ Scope { root.ctxUsed = e.ctx_used; root.ctxMax = e.ctx_max; root.load(e.id, e.turns, e.pending); + if (e.pending) root.lastActive = Date.now(); break; case "conversation": root.load(e.id, e.turns, null); + // Opened from History on purpose: it counts as active now. + if (e.turns.length) root.lastActive = Date.now(); break; case "user": - turns.append(root.row({ user: e.text }, true)); + turns.append(root.row({ user: e.text, files: e.files }, true)); + root.lastActive = Date.now(); break; case "think": root.bufThink += e.delta; break; case "token": root.bufReply += e.delta; break; @@ -138,6 +199,7 @@ Scope { turns.setProperty(i, "stopped", e.stopped === true); } if (!e.stopped) root.convId = e.id; + root.lastActive = Date.now(); break; case "error": // Only an error marked turn ended the reply. A command error, @@ -175,7 +237,7 @@ Scope { id: link active: true Socket { - path: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/desktop-assistant.sock` + path: root.socketPath connected: true parser: SplitParser { onRead: line => root.handle(line) } } @@ -205,7 +267,7 @@ Scope { Card { anchors.centerIn: parent width: Math.min(760, parent.width - 48) - height: Math.round(parent.height * 0.7) + fullHeight: Math.round(parent.height * 0.7) canvas: root turnModel: turns } diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml index ab80d1b..477f430 100644 --- a/desktop/assistant/Card.qml +++ b/desktop/assistant/Card.qml @@ -11,14 +11,29 @@ import QtQuick import ".." +import "Files.js" as Files -// 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. +// The card in its three states (Assistant.qml, mode): entry is the input +// line alone; file adds the staged chips and what can be done with them; +// chat adds the header and the transcript. A panel (history, picker) takes +// the full height whatever the state. All state lives in the canvas, which +// outlives this card, so closing and reopening loses nothing. Rectangle { id: card property var canvas property var turnModel + property real fullHeight: 600 // the chat state's height, set by the window + + readonly property string mode: card.canvas.mode + readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen + readonly property bool tall: card.mode === "chat" || card.panel + + // Bottom-up: the input, then the actions (file state), then the chips. + height: card.tall ? card.fullHeight + : footer.height + 28 + (chips.visible ? chips.height + 10 : 0) + + (actions.visible ? actions.height + 10 : 0) + Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } + clip: true color: Qt.alpha(Theme.base, 0.82) radius: 14 @@ -26,7 +41,7 @@ Rectangle { 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. + // the ancestor of every text field, so their Escape bubbles here. Keys.onEscapePressed: card.canvas.back() // Clicks on the card must not reach the close-on-click catcher behind it. @@ -34,7 +49,7 @@ Rectangle { Component.onCompleted: input.forceActiveFocus() - // Focus falls out of the card when its holder goes: the history field + // Focus falls out of the card when its holder goes: a panel's field // hiding, or a streaming reply's text rebuilt under a click. With the // keyboard grabbed, nothing would then reach Escape. Give it back. Connections { @@ -47,12 +62,15 @@ Rectangle { Connections { target: card.canvas function onHistoryOpenChanged() { if (!card.canvas.historyOpen) input.forceActiveFocus(); } + function onPickerOpenChanged() { if (!card.canvas.pickerOpen) input.forceActiveFocus(); } + function onChattingChanged() { input.forceActiveFocus(); } } - // --- header --- + // --- header (chat) --- Item { id: header + visible: card.mode === "chat" anchors { top: parent.top; left: parent.left; right: parent.right; margins: 14 } height: 32 @@ -85,17 +103,19 @@ Rectangle { Button { text: "New" enabled: card.canvas.connected - onClicked: { card.canvas.send({ type: "new" }); card.canvas.historyOpen = false; input.forceActiveFocus(); } + onClicked: { card.canvas.fresh(); card.canvas.historyOpen = false; input.forceActiveFocus(); } } } } - // --- transcript --- + // --- transcript (chat) --- ListView { id: list + visible: card.mode === "chat" anchors { - top: header.bottom; topMargin: 8; bottom: footer.top; bottomMargin: 8 + top: header.bottom; topMargin: 8 + bottom: chips.visible ? chips.top : footer.top; bottomMargin: 8 left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 } clip: true @@ -125,6 +145,78 @@ Rectangle { } } + // --- staged files (file, chat) --- + + Flow { + id: chips + visible: card.canvas.staged.length > 0 + anchors { + bottom: actions.visible ? actions.top : footer.top; bottomMargin: 10 + left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 + } + spacing: 6 + + Repeater { + model: card.canvas.staged + Rectangle { + id: chip + required property var modelData + required property int index + readonly property bool ok: Files.chats(modelData.kind) + width: label.implicitWidth + cross.implicitWidth + 24 + height: label.implicitHeight + 10 + radius: height / 2 + color: Qt.alpha(Theme.surface, 0.9) + border.width: 1 + border.color: Qt.alpha(chip.ok ? Theme.text : Theme.red, 0.25) + + Text { + id: label + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: `${chip.modelData.kind} · ${chip.modelData.name}` + color: chip.ok ? Theme.text : Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + id: cross + anchors { right: parent.right; rightMargin: 9; verticalCenter: parent.verticalCenter } + text: "×" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + MouseArea { + anchors { fill: parent; margins: -4 } + cursorShape: Qt.PointingHandCursor + onClicked: card.canvas.unstage(chip.index) + } + } + } + } + } + + // --- what to do with them (file) --- + // Piece 1 offers Chat about it; the Ask and Do rows (model prompts and + // local operations) are their own specs and land here. + + Row { + id: actions + visible: card.mode === "file" + anchors { bottom: footer.top; bottomMargin: 10; left: parent.left; leftMargin: 14 } + spacing: 10 + + Button { + text: "Chat about it" + enabled: card.canvas.connected && !card.canvas.blocked + onClicked: card.canvas.chatting = true + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: card.canvas.blocked + text: "chat supports text, images and PDF" + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + // --- input --- Item { @@ -143,9 +235,24 @@ Rectangle { font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } + // The paperclip, nf-fa-paperclip, checked in Inconsolata Nerd Font. + Button { + id: attach + visible: card.mode !== "entry" + width: visible ? implicitWidth : 0 + anchors { bottom: parent.bottom; left: parent.left } + height: box.height + text: "" + font.family: Theme.iconFamily + onClicked: card.canvas.pickerOpen = !card.canvas.pickerOpen + } + Rectangle { id: box - anchors { bottom: parent.bottom; left: parent.left; right: send.left; rightMargin: 8 } + anchors { + bottom: parent.bottom; left: attach.right; leftMargin: attach.visible ? 8 : 0 + right: history.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 @@ -188,21 +295,35 @@ Rectangle { } Text { - anchors { left: parent.left; top: parent.top; margins: 8 } + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 8 } visible: input.text === "" - text: "Message the assistant" + elide: Text.ElideRight + text: card.mode === "entry" ? "How can I help you?" + : card.canvas.staged.length ? `Ask about ${card.canvas.staged[0].name}…` + : "Message the assistant" color: Theme.overlay font: input.font } } + // History from every state; in chat the header has it too. + Button { + id: history + visible: card.mode !== "chat" + width: visible ? implicitWidth : 0 + anchors { bottom: parent.bottom; right: send.left; rightMargin: visible ? 8 : 0 } + height: box.height + text: "History" + onClicked: card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory() + } + 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 + enabled: card.canvas.connected && (card.canvas.busy || !card.canvas.blocked) onClicked: { if (card.canvas.busy) card.canvas.send({ type: "stop" }); else if (card.canvas.ask(input.text)) input.text = ""; @@ -211,9 +332,24 @@ Rectangle { } } - History { - anchors.fill: list - visible: card.canvas.historyOpen - canvas: card.canvas + // --- panels, over everything above the input --- + + Item { + id: panelArea + anchors { + top: parent.top; topMargin: 14; bottom: chips.visible ? chips.top : footer.top + bottomMargin: 8; left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 + } + + History { + anchors.fill: parent + visible: card.canvas.historyOpen + canvas: card.canvas + } + Picker { + anchors.fill: parent + visible: card.canvas.pickerOpen + canvas: card.canvas + } } } diff --git a/desktop/assistant/Message.qml b/desktop/assistant/Message.qml index c724c2e..fcf77ec 100644 --- a/desktop/assistant/Message.qml +++ b/desktop/assistant/Message.qml @@ -22,6 +22,7 @@ Column { required property string reply required property string think required property string tools + required property string files // JSON: [{name, kind, pages, truncated}] required property string error required property bool live required property bool stopped @@ -43,6 +44,32 @@ Column { } } + // The files that went with the question, as the daemon read them. + Flow { + width: msg.width + spacing: 6 + visible: msg.files !== "[]" + Repeater { + model: JSON.parse(msg.files) + Rectangle { + required property var modelData + width: f.implicitWidth + 16 + height: f.implicitHeight + 6 + radius: height / 2 + color: Qt.alpha(Theme.surface, 0.6) + Text { + id: f + anchors.centerIn: parent + text: parent.modelData.name + + (parent.modelData.pages ? ` · ${parent.modelData.pages} pages` : "") + + (parent.modelData.truncated ? " · truncated" : "") + color: parent.modelData.truncated ? Theme.yellow : Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + } + } + } + // Collapsed by default; a click shows the reasoning as plain text. Text { visible: msg.think !== "" diff --git a/desktop/assistant/Picker.qml b/desktop/assistant/Picker.qml new file mode 100644 index 0000000..1465d1b --- /dev/null +++ b/desktop/assistant/Picker.qml @@ -0,0 +1,143 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import QtQuick +import QtQuick.Controls +import Qt.labs.folderlistmodel +import ".." + +// A file picker drawn in the card. A system dialog cannot be used: the card +// is an overlay layer surface holding the keyboard, so a dialog window would +// open beneath it, out of reach. Click a file to stage it and close, +// Ctrl+click to stage and stay; the filter narrows this directory. +Rectangle { + id: picker + property var canvas + + readonly property string home: Quickshell.env("HOME") || "/" + property string dir: home + + color: Qt.alpha(Theme.base, 0.97) + radius: 8 + + onVisibleChanged: if (visible) { + dir = canvas.pickDir || home; + field.text = ""; + field.forceActiveFocus(); + } + + MouseArea { anchors.fill: parent } // keep clicks off what is beneath + + function enter(path) { dir = path; field.text = ""; rows.currentIndex = 0; } + function up() { if (dir !== "/") enter(dir.slice(0, dir.lastIndexOf("/")) || "/"); } + function choose(i, keep) { + const isDir = files.get(i, "fileIsDir"), path = files.get(i, "filePath"); + if (isDir) { enter(path); return; } + canvas.pickDir = dir; + canvas.stage(path); + if (!keep) canvas.pickerOpen = false; + } + + FolderListModel { + id: files + folder: "file://" + picker.dir + showDirsFirst: true + showDotAndDotDot: false + // Hidden entries only on asking for them. + showHidden: field.text.startsWith(".") + caseSensitive: false + nameFilters: field.text ? ["*" + field.text + "*"] : ["*"] + sortCaseSensitive: false + } + + Row { + id: bar + anchors { top: parent.top; left: parent.left; right: parent.right } + spacing: 6 + Button { text: "Home"; onClicked: picker.enter(picker.home) } + Button { text: "Up"; enabled: picker.dir !== "/"; onClicked: picker.up() } + Text { + anchors.verticalCenter: parent.verticalCenter + width: bar.width - x + text: picker.dir.startsWith(picker.home) ? "~" + picker.dir.slice(picker.home.length) : picker.dir + elide: Text.ElideLeft + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + + TextField { + id: field + anchors { top: bar.bottom; topMargin: 8; left: parent.left; right: parent.right } + height: 32 + placeholderText: "Filter" + 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: rows.currentIndex = 0 + Keys.onUpPressed: rows.decrementCurrentIndex() + Keys.onDownPressed: rows.incrementCurrentIndex() + Keys.onReturnPressed: event => { if (rows.count) picker.choose(rows.currentIndex, event.modifiers & Qt.ControlModifier); } + Keys.onEnterPressed: event => { if (rows.count) picker.choose(rows.currentIndex, event.modifiers & Qt.ControlModifier); } + } + + ListView { + id: rows + anchors { top: field.bottom; topMargin: 8; left: parent.left; right: parent.right; bottom: parent.bottom } + clip: true + spacing: 2 + boundsBehavior: Flickable.StopAtBounds + model: files + highlightMoveDuration: 0 + + delegate: Rectangle { + id: r + required property int index + required property string fileName + required property bool fileIsDir + readonly property bool staged: picker.canvas.staged.some(f => f.path === picker.dir.replace(/\/$/, "") + "/" + r.fileName) + width: rows.width + height: name.implicitHeight + 10 + radius: 6 + color: rows.currentIndex === r.index ? Qt.alpha(Theme.accent, 0.18) + : hover.hovered ? Qt.alpha(Theme.surface, 0.9) : "transparent" + + HoverHandler { id: hover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: mouse => { rows.currentIndex = r.index; picker.choose(r.index, mouse.modifiers & Qt.ControlModifier); } + } + Text { + id: name + anchors { left: parent.left; leftMargin: 8; right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + text: (r.fileIsDir ? r.fileName + "/" : r.fileName) + (r.staged ? " ✓" : "") + elide: Text.ElideMiddle + color: r.fileIsDir ? Theme.accent : Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + + Text { + anchors.centerIn: parent + visible: rows.count === 0 + text: files.status === FolderListModel.Loading ? "Loading…" : "Nothing here" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + } +} diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md index 8fc200b..aabce2d 100644 --- a/desktop/assistant/README.md +++ b/desktop/assistant/README.md @@ -17,10 +17,20 @@ what is typed. The protocol is specified in that repo, | `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 | +| `Picker.qml` | the in-card file picker behind the paperclip | | `Md.js` | splits a message into prose and fenced code | +| `Files.js` | a path's kind, and a dropped URL to a path | -Keys: Enter sends, Shift+Enter is a newline, Escape closes the history -panel and then the card. Stop replaces Send while a reply streams. +Three states (`Assistant.qml`, `mode`): **entry**, one line, "How can I +help you?"; **file**, files staged and nothing asked yet, with what can be +done with them; **chat**, the transcript. SUPER+Home resumes a conversation +active in the last 10 minutes and otherwise starts a new one. A file +dropped on the island (`../island/`) starts a new one in the file state. +The paperclip opens a picker drawn in the card: a system dialog would open +beneath the overlay that holds the keyboard. + +Keys: Enter sends, Shift+Enter is a newline, Escape closes the picker, then +the history panel, then the card. Stop replaces Send while a reply streams. Notes: @@ -49,4 +59,11 @@ Notes: are stripped first, since a match inside a code span would show the markers literally. -Test the splitter: `node desktop/assistant/test_md.js`. +- **File kinds are decided twice.** `Files.js` decides what the card + offers; the daemon decides what chat accepts. The lists match, but the + daemon also takes any `text/*` file, which the card may show as "other". +- **`socketPath`** is a property so a check can point the canvas at a + scratch daemon; the desktop uses the default. + +Test the splitter and the kinds: `node desktop/assistant/test_md.js`, +`node desktop/assistant/test_files.js`. -- cgit v1.2.3