aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 16:41:51 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 16:41:51 +0200
commit607934e74ffabc1dcce72b29fc28e92a669e54ce (patch)
tree47ca9f67742c1892ff9197b5d4d31f6dd6534d36
parent9a2a0c93cc30af979a26d957fef603e2378676be (diff)
downloadquickshell-607934e74ffabc1dcce72b29fc28e92a669e54ce.tar.gz
quickshell-607934e74ffabc1dcce72b29fc28e92a669e54ce.zip
feat(assistant): the Do row of file operations
Conversions, extraction, merge and compression on the staged files from the file state, one at a time, with Stop; Compress… opens its formats. To .md goes through the daemon's write, the rest through fileops. The card keeps the result line, Saved name with Open and Folder, or the error, so another operation can follow on the same files. Results land in Documents/Assistant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--desktop/assistant/Assistant.qml84
-rw-r--r--desktop/assistant/Card.qml85
-rw-r--r--desktop/assistant/Ops.js53
-rw-r--r--desktop/assistant/README.md12
-rw-r--r--desktop/assistant/test_ops.js45
5 files changed, 273 insertions, 6 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index 0139ef9..b1329b1 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -41,6 +41,22 @@ Scope {
// 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"
+ // The Do row's operation: one at a time, either fileops (a Process) or
+ // the daemon's write (To .md). Its result stays on the card.
+ property string opLabel: ""
+ property bool opRunning: false
+ property bool opDaemon: false
+ property int opChars: 0
+ property var opOut: []
+ property string opError: ""
+ // Results folder: $(xdg-user-dir DOCUMENTS)/Assistant, read once.
+ property string outDir: (Quickshell.env("HOME") || "") + "/Documents/Assistant"
+ readonly property string fileops:
+ decodeURIComponent(Qt.resolvedUrl("fileops").toString().replace(/^file:\/\//, ""))
+ readonly property string mdPrompt: "Rewrite this document as Markdown: keep its wording; "
+ + "use headings, lists and tables where the original has them; transcribe scanned "
+ + "pages. Output only the Markdown."
+
// A staged kind chat does not take holds the question back.
readonly property bool blocked: root.staged.some(f => !Files.chats(f.kind))
@@ -102,6 +118,7 @@ Scope {
const files = paths.filter(p => p !== "").map(Files.file);
if (!files.length) return;
root.fresh();
+ root.clearOp();
root.staged = files;
root.historyOpen = root.pickerOpen = false;
root.open = true;
@@ -114,6 +131,64 @@ Scope {
root.refine();
}
+ function clearOp() {
+ if (root.opRunning) return;
+ root.opLabel = root.opError = "";
+ root.opOut = [];
+ root.opChars = 0;
+ }
+
+ function runOp(o) {
+ if (root.opRunning || !root.staged.length) return;
+ root.clearOp();
+ root.opLabel = o.label;
+ root.opRunning = true;
+ const paths = root.staged.map(f => f.path);
+ if (o.op === "md") {
+ if (!root.connected) { root.opRunning = false; root.opError = "assistant not running"; return; }
+ root.opDaemon = true;
+ const name = root.staged[0].name.replace(/\.[^.]*$/, "") + ".md";
+ root.send({ type: "write", files: paths, prompt: root.mdPrompt, dir: root.outDir, name: name });
+ } else {
+ root.opDaemon = false;
+ ops.command = [root.fileops, o.op, root.outDir].concat(paths);
+ ops.running = false;
+ ops.running = true;
+ }
+ }
+
+ function stopOp() {
+ if (!root.opRunning) return;
+ if (root.opDaemon) root.send({ type: "stop" });
+ else ops.running = false; // SIGTERM: fileops kills its tool, removes the partial
+ root.opRunning = false;
+ root.opError = "stopped";
+ }
+
+ Process {
+ id: ops
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (!root.opRunning || root.opDaemon) return;
+ root.opRunning = false;
+ try {
+ const r = JSON.parse(text.trim().split("\n").pop());
+ if (r.ok) root.opOut = r.out; else root.opError = r.error;
+ } catch (e) {
+ root.opError = "fileops gave no answer";
+ }
+ }
+ }
+ }
+
+ Process {
+ running: true
+ command: ["xdg-user-dir", "DOCUMENTS"]
+ stdout: StdioCollector {
+ onStreamFinished: if (text.trim().startsWith("/")) root.outDir = text.trim() + "/Assistant"
+ }
+ }
+
// A file whose name says nothing (a script with no suffix) is "other"
// until file(1) has read it. One run for all of them; the daemon makes
// its own call on the content when the question is sent.
@@ -244,7 +319,16 @@ Scope {
if (!e.stopped) root.convId = e.id;
root.lastActive = Date.now();
break;
+ case "progress": if (root.opDaemon) root.opChars = e.chars; break;
+ case "written":
+ if (root.opDaemon && root.opRunning) { root.opRunning = false; root.opOut = [e.path]; }
+ break;
case "error":
+ if (e.turn && root.opDaemon && root.opRunning) {
+ root.opRunning = false;
+ root.opError = e.message;
+ break;
+ }
// Only an error marked turn ended the reply. A command error,
// perhaps another client's, leaves a streaming reply alone.
if (e.turn && i >= 0 && turns.get(i).live) {
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index f63ce59..02ee476 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -9,10 +9,12 @@
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
+import Quickshell
import QtQuick
import ".."
import "Files.js" as Files
import "Presets.js" as Presets
+import "Ops.js" as Ops
// 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;
@@ -33,6 +35,8 @@ Rectangle {
height: card.tall ? card.fullHeight
: footer.height + 28 + (chips.visible ? chips.height + 10 : 0)
+ (asks.visible ? asks.height + 10 : 0)
+ + (dos.visible ? dos.height + 10 : 0)
+ + (status.visible ? status.height + 10 : 0)
+ (actions.visible ? actions.height + 10 : 0)
Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
clip: true
@@ -153,7 +157,8 @@ Rectangle {
id: chips
visible: card.canvas.staged.length > 0
anchors {
- bottom: asks.visible ? asks.top : actions.visible ? actions.top : footer.top
+ bottom: asks.visible ? asks.top : dos.visible ? dos.top : status.visible ? status.top
+ : actions.visible ? actions.top : footer.top
bottomMargin: 10
left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
}
@@ -198,15 +203,14 @@ Rectangle {
// --- what to do with them (file) ---
// Ask: canned questions matching the staged kinds (Presets.js). A click
- // sends one; Shift+click puts it in the input to edit first. The Do row
- // (local operations) is its own spec and lands here too.
+ // sends one; Shift+click puts it in the input to edit first.
Flow {
id: asks
readonly property var offered: Presets.offered(Presets.list(Prefs.presets), card.canvas.staged)
- visible: card.mode === "file" && offered.length > 0
+ visible: card.mode === "file" && offered.length > 0 && !card.canvas.opRunning
anchors {
- bottom: actions.top; bottomMargin: 10
+ bottom: dos.visible ? dos.top : status.visible ? status.top : actions.top; bottomMargin: 10
left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
}
spacing: 8
@@ -230,6 +234,77 @@ Rectangle {
}
}
+ // Do: local operations (Ops.js, run by fileops or the daemon's write).
+ // Compress… swaps the row for its formats. Hidden while one runs.
+ property bool compressing: false
+ Connections { target: card.canvas; function onStagedChanged() { card.compressing = false; } }
+
+ Flow {
+ id: dos
+ readonly property var offered: card.compressing
+ ? Ops.COMPRESS.concat([{ label: "Back", op: "back" }])
+ : Ops.offered(card.canvas.staged)
+ visible: card.mode === "file" && offered.length > 0 && !card.canvas.opRunning
+ anchors {
+ bottom: status.visible ? status.top : actions.top; bottomMargin: 10
+ left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
+ }
+ spacing: 8
+
+ Repeater {
+ model: dos.offered
+ Button {
+ required property var modelData
+ text: modelData.label
+ // Local operations need no daemon; To .md does.
+ enabled: modelData.op !== "md" || card.canvas.connected
+ onClicked: {
+ if (modelData.op === "compress") card.compressing = true;
+ else if (modelData.op === "back") card.compressing = false;
+ else { card.compressing = false; card.canvas.runOp(modelData); }
+ }
+ }
+ }
+ }
+
+ // What the operation is doing, or what it made.
+ Row {
+ id: status
+ readonly property var c: card.canvas
+ visible: card.mode === "file" && (c.opRunning || c.opError !== "" || c.opOut.length > 0)
+ anchors { bottom: actions.top; bottomMargin: 10; left: parent.left; leftMargin: 14 }
+ spacing: 10
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: status.c.opRunning
+ ? (status.c.opDaemon && status.c.opChars
+ ? `${status.c.opLabel}… ${(status.c.opChars / 1000).toFixed(1)}k characters`
+ : `Working on ${status.c.opLabel}…`)
+ : status.c.opError !== "" ? status.c.opError
+ : status.c.opOut.length === 1 ? `Saved ${status.c.opOut[0].slice(status.c.opOut[0].lastIndexOf("/") + 1)}`
+ : `Saved ${status.c.opOut.length} files`
+ color: status.c.opError !== "" && !status.c.opRunning ? Theme.red : Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Button {
+ visible: status.c.opRunning
+ text: "Stop"
+ danger: true
+ onClicked: status.c.stopOp()
+ }
+ Button {
+ visible: !status.c.opRunning && status.c.opOut.length === 1
+ text: "Open"
+ onClicked: Quickshell.execDetached(["xdg-open", status.c.opOut[0]])
+ }
+ Button {
+ visible: !status.c.opRunning && status.c.opOut.length > 0
+ text: "Folder"
+ onClicked: Quickshell.execDetached(["xdg-open", status.c.outDir])
+ }
+ }
+
Row {
id: actions
visible: card.mode === "file"
diff --git a/desktop/assistant/Ops.js b/desktop/assistant/Ops.js
new file mode 100644
index 0000000..e59560d
--- /dev/null
+++ b/desktop/assistant/Ops.js
@@ -0,0 +1,53 @@
+// 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
+
+// The file state's Do row. op is a fileops operation, or "md" for the
+// daemon's write, or "compress", which opens the COMPRESS formats. kinds
+// lists the staged kinds it fits ("any" fits every mix); min is the number
+// of files it needs; format is the suffix a file already has when the
+// operation would not change it.
+const OPS = [
+ { label: "To .md", op: "md", kinds: ["pdf"] },
+ { label: "To images", op: "pdf-images", kinds: ["pdf"] },
+ { label: "Merge", op: "pdf-merge", kinds: ["pdf"], min: 2 },
+ { label: "Extract audio", op: "audio", kinds: ["video"] },
+ { label: "To MP4", op: "mp4", kinds: ["video"], format: [".mp4"] },
+ { label: "To WebM", op: "webm", kinds: ["video"], format: [".webm"] },
+ { label: "To GIF", op: "gif", kinds: ["video"] },
+ { label: "Trackcrop", op: "trackcrop", kinds: ["video"] },
+ { label: "To PNG", op: "png", kinds: ["image"], format: [".png"] },
+ { label: "To JPEG", op: "jpeg", kinds: ["image"], format: [".jpg", ".jpeg"] },
+ { label: "To WebP", op: "webp", kinds: ["image"], format: [".webp"] },
+ { label: "Resize 50%", op: "half", kinds: ["image"] },
+ { label: "Extract", op: "extract", kinds: ["archive"] },
+ { label: "Compress…", op: "compress", kinds: ["any"] },
+];
+
+const COMPRESS = [
+ { label: ".zip", op: "zip" },
+ { label: ".tar.gz", op: "tgz" },
+ { label: ".tar.xz", op: "txz" },
+ { label: ".tar.bz2", op: "tbz2" },
+];
+
+function ext(name) {
+ const n = name.toLowerCase(), dot = n.lastIndexOf(".");
+ return dot > 0 ? n.slice(dot) : "";
+}
+
+function offered(files) {
+ if (!files.length) return [];
+ return OPS.filter(o => (o.kinds.includes("any") || files.every(f => o.kinds.includes(f.kind)))
+ && files.length >= (o.min ?? 1)
+ && !(o.format && files.every(f => o.format.includes(ext(f.name)))));
+}
diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md
index 8c74343..41c01b3 100644
--- a/desktop/assistant/README.md
+++ b/desktop/assistant/README.md
@@ -21,6 +21,8 @@ what is typed. The protocol is specified in that repo,
| `Md.js` | splits a message into prose and fenced code |
| `Files.js` | a path's kind, and a dropped URL to a path |
| `Presets.js` | the Ask row's canned questions and which kinds they fit |
+| `Ops.js` | the Do row's operations and which kinds they fit |
+| `fileops` | runs a Do operation: `fileops <op> <outdir> <file...>`, one JSON line back |
Three states (`Assistant.qml`, `mode`): **entry**, one line, "How can I
help you?"; **file**, files staged and nothing asked yet, with what can be
@@ -74,5 +76,13 @@ Notes:
`Prefs.setPresets`. Each prompt asks for an answer in the document's
language, since the model answers in the language it is asked in.
+- **Do operations** write to `$(xdg-user-dir DOCUMENTS)/Assistant`, under
+ the first free name, through a temporary file renamed on success; Stop
+ sends SIGTERM and fileops kills its tool's process group and removes the
+ partial result. To .md goes through the daemon's `write` instead. This
+ ffmpeg has no libx264 and no AAC encoder: To MP4 is `h264_vaapi` with MP3
+ audio, and Extract audio copies AAC, MP3 and Opus tracks as they are.
+
Tests: `node desktop/assistant/test_md.js`, `test_files.js`,
-`test_presets.js` (same directory).
+`test_presets.js`, `test_ops.js`, and `/usr/bin/python3
+desktop/assistant/test_fileops.py` (same directory).
diff --git a/desktop/assistant/test_ops.js b/desktop/assistant/test_ops.js
new file mode 100644
index 0000000..6d53376
--- /dev/null
+++ b/desktop/assistant/test_ops.js
@@ -0,0 +1,45 @@
+// 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_ops.js
+// Ops.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 + "/Ops.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const O = new Function(src + "\nreturn { offered, COMPRESS };")();
+
+const f = (name, kind) => ({ path: "/x/" + name, name, kind });
+const labels = files => O.offered(files).map(o => o.label);
+
+assert.deepStrictEqual(labels([f("a.pdf", "pdf")]), ["To .md", "To images", "Compress…"]);
+assert.deepStrictEqual(labels([f("a.pdf", "pdf"), f("b.pdf", "pdf")]),
+ ["To .md", "To images", "Merge", "Compress…"]);
+assert.deepStrictEqual(labels([f("a.pdf", "pdf"), f("p.png", "image")]), ["Compress…"]);
+// An image's own format is not offered.
+assert.deepStrictEqual(labels([f("p.png", "image")]), ["To JPEG", "To WebP", "Resize 50%", "Compress…"]);
+assert.deepStrictEqual(labels([f("p.JPG", "image")]), ["To PNG", "To WebP", "Resize 50%", "Compress…"]);
+// Mixed formats: offered while any file would change.
+assert.deepStrictEqual(labels([f("p.png", "image"), f("q.jpg", "image")]),
+ ["To PNG", "To JPEG", "To WebP", "Resize 50%", "Compress…"]);
+assert.deepStrictEqual(labels([f("c.mp4", "video")]),
+ ["Extract audio", "To WebM", "To GIF", "Trackcrop", "Compress…"]);
+assert.deepStrictEqual(labels([f("c.mkv", "video")]),
+ ["Extract audio", "To MP4", "To WebM", "To GIF", "Trackcrop", "Compress…"]);
+assert.deepStrictEqual(labels([f("b.tar.gz", "archive")]), ["Extract", "Compress…"]);
+assert.deepStrictEqual(labels([f("n.txt", "text")]), ["Compress…"]);
+assert.deepStrictEqual(labels([]), []);
+assert.deepStrictEqual(O.COMPRESS.map(c => c.op), ["zip", "tgz", "txz", "tbz2"]);
+
+console.log("ok");