aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/assistant
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 16:06:33 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 16:06:33 +0200
commitf6ceae477979b32869c0c17e2b1fc05477d6eb81 (patch)
tree4fecf207f449e7a454d4ee80e28407ddfa63df2d /desktop/assistant
parent9a0c898a7a3d6c134626f05508c63840477e4be3 (diff)
downloadquickshell-f6ceae477979b32869c0c17e2b1fc05477d6eb81.tar.gz
quickshell-f6ceae477979b32869c0c17e2b1fc05477d6eb81.zip
feat(assistant): Ask presets in the file state
A row of canned questions matching every staged file's kind, plus Compare for two or more files: one click asks it, Shift+click puts it in the input to edit first. Defaults live in Presets.js and the list in use in Prefs, so a future drawer settings page can edit it; a saved list that does not validate reads as the defaults. Each prompt asks for an answer in the document's language. Code and configuration get their own kind, so a script is offered Explain and Review rather than Summarize. Button's clicked signal carries the mouse event for the Shift check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/assistant')
-rw-r--r--desktop/assistant/Card.qml39
-rw-r--r--desktop/assistant/Files.js10
-rw-r--r--desktop/assistant/Presets.js46
-rw-r--r--desktop/assistant/README.md11
-rw-r--r--desktop/assistant/test_files.js5
-rw-r--r--desktop/assistant/test_presets.js46
6 files changed, 147 insertions, 10 deletions
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index 228cfdb..f63ce59 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -12,6 +12,7 @@
import QtQuick
import ".."
import "Files.js" as Files
+import "Presets.js" as Presets
// 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;
@@ -31,6 +32,7 @@ Rectangle {
// 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)
+ + (asks.visible ? asks.height + 10 : 0)
+ (actions.visible ? actions.height + 10 : 0)
Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
clip: true
@@ -151,7 +153,8 @@ Rectangle {
id: chips
visible: card.canvas.staged.length > 0
anchors {
- bottom: actions.visible ? actions.top : footer.top; bottomMargin: 10
+ bottom: asks.visible ? asks.top : actions.visible ? actions.top : footer.top
+ bottomMargin: 10
left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
}
spacing: 6
@@ -194,8 +197,38 @@ Rectangle {
}
// --- 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.
+ // 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.
+
+ Flow {
+ id: asks
+ readonly property var offered: Presets.offered(Presets.list(Prefs.presets), card.canvas.staged)
+ visible: card.mode === "file" && offered.length > 0
+ anchors {
+ bottom: actions.top; bottomMargin: 10
+ left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
+ }
+ spacing: 8
+
+ Repeater {
+ model: asks.offered
+ Button {
+ required property var modelData
+ text: modelData.label
+ enabled: card.canvas.connected && !card.canvas.blocked
+ onClicked: mouse => {
+ if (mouse && (mouse.modifiers & Qt.ShiftModifier)) {
+ input.text = Presets.prompt(modelData);
+ input.cursorPosition = input.text.length;
+ input.forceActiveFocus();
+ } else {
+ card.canvas.ask(Presets.prompt(modelData));
+ }
+ }
+ }
+ }
+ }
Row {
id: actions
diff --git a/desktop/assistant/Files.js b/desktop/assistant/Files.js
index bcfe1f0..825aed1 100644
--- a/desktop/assistant/Files.js
+++ b/desktop/assistant/Files.js
@@ -17,17 +17,20 @@
// ponytail: the daemon also takes any text/* mime type; a file of such a
// type with a suffix not listed here reads as "other" and is not offered
// chat. Add the suffix when one shows up.
-const TEXT = [".py", ".c", ".h", ".cpp", ".hpp", ".rs", ".go", ".js", ".ts", ".jsx",
+// Text splits into prose and data (text) and code or configuration (code),
+// so each gets the presets that fit it; the daemon sees one text kind.
+const TEXT = [".md", ".rst", ".txt", ".csv"];
+const CODE = [".py", ".c", ".h", ".cpp", ".hpp", ".rs", ".go", ".js", ".ts", ".jsx",
".tsx", ".sh", ".bash", ".zsh", ".lua", ".rb", ".pl", ".php", ".java",
".kt", ".swift", ".sql", ".toml", ".yaml", ".yml", ".json", ".ini",
- ".cfg", ".conf", ".md", ".rst", ".txt", ".csv", ".xml", ".html", ".css",
+ ".cfg", ".conf", ".xml", ".html", ".css",
".diff", ".patch", ".slackbuild", ".info", ".desktop", ".service", ".qml"];
const IMAGE = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
const VIDEO = [".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v"];
const ARCHIVE = [".zip", ".tar", ".gz", ".tgz", ".xz", ".txz", ".bz2", ".zst", ".7z", ".rar"];
// Kinds chat takes; the rest only get local actions.
-const CHAT = ["text", "image", "pdf"];
+const CHAT = ["text", "code", "image", "pdf"];
function name(path) { return path.slice(path.lastIndexOf("/") + 1); }
@@ -40,6 +43,7 @@ function kind(path) {
if (VIDEO.includes(ext)) return "video";
if (ARCHIVE.includes(ext)) return "archive";
if (TEXT.includes(ext)) return "text";
+ if (CODE.includes(ext)) return "code";
return "other";
}
diff --git a/desktop/assistant/Presets.js b/desktop/assistant/Presets.js
new file mode 100644
index 0000000..116d7d0
--- /dev/null
+++ b/desktop/assistant/Presets.js
@@ -0,0 +1,46 @@
+// 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
+
+// Canned questions for the file state's Ask row. kinds lists the staged
+// kinds a preset applies to (Files.js); ["many"] offers it whenever two or
+// more files are staged. The list in use is Prefs.presets when one is
+// saved (a settings page will edit it), these otherwise.
+const DEFAULTS = [
+ { label: "Summarize", kinds: ["pdf", "text"], prompt: "Summarize this in a few short paragraphs." },
+ { label: "Key points", kinds: ["pdf"], prompt: "List the key points as bullets." },
+ { label: "Explain simply", kinds: ["pdf", "text"], prompt: "Explain this simply, as to a non-expert." },
+ { label: "Describe", kinds: ["image"], prompt: "Describe this image." },
+ { label: "Extract text", kinds: ["image", "pdf"], prompt: "Transcribe all the text, keeping the layout where it helps." },
+ { label: "What is this?", kinds: ["image"], prompt: "What is this, and what is it for?" },
+ { label: "Explain", kinds: ["code"], prompt: "Explain what this code does." },
+ { label: "Review", kinds: ["code"], prompt: "Review this code: bugs, risks, and simplifications." },
+ { label: "Compare", kinds: ["many"], prompt: "Compare these files: what they share and how they differ." },
+];
+
+// The saved list if it is one, else the defaults: a hand edit with a typo
+// must not leave the row empty.
+function list(saved) {
+ const ok = Array.isArray(saved) && saved.every(p => p && typeof p.label === "string"
+ && typeof p.prompt === "string" && Array.isArray(p.kinds));
+ return ok ? saved : DEFAULTS;
+}
+
+function offered(presets, files) {
+ if (!files.length) return [];
+ return presets.filter(p => p.kinds.includes("many") ? files.length > 1
+ : files.every(f => p.kinds.includes(f.kind)));
+}
+
+// The model answers in the language it is asked in; a preset is asked in
+// English, so it says which language to use.
+function prompt(p) { return p.prompt + " Answer in the language of the document."; }
diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md
index aabce2d..4715b12 100644
--- a/desktop/assistant/README.md
+++ b/desktop/assistant/README.md
@@ -20,6 +20,7 @@ what is typed. The protocol is specified in that repo,
| `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 |
+| `Presets.js` | the Ask row's canned questions and which kinds they fit |
Three states (`Assistant.qml`, `mode`): **entry**, one line, "How can I
help you?"; **file**, files staged and nothing asked yet, with what can be
@@ -65,5 +66,11 @@ Notes:
- **`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`.
+- **Ask presets** live in `Prefs.qml` (`assistant.presets`) once saved and
+ are `Presets.js`'s defaults until then; an invalid saved list reads as the
+ defaults. There is no editor yet: a drawer settings page will call
+ `Prefs.setPresets`. Each prompt asks for an answer in the document's
+ language, since the model answers in the language it is asked in.
+
+Tests: `node desktop/assistant/test_md.js`, `test_files.js`,
+`test_presets.js` (same directory).
diff --git a/desktop/assistant/test_files.js b/desktop/assistant/test_files.js
index 5ece64f..80a855d 100644
--- a/desktop/assistant/test_files.js
+++ b/desktop/assistant/test_files.js
@@ -23,12 +23,13 @@ const F = new Function(src + "\nreturn { kind, chats, fromUrl, file, name };")()
const cases = {
"/a/report.pdf": "pdf", "/a/Report.PDF": "pdf", "/a/x.png": "image", "/a/x.JPEG": "image",
"/a/clip.mp4": "video", "/a/c.MKV": "video", "/a/src.tar.gz": "archive", "/a/b.7z": "archive",
- "/a/main.py": "text", "/a/notes.md": "text", "/a/pkg.SlackBuild": "text",
+ "/a/main.py": "code", "/a/notes.md": "text", "/a/pkg.SlackBuild": "code",
+ "/a/data.csv": "text", "/a/README.rst": "text", "/a/a.txt": "text", "/a/c.json": "code",
"/a/Makefile": "other", "/a/.bashrc": "other", "/a/x.bin": "other", "/a/dir.d/noext": "other",
};
for (const [p, k] of Object.entries(cases)) assert.strictEqual(F.kind(p), k, p);
-assert.ok(F.chats("pdf") && F.chats("image") && F.chats("text"));
+assert.ok(F.chats("pdf") && F.chats("image") && F.chats("text") && F.chats("code"));
assert.ok(!F.chats("video") && !F.chats("archive") && !F.chats("other"));
assert.strictEqual(F.fromUrl("file:///home/you/My%20Docs/caf%C3%A9.pdf"), "/home/you/My Docs/café.pdf");
diff --git a/desktop/assistant/test_presets.js b/desktop/assistant/test_presets.js
new file mode 100644
index 0000000..e4a18f4
--- /dev/null
+++ b/desktop/assistant/test_presets.js
@@ -0,0 +1,46 @@
+// 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_presets.js
+// Presets.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 + "/Presets.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const P = new Function(src + "\nreturn { DEFAULTS, list, offered, prompt };")();
+
+const files = (...kinds) => kinds.map((kind, i) => ({ path: `/f${i}`, name: `f${i}`, kind }));
+const labels = staged => P.offered(P.DEFAULTS, staged).map(p => p.label);
+
+assert.deepStrictEqual(labels(files("pdf")), ["Summarize", "Key points", "Explain simply", "Extract text"]);
+assert.deepStrictEqual(labels(files("image", "pdf")), ["Extract text", "Compare"]);
+assert.deepStrictEqual(labels(files("code", "code")), ["Explain", "Review", "Compare"]);
+assert.deepStrictEqual(labels(files("code")), ["Explain", "Review"]);
+assert.deepStrictEqual(labels(files("text")), ["Summarize", "Explain simply"]);
+assert.deepStrictEqual(labels(files("image")), ["Describe", "Extract text", "What is this?"]);
+assert.deepStrictEqual(labels(files("video")), []);
+assert.deepStrictEqual(labels([]), []);
+
+// Every prompt asks for the document's language.
+for (const p of P.DEFAULTS)
+ assert.ok(P.prompt(p).endsWith(" Answer in the language of the document."), p.label);
+
+// A saved list is used when valid; anything else reads as the defaults.
+const mine = [{ label: "Mine", prompt: "Do it.", kinds: ["pdf"] }];
+assert.strictEqual(P.list(mine), mine);
+for (const bad of [null, undefined, {}, "x", [{ label: "No kinds", prompt: "p" }],
+ [{ label: 3, prompt: "p", kinds: [] }], [null]])
+ assert.strictEqual(P.list(bad), P.DEFAULTS, JSON.stringify(bad));
+
+console.log("ok");