diff options
Diffstat (limited to 'docs/superpowers')
6 files changed, 2359 insertions, 6 deletions
diff --git a/docs/superpowers/plans/2026-10-23-assistant-cloud-models.md b/docs/superpowers/plans/2026-10-23-assistant-cloud-models.md new file mode 100644 index 0000000..6d9628a --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-assistant-cloud-models.md @@ -0,0 +1,909 @@ +# Assistant cloud models (quickshell side) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Configure cloud providers in the settings window's Assistant page, and pick the model for each chat from the assistant canvas. + +**Architecture:** The daemon (desktop-assistant, already done) owns providers, keys and routing. This side only edits `[[cloud]]` tables in its `config.toml` (`Config.js`, line based, as for the other keys), runs `assistant.py --cloud-models <name>` to fetch a provider's ids, and sends `model` with each `ask`. The canvas keeps the chat's model from the daemon's `hello.chat_model` and `conversation.model`, and lists models from its `models` event in a panel like History. + +**Tech Stack:** Quickshell 0.3.1 QML, `.pragma library` JS tested with node. Check: `node desktop/modules/assistant/test_config.js` (prints nothing, exits 0). QML is checked live by the user (a detached `qs` does not survive an agent's tool call, AGENTS.md). + +Spec: desktop-assistant `docs/superpowers/specs/2026-10-23-cloud-models-design.md`, sections "Settings page" and "Canvas header". Daemon protocol now in place: `ask` takes `model`; `hello` has `chat_model`; `conversation` has `model`; `{"type": "models"}` answers `{"type": "models", "items": [{id, status, cloud: false} | {id: "provider:model", provider, cloud: true}]}`; `assistant.py --cloud-models NAME` prints a JSON array of ids, or exits non-zero with the reason on stderr. + +--- + +## File map + +- Modify `desktop/modules/assistant/Config.js`: `clouds`, `setClouds`, `cloudError`. +- Modify `desktop/modules/assistant/test_config.js`: their checks. +- Modify `desktop/modules/assistant/AssistantModule.qml`: cloud state, edits, fetch, `save(done)`, `ensureModel` replacing `ensurePreferred`. +- Modify `desktop/modules/assistant/AssistantPage.qml`: Cloud section, cloud rows with Prefer, Model text. +- Modify `desktop/assistant/Assistant.qml`: `chatModel`, `preferred`, `wanted`, `cloud`, `models`, `modelsOpen`, `pickModel`, `model` on every ask. +- Create `desktop/assistant/Models.qml`: the model panel. +- Modify `desktop/assistant/Card.qml`: header label opens the panel, a Model button outside chat, Ctrl+M, panel slot. +- Modify `desktop/shell.qml`: `preferred` and `ensureModel` wiring. +- Modify `desktop/modules/assistant/README.md`, `desktop/assistant/README.md`. + +Every new source file carries the repo's GPLv2 header (copy it from `desktop/assistant/History.qml`). No home paths in committed files. No em dashes. Commits GPG-signed (automatic), never `--no-verify`; never stage HANDOFF.md; trailer `Co-Authored-By: <the model you are> <noreply@anthropic.com>`. + +--- + +### Task 1: `[[cloud]]` tables in Config.js + +**Files:** +- Modify: `desktop/modules/assistant/Config.js` (append) +- Test: `desktop/modules/assistant/test_config.js` + +- [ ] **Step 1: Write the failing tests** + +In `test_config.js`, the line building `C` exports the new functions: + +```js +const C = new Function(src + "\nreturn { scalars, setScalar, env, setEnv, quote, clouds, setClouds, cloudError };")(); +``` + +Append at the end of the file: + +```js +// [[cloud]] tables. +const withCloud = file + [ + "# Cloud models.", + "[[cloud]]", + 'name = "hetzner"', + 'url = "https://llm.example.org/v1" # a comment', + 'key_cmd = "pass llm/x"', + 'models = ["a/b", "c:d"]', + "", + "[[cloud]]", + 'name = "two"', + 'url = "u"', + 'key_cmd = "k"', + "models = []", + "", +].join("\n"); +assert.deepStrictEqual(C.clouds(withCloud), [ + { name: "hetzner", url: "https://llm.example.org/v1", key_cmd: "pass llm/x", models: ["a/b", "c:d"] }, + { name: "two", url: "u", key_cmd: "k", models: [] }]); +assert.deepStrictEqual(C.clouds(file), []); +assert.deepStrictEqual(C.env(withCloud), C.env(file)); // [env] ends at the table +// A table this cannot read in full is not offered for editing. +assert.strictEqual(C.clouds(withCloud.replace('models = ["a/b", "c:d"]', 'models = [\n "a/b",\n]')), null); +assert.strictEqual(C.clouds(withCloud.replace('name = "two"', 'name = "two"\nextra = 1')), null); +// Rewritten at the end of the file; everything else stays. +const one = C.setClouds(withCloud, [{ name: "n", url: "u", key_cmd: 'pass "q"', models: ["m"] }]); +assert.deepStrictEqual(C.clouds(one), [{ name: "n", url: "u", key_cmd: 'pass "q"', models: ["m"] }]); +assert.deepStrictEqual(C.env(one), C.env(file)); +assert.deepStrictEqual(C.scalars(one, KEYS), C.scalars(file, KEYS)); +assert.ok(one.includes("# Cloud models.") && one.endsWith('models = ["m"]\n'), one); +assert.deepStrictEqual(C.clouds(C.setClouds(one, [])), []); +// What the daemon refuses to start with. +const ok = { name: "a", url: "u", key_cmd: "k", models: [] }; +assert.strictEqual(C.cloudError([ok]), ""); +assert.strictEqual(C.cloudError([]), ""); +for (const bad of [{ name: "" }, { name: "a:b" }, { url: "" }, { key_cmd: " " }]) + assert.notStrictEqual(C.cloudError([Object.assign({}, ok, bad)]), "", JSON.stringify(bad)); +assert.notStrictEqual(C.cloudError([ok, ok]), ""); +``` + +- [ ] **Step 2: Run, expect failure** + +`node desktop/modules/assistant/test_config.js`. Expected: `ReferenceError: clouds is not defined`. + +- [ ] **Step 3: Implement** + +Append to `Config.js`: + +```js +// [[cloud]] tables, the daemon's providers: name, url and key_cmd strings +// and a one-line models array, as setClouds writes them. +const CLOUD = /^\s*\[\[cloud\]\]\s*(#.*)?$/; + +// A one-line array of strings, ["a", "b"]; null for anything else. +function strings(rest) { + let r = rest.trim(); + if (!r.startsWith("[")) return null; + const out = []; + r = r.slice(1).trim(); + while (!r.startsWith("]")) { + if (!r.startsWith('"')) return null; + const v = value(r); + out.push(v.v); + r = r.slice(v.end).trim(); + if (r.startsWith(",")) r = r.slice(1).trim(); + else if (!r.startsWith("]")) return null; + } + const tail = r.slice(1).trim(); + return tail === "" || tail.startsWith("#") ? out : null; +} + +// [{start, end}] of each [[cloud]] table, its header to the next header. +function cloudBlocks(lines) { + const out = []; + lines.forEach((l, i) => { + if (!CLOUD.test(l)) return; + const e = lines.findIndex((x, j) => j > i && HEADER.test(x)); + out.push({ start: i, end: e < 0 ? lines.length : e }); + }); + return out; +} + +// [{name, url, key_cmd, models}] in file order, or null when a table holds +// a line this cannot read (a multi-line array, another key): the page then +// leaves them to be edited by hand rather than rewrite what it did not read. +function clouds(text) { + const lines = text.split("\n"); + const out = []; + for (const b of cloudBlocks(lines)) { + const c = { name: "", url: "", key_cmd: "", models: [] }; + for (let i = b.start + 1; i < b.end; i++) { + if (/^\s*(#.*)?$/.test(lines[i])) continue; + const m = KEY.exec(lines[i]); + if (!m) return null; + if (m[3] === "models") { + const a = strings(m[4]); + if (a === null) return null; + c.models = a; + } else if (["name", "url", "key_cmd"].includes(m[3]) && m[4].startsWith('"')) { + c[m[3]] = value(m[4]).v; + } else return null; + } + out.push(c); + } + return out; +} + +// Replaces every [[cloud]] table with list's, at the end of the file. The +// lines inside the old tables go with them; comments above them stay. +function setClouds(text, list) { + const lines = text.split("\n"); + for (const b of cloudBlocks(lines).reverse()) lines.splice(b.start, b.end - b.start); + while (lines.length && lines[lines.length - 1].trim() === "") lines.pop(); + for (const c of list) + lines.push("", "[[cloud]]", "name = " + quote(c.name), "url = " + quote(c.url), + "key_cmd = " + quote(c.key_cmd), + "models = [" + c.models.map(quote).join(", ") + "]"); + return lines.join("\n") + "\n"; +} + +// Why list cannot be saved, "" when it can: the daemon refuses to start on +// any of these (assistant.py, check_cloud). +function cloudError(list) { + for (const c of list) { + if (!c.name.trim() || !c.url.trim() || !c.key_cmd.trim()) + return "A cloud provider needs a name, a URL and a key command."; + if (c.name.includes(":")) return `Cloud name ${c.name} has a colon.`; + } + const names = list.map(c => c.name); + const dup = names.find((n, i) => names.indexOf(n) !== i); + return dup !== undefined ? `Cloud name ${dup} is used twice.` : ""; +} +``` + +Also extend the file's header comment (the paragraph starting `// The file is edited line by line`): after `and \`KEY = "value"\` lines in [env].` add the sentence `[[cloud]] tables are read when they hold only what setClouds writes, and rewritten whole.` + +- [ ] **Step 4: Run, expect success** + +`node desktop/modules/assistant/test_config.js`: no output, exit 0 (`echo $?` prints 0). + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/assistant/Config.js desktop/modules/assistant/test_config.js +git commit -m "assistant settings: read and write the daemon's [[cloud]] tables" +``` + +(with the trailer, see File map) + +--- + +### Task 2: AssistantModule, cloud state and fetch + +**Files:** +- Modify: `desktop/modules/assistant/AssistantModule.qml` + +No node test: QML. Verified in Task 6. + +- [ ] **Step 1: Keywords** + +`keywords:` gains `"cloud", "provider"`. + +- [ ] **Step 2: Cloud state** + +After the `function envValue(key) ...` line add: + +```qml + // The [[cloud]] tables in the draft, null when one cannot be read (the + // page then leaves them alone), and the saved ones, which the canvas and + // Prefer offer. cloudCount follows the draft's count only, so rows are + // not rebuilt on each keystroke (as envKeys). + readonly property var clouds: C.clouds(am.draft) + readonly property var savedClouds: C.clouds(am.text) ?? [] + property int cloudCount: 0 + readonly property string cloudError: am.clouds ? C.cloudError(am.clouds) : "" + // name -> the provider's ids, or why they could not be fetched. + property var fetched: ({}) + property string fetching: "" +``` + +In the existing `onDraftChanged` handler, add after its two lines: + +```qml + const n = (C.clouds(am.draft) ?? []).length; + if (n !== am.cloudCount) am.cloudCount = n; +``` + +`valid` becomes: + +```qml + readonly property bool valid: Object.keys(am.invalid).length === 0 && am.cloudError === "" +``` + +- [ ] **Step 3: Edits** + +After `function quote(s) { return C.quote(s); }` add: + +```qml + // patch is {name}, {url}, {key_cmd} or {models}; i === cloudCount adds one. + function editCloud(i, patch) { + const list = (am.clouds ?? []).map(c => Object.assign({}, c)); + if (i === list.length) list.push({ name: "", url: "", key_cmd: "", models: [] }); + Object.assign(list[i], patch); + am.draft = C.setClouds(am.draft, list); + } + function removeCloud(i) { am.draft = C.setClouds(am.draft, am.clouds.filter((c, j) => j !== i)); } + + // The daemon reads the saved config, so Fetch saves the draft first. + // The key command runs in that process; the key never reaches here. + function fetchModels(name) { + am.save(() => { + am.fetching = name; + am.run([am.python, am.daemonDir + "/assistant.py", "--cloud-models", name], null, + (code, out, err) => { + am.fetching = ""; + let got; + try { got = code === 0 ? JSON.parse(out) : (err.trim().split("\n").pop() || `exit ${code}`); } + catch (e) { got = "the assistant sent an unreadable list"; } + am.fetched = Object.assign({}, am.fetched, { [name]: got }); + }); + }); + } +``` + +- [ ] **Step 4: `save(done)`** + +`save()` becomes `save(done)`, calling `done` once both writes are through; `pending` is set only when something was written: + +```qml + function save(done) { + if (am.text !== am.base) { + am.error = "config.toml changed on disk since these edits began. Discard to load it, then redo them."; + return; + } + am.error = ""; + const t = am.draft; + const changed = t !== am.text || Object.keys(am.toolsWanted).length > 0; + const tools = () => am.applyTools(Object.keys(am.toolsWanted), () => { + if (changed) am.pending = true; + am.listTools(); + if (done) done(); + }); + if (t === am.text) { tools(); return; } + am.run([am.python, "-c", am.writer, am.cfgPath], t, (code, out, err) => { + if (code !== 0) { am.error = err.trim() || "config.toml could not be written"; return; } + am.text = t; + am.base = t; + tools(); + }); + } +``` + +The SaveBar's `onSave: am.save()` stays as it is. + +- [ ] **Step 5: `ensureModel` replaces `ensurePreferred`** + +Replace the whole `ensurePreferred` function and its comment with: + +```qml + // Called when the canvas opens and when its chat's model changes, with + // the chat's model, else the preferred one: loaded if it is a router + // model neither loaded nor loading. A cloud id is not in the router's + // list, so nothing is swapped for it. One model and one client at a time + // is how this desktop runs, so this is the moment to bring the model + // back. The list is read every time, so hears is current. + function ensureModel(id) { + if (am.loading !== "") return; + am.listModels(() => { + const m = am.models.find(x => x.id === id); + if (m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id); + }); + } +``` + +Then `grep -rn ensurePreferred desktop/` must list only `desktop/shell.qml` (fixed in Task 5). + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/assistant/AssistantModule.qml +git commit -m "assistant settings: cloud providers, fetch their models, load the chat's model" +``` + +--- + +### Task 3: AssistantPage, Cloud section and cloud Prefer + +**Files:** +- Modify: `desktop/modules/assistant/AssistantPage.qml` + +- [ ] **Step 1: Model text** + +The first `Small` under `Heading { text: "Model" }` gets: + +```qml + text: "Each chat uses the model picked in the canvas header, else the preferred one. " + + "Opening the canvas loads that model if it is a local one and another is in. " + + "Load swaps a model in now, for every client, without changing the preference." +``` + +- [ ] **Step 2: Cloud models in the Model list** + +Right after the router models' `Repeater { model: page.asst.models ... }` (before the `modelError` Small), add: + +```qml + // The saved cloud models: Prefer only, there is nothing to load. + Repeater { + model: page.asst.savedClouds.flatMap(c => c.models.map(m => c.name + ":" + m)) + + delegate: Rectangle { + id: cloudRow + required property string modelData + readonly property bool preferred: Prefs.assistantModel === modelData + objectName: "model:" + modelData + width: page.width + height: 40 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + // nf-fa-cloud, present in Inconsolata Nerd Font. + Text { + id: cloudIcon + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + id: cloudName + anchors { left: cloudIcon.right; leftMargin: 8; verticalCenter: parent.verticalCenter } + text: cloudRow.modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + Small { + anchors { left: cloudName.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + visible: cloudRow.preferred + text: "preferred" + color: Theme.accent + } + Button { + anchors { right: parent.right; rightMargin: 6; verticalCenter: parent.verticalCenter } + text: cloudRow.preferred ? "Preferred" : "Prefer" + enabled: !cloudRow.preferred + onClicked: Prefs.setAssistantModel(cloudRow.modelData) + } + } + } +``` + +- [ ] **Step 3: Cloud section** + +Insert right before the `Heading { text: "Tool environment" }` line: + +```qml + // --- cloud --- + + Heading { text: "Cloud" } + + Small { + width: page.width + wrapMode: Text.Wrap + text: "Providers with an OpenAI-compatible API, offered in the canvas's model menu. A cloud " + + "chat is sent its history, attachments and the results of the tools marked " + + "cloud = true; voice is still transcribed here. The key command prints the API key: " + + "the assistant runs it at startup and on Fetch, which saves this page first." + } + + Small { + width: page.width + visible: page.asst.clouds === null + wrapMode: Text.Wrap + text: "config.toml has a [[cloud]] table this page cannot read; edit it by hand." + color: Theme.red + } + + Repeater { + model: page.asst.clouds === null ? 0 : page.asst.cloudCount + + delegate: Rectangle { + id: prov + required property int index + readonly property var c: page.asst.clouds?.[index] ?? { name: "", url: "", key_cmd: "", models: [] } + readonly property var got: page.asst.fetched[prov.c.name] + objectName: "cloud:" + index + width: page.width + height: provCol.implicitHeight + 20 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Column { + id: provCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 6 + + Labelled { + width: provCol.width + label: "Name" + Field { + anchors.fill: parent + value: prov.c.name + placeholder: "openrouter" + onEdited: t => page.asst.editCloud(prov.index, { name: t.trim() }) + } + } + Labelled { + width: provCol.width + label: "URL" + Field { + anchors.fill: parent + value: prov.c.url + placeholder: "https://openrouter.ai/api/v1" + onEdited: t => page.asst.editCloud(prov.index, { url: t.trim() }) + } + } + Labelled { + width: provCol.width + label: "Key command" + Field { + anchors.fill: parent + value: prov.c.key_cmd + placeholder: "pass show llm/openrouter" + onEdited: t => page.asst.editCloud(prov.index, { key_cmd: t.trim() }) + } + } + Row { + spacing: 6 + Button { + text: page.asst.fetching === prov.c.name ? "Fetching…" : "Fetch models" + enabled: page.asst.fetching === "" && page.asst.valid && prov.c.name !== "" + onClicked: page.asst.fetchModels(prov.c.name) + } + Button { + text: "Remove" + danger: true + onClicked: page.asst.removeCloud(prov.index) + } + } + Small { + width: provCol.width + visible: typeof prov.got === "string" + wrapMode: Text.Wrap + text: typeof prov.got === "string" ? prov.got : "" + color: Theme.red + } + // The fetched ids and any kept from before, each switched + // into models or out of it. + Repeater { + model: Array.isArray(prov.got) + ? [...new Set(prov.got.concat(prov.c.models))].sort() : prov.c.models + + delegate: Item { + id: idRow + required property string modelData + width: provCol.width + height: 28 + Switch { + id: idOn + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + checked: prov.c.models.includes(idRow.modelData) + onToggled: on => page.asst.editCloud(prov.index, { models: on + ? prov.c.models.concat([idRow.modelData]) + : prov.c.models.filter(m => m !== idRow.modelData) }) + } + Text { + anchors { left: idOn.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: idRow.modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + } + } + } + } + + Small { + width: page.width + visible: page.asst.cloudError !== "" + wrapMode: Text.Wrap + text: page.asst.cloudError + color: Theme.red + } + + Button { + text: "Add provider" + enabled: page.asst.clouds !== null + onClicked: page.asst.editCloud(page.asst.cloudCount, {}) + } +``` + +- [ ] **Step 4: Commit** + +```bash +git add desktop/modules/assistant/AssistantPage.qml +git commit -m "assistant settings: a Cloud section, and Prefer for cloud models" +``` + +--- + +### Task 4: Canvas, the chat's model + +**Files:** +- Modify: `desktop/assistant/Assistant.qml` +- Create: `desktop/assistant/Models.qml` +- Modify: `desktop/assistant/Card.qml` + +- [ ] **Step 1: State in Assistant.qml** + +After `property string swapping: ""` add: + +```qml + // The model this chat is asked with: its stored pick (hello.chat_model, + // conversation.model), "" for none, then the preferred one (set from + // shell.qml). "" for both: the daemon uses whatever the router has + // loaded. Spec: desktop-assistant, + // docs/superpowers/specs/2026-10-23-cloud-models-design.md. + property string chatModel: "" + property string preferred: "" + readonly property string wanted: root.chatModel || root.preferred + // The daemon's models event: [{id, status, cloud: false} | {id, provider, cloud: true}]. + property var models: [] + readonly property bool cloud: root.models.some(m => m.cloud && m.id === root.wanted) + property bool modelsOpen: false +``` + +- [ ] **Step 2: Functions** + +`close()` resets the panel too: + +```qml + root.open = false; root.historyOpen = false; root.pickerOpen = false; root.modelsOpen = false; +``` + +`back()`, after the recording branch: + +```qml + else if (root.modelsOpen) root.modelsOpen = false; +``` + +`showHistory()` gains `root.modelsOpen = false;` next to `root.pickerOpen = false;`. + +After `showHistory()` add: + +```qml + function showModels() { + root.historyOpen = root.pickerOpen = false; + root.modelsOpen = true; + root.send({ type: "models" }); + } + // Applies from the next ask; the daemon stores it with the chat then. + // Moving a chat with turns to the cloud sends its history there, so + // that is said once, on the switch. + function pickModel(id) { + const m = root.models.find(x => x.id === id); + if (m && m.cloud && !root.cloud && turns.count) + root.notice = `History in this chat goes to ${m.provider}.`; + root.chatModel = id; + root.modelsOpen = false; + } + function withModel(cmd) { + if (root.wanted) cmd.model = root.wanted; + return cmd; + } +``` + +Both asks carry the model. In `ask(text)`: + +```qml + root.send(root.withModel({ type: "ask", text: text, files: root.staged.map(f => f.path) })); +``` + +In the recorder's `onExited`: + +```qml + root.send(root.withModel({ type: "ask", text: "", audio: root.micPath, + files: root.staged.map(f => f.path) })); +``` + +The `write` send (To .md) stays without a model: the daemon always writes with the local one. + +- [ ] **Step 3: Events** + +In `handle()`: +- `case "hello":` after `root.model = e.model ?? "";` add `root.chatModel = e.chat_model ?? "";` and, at its end before `break`, `root.send({ type: "models" });` (so the cloud marker is right before the panel was ever opened). +- `case "conversation":` after `root.load(e.id, e.turns, null);` add `root.chatModel = e.model ?? "";` +- New case, next to `"results"`: `case "models": root.models = e.items; break;` + +- [ ] **Step 4: `Models.qml`** + +Create `desktop/assistant/Models.qml` (GPLv2 header first, copied from `History.qml`): + +```qml +import QtQuick +import ".." + +// The model panel, over the transcript: the router's models, then the +// cloud ones, in the daemon's order. A pick applies to this chat from its +// next question. +Rectangle { + id: panel + property var canvas + + color: Qt.alpha(Theme.base, 0.97) + radius: 8 + + MouseArea { anchors.fill: parent } // keep clicks off the transcript below + + ListView { + id: rows + anchors.fill: parent + clip: true + spacing: 2 + model: panel.canvas.models + + delegate: Rectangle { + id: r + required property var modelData + readonly property bool picked: r.modelData.id === panel.canvas.wanted + width: rows.width + height: 34 + radius: 6 + color: hover.hovered ? Qt.alpha(Theme.surface, 0.9) : "transparent" + border.width: r.picked ? 1 : 0 + border.color: Qt.alpha(Theme.accent, 0.5) + + HoverHandler { id: hover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: panel.canvas.pickModel(r.modelData.id) + } + // nf-fa-cloud, present in Inconsolata Nerd Font. + Text { + id: icon + anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter } + width: 16 + text: r.modelData.cloud ? "" : "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + anchors { left: icon.right; leftMargin: 6; right: stat.left; rightMargin: 8 + verticalCenter: parent.verticalCenter } + // A cloud id without its provider: the right column says it. + text: r.modelData.cloud ? r.modelData.id.slice(r.modelData.provider.length + 1) + : r.modelData.id + elide: Text.ElideRight + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: r.picked } + } + Text { + id: stat + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + text: r.modelData.cloud ? r.modelData.provider : r.modelData.status + color: r.modelData.status === "loaded" ? Theme.green : Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + + Text { + anchors.centerIn: parent + visible: rows.count === 0 + text: "No models: is llama-server running?" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + } +} +``` + +- [ ] **Step 5: Card.qml** + +`panel` includes the new one: + +```qml + readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen || card.canvas.modelsOpen +``` + +Ctrl+M, in `Keys.onPressed`, after the `Qt.Key_H` branch: + +```qml + } else if (event.key === Qt.Key_M && card.canvas.connected) { + card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels(); +``` + +and the comment above it reads `// Ctrl+N, Ctrl+H and Ctrl+M do what New, History and the model menu do.` + +Header: replace the single header `Text { ... }` (the one showing "assistant not running" / "loading …" / the model) with: + +```qml + // The chat's model, a click opens the model panel. nf-fa-cloud for a + // cloud one, nf-fa-angle_down after it. + Row { + id: modelRow + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + spacing: 6 + Text { + anchors.verticalCenter: parent.verticalCenter + visible: card.canvas.connected && !card.canvas.swapping && card.canvas.cloud + text: "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: !card.canvas.connected ? "assistant not running" + : card.canvas.swapping ? `loading ${card.canvas.swapping}…` + : card.canvas.wanted || card.canvas.model || "assistant" + color: card.canvas.connected ? Theme.text : Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: card.canvas.connected + text: "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + } + MouseArea { + anchors.fill: modelRow + enabled: card.canvas.connected + cursorShape: Qt.PointingHandCursor + onClicked: card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels() + } +``` + +Outside chat, a Model button left of History. Add before `Button { id: history ...`: + +```qml + // The model menu outside chat, where there is no header; nf-fa-cloud + // when the next question goes to a cloud model. + Button { + id: modelBtn + visible: card.mode !== "chat" + width: visible ? implicitWidth : 0 + anchors { bottom: parent.bottom; right: history.left; rightMargin: visible ? 8 : 0 } + height: box.height + icon: card.canvas.cloud ? "" : "" + text: "Model" + enabled: card.canvas.connected + onClicked: card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels() + } +``` + +and the input `box`'s anchors change `right: history.left` to `right: modelBtn.left`. + +In `panelArea`, after `Picker { ... }`: + +```qml + Models { + anchors.fill: parent + visible: card.canvas.modelsOpen + canvas: card.canvas + } +``` + +- [ ] **Step 6: Commit** + +```bash +git add desktop/assistant/Assistant.qml desktop/assistant/Models.qml desktop/assistant/Card.qml +git commit -m "assistant: pick the chat's model, local or cloud, from the header" +``` + +--- + +### Task 5: Wiring and docs + +**Files:** +- Modify: `desktop/shell.qml`, `desktop/modules/assistant/README.md`, `desktop/assistant/README.md` + +- [ ] **Step 1: shell.qml** + +```qml + Assistant { id: assistant; swapping: asstSettings.loading; hears: asstSettings.hears + preferred: Prefs.assistantModel } +``` + +and the Connections block becomes: + +```qml + // Opening the assistant, or picking another model for its chat, loads + // that model if it is a local one and another is in VRAM. See + // modules/assistant/README.md. + Connections { + target: assistant + function onOpenChanged() { if (assistant.open) asstSettings.ensureModel(assistant.wanted); } + function onWantedChanged() { if (assistant.open) asstSettings.ensureModel(assistant.wanted); } + } +``` + +`grep -rn ensurePreferred desktop/` prints nothing. + +- [ ] **Step 2: `desktop/modules/assistant/README.md`** + +Replace the paragraph starting `Opening the assistant canvas (SUPER+Home) loads the preferred model` with: + +``` +Each chat uses the model picked in the canvas's model menu, else the +preferred one, else whatever is loaded. Opening the canvas, or picking a +local model for its chat, loads that model when another one is resident, +and the canvas header reads "loading <model>…" until it is in. This +desktop runs one model and one client at a time, so that is when the +model should come back. A cloud model loads nothing. +``` + +After the paragraph ending `...which the daemon's \`*.toml\` glob skips.`, add: + +``` +**Cloud** edits the daemon's `[[cloud]]` tables: name, URL and key +command per provider. Fetch saves the page, then runs `assistant.py +--cloud-models <name>`, which runs the key command and lists the +provider's ids; the switches choose which the canvas offers. The key +never reaches this page. A table holding more than the page writes (a +multi-line array, another key) is left alone, with a note to edit it by +hand. The saved cloud models also get Prefer in the Model list. +``` + +- [ ] **Step 3: `desktop/assistant/README.md`** + +Add a `Models.qml` row to the file table: + +``` +| `Models.qml` | the model panel: local and cloud models, the chat's pick | +``` + +After the mic paragraph, add: + +``` +The header's model name (the Model button outside chat, or Ctrl+M) opens +the model panel: the router's models, then each cloud provider's. A pick +applies to this chat from its next question and is stored with it by the +daemon; a new chat starts on the preferred model. Moving a chat with turns +to a cloud model says once that its history goes to that provider. Voice is +transcribed by the local model in a cloud chat too. +``` + +and in the Keys line, `Ctrl+H toggles the history panel.` becomes `Ctrl+H toggles the history panel, Ctrl+M the model panel.` + +- [ ] **Step 4: Check and commit** + +`node desktop/modules/assistant/test_config.js` exits 0. Then: + +```bash +git add desktop/shell.qml desktop/modules/assistant/README.md desktop/assistant/README.md +git commit -m "assistant: load the chat's model on open, docs for cloud models" +``` + +--- + +### Task 6: Live check (the user) + +The agent cannot run `qs` itself (AGENTS.md, "A detached qs does not survive"). Ask the user to run `./reload.sh desktop` and check, then fix what they report: + +- [ ] Settings, Assistant page: the hetzner card shows name, URL, key command, and its two models switched on. Fetch lists the four ids (pass may ask for the PIN). Switching one off and Save rewrites the `[[cloud]]` table at the end of `config.toml`; Restart applies it. Add provider with an empty name holds Save back with the cloud error. +- [ ] Model list: the cloud models have Prefer; preferring one marks it. +- [ ] Canvas, entry state: Model button opens the panel; local models with their state, then the hetzner ones with the cloud icon; the preferred model is outlined. +- [ ] Pick `Qwen3.8-27B`, ask about the weather: the header shows the cloud icon and the id; the answer uses `weather`. +- [ ] Pick Gemma in that chat: the notice does not show; next ask answers locally (ctx meter shows `/ 32768` again). Pick the cloud one back: "History in this chat goes to hetzner." shows. +- [ ] History: reopening the chat shows its stored model in the header. +- [ ] The header's model name opens the panel on click, and Ctrl+M toggles it. diff --git a/docs/superpowers/plans/2026-10-23-companion-blush.md b/docs/superpowers/plans/2026-10-23-companion-blush.md new file mode 100644 index 0000000..4ae1842 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-companion-blush.md @@ -0,0 +1,655 @@ +# Companion Blush Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The companion turns to a listed wallpaper (`watch` frame, 1.5 s) and blushes (`blush3`, 3 s) when the workspace in front of her is empty, at most once every 3 minutes. + +**Architecture:** `Mood.js` gains an `admire` state stamped by `admireAt`. A new singleton `desktop/companion/Admired.qml` owns the plain-text wallpaper list (pure helpers in `ListFile.js`) and what wallp has on each monitor. `Companion.qml` checks the trigger on workspace events and whenever she stops being away. The Companion settings page takes the list path (`Prefs.companionBlushFile`), and the wallpaper picker draws a heart toggle on each thumbnail. + +**Tech Stack:** Quickshell 0.3.1 QML, QML JS libraries tested under node, Hyprland raw events. + +Spec: `docs/superpowers/specs/2026-10-23-companion-blush-design.md`. + +Read AGENTS.md "Verifying, and how not to" and "Reloading" before running anything: never start `qs` from a tool call, `pkill -x qs` only, and a new component file needs `./reload.sh desktop` from the user's terminal. + +--- + +## File map + +| File | Change | +| --- | --- | +| `desktop/companion/ListFile.js` | create: `parse`, `toggle`, `outputs`, pure | +| `desktop/companion/Mood.js` | `T.admireLook`, `T.admireBlush`, `T.admireCooldown`, the `admire` row, `admireAllowed` | +| `desktop/companion/test_mood.js` | tests for both | +| `desktop/Prefs.qml` | `companionBlushFile` + setter | +| `desktop/companion/Admired.qml` | create: the singleton | +| `desktop/companion/Companion.qml` | trigger, `admireAt` in the snapshot, the line | +| `desktop/companion/Lines.js` | `wallpaper` lines | +| `desktop/modules/companion/CompanionModule.qml` | the path field | +| `desktop/modules/appearance/WallpaperTab.qml` | the heart | +| `desktop/companion/README.md`, spec | docs | + +--- + +### Task 1: ListFile.js + +**Files:** +- Create: `desktop/companion/ListFile.js` +- Test: `desktop/companion/test_mood.js` + +- [ ] **Step 1: Write the failing test** + +In `test_mood.js`, after the `const M = ...` block, add: + +```js +const lsrc = fs.readFileSync(__dirname + "/ListFile.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const L = new Function(lsrc + "\nreturn { parse, toggle, outputs };")(); +``` + +Before `console.log("ok");`, add: + +```js +// The wallpaper list: paths, blank lines and # comments skipped. +assert.deepStrictEqual(L.parse(""), []); +assert.deepStrictEqual(L.parse("# mine\n/a.png\n\n /b.png \n#/c.png\n"), ["/a.png", "/b.png"]); +// Toggling adds a missing path and removes a listed one, leaving +// comments and every other line as they were. +assert.strictEqual(L.toggle("", "/a.png"), "/a.png\n"); +assert.strictEqual(L.toggle("# mine\n/b.png", "/a.png"), "# mine\n/b.png\n/a.png\n"); +assert.strictEqual(L.toggle("# mine\n/a.png\n/b.png\n", "/a.png"), "# mine\n/b.png\n"); +assert.strictEqual(L.toggle("/a.png\n", "/a.png"), ""); +// A commented-out path is not listed, so toggling it adds a live line. +assert.strictEqual(L.toggle("#/a.png\n", "/a.png"), "#/a.png\n/a.png\n"); +// wallp.conf's outputs, by connector. +assert.deepStrictEqual(L.outputs("# x\nOUTPUT_H=DP-1\nOUTPUT_V=DP-3\nDEFAULT_H=~/h.png\n"), + { "DP-1": "H", "DP-3": "V" }); +assert.deepStrictEqual(L.outputs(""), {}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node desktop/companion/test_mood.js` +Expected: FAIL, `ENOENT: no such file or directory` for `ListFile.js`. + +- [ ] **Step 3: Write ListFile.js** + +```js +// 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 wallpapers the companion blushes at, as a plain text file: one +// absolute path per line, blank lines and lines starting with # skipped. +// Pure, so test_mood.js drives it; Admired.qml reads and writes the file. + +function parse(text) { + const out = []; + for (const l of text.split("\n")) { + const p = l.trim(); + if (p !== "" && p[0] !== "#") out.push(p); + } + return out; +} + +// text with path's line removed if it is listed, else appended. Comments +// and every other line stay as they are. +function toggle(text, path) { + const lines = text === "" ? [] : text.replace(/\n$/, "").split("\n"); + const kept = lines.filter(l => l.trim() !== path); + if (kept.length === lines.length) kept.push(path); + return kept.length ? kept.join("\n") + "\n" : ""; +} + +// wallp.conf's OUTPUT_H and OUTPUT_V, as { connector: "H" | "V" }. An exec +// loop, because QML's engine has no String.matchAll. +function outputs(conf) { + const out = {}; + const re = /^\s*OUTPUT_([HV])\s*=\s*(\S+)/gm; + let m; + while ((m = re.exec(conf)) !== null) out[m[2]] = m[1]; + return out; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node desktop/companion/test_mood.js` +Expected: `ok` + +- [ ] **Step 5: Commit** + +```bash +git add desktop/companion/ListFile.js desktop/companion/test_mood.js +git commit -m "companion: ListFile.js, the wallpaper list as plain text + +Toggling keeps comments and every other line, so a hand-edited list +survives the picker's heart. wallp.conf's outputs map a monitor to the +H or V wallpaper. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 2: the admire state in Mood.js + +**Files:** +- Modify: `desktop/companion/Mood.js` (`T`, `pick`, a new function) +- Test: `desktop/companion/test_mood.js` + +- [ ] **Step 1: Write the failing test** + +In `test_mood.js`: + +Add `admireAllowed` to the `M` export list: +```js + "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue, admireAllowed };")(); +``` + +Add `admireAt: 0,` to `calm`, after `wakeAt: 0,`. + +In `ladder`, between the `clicks` and `mock` rows: +```js + ["admire", { admireAt: NOW - 10 }], +``` + +Before the `ListFile` tests, add: +```js +// Admiring a wallpaper: turned to it, then blushing, then done. +assert.deepStrictEqual(M.pick(calm({ admireAt: NOW - 10 })), { state: "admire", frame: "watch" }); +assert.strictEqual(frame({ admireAt: NOW - M.T.admireLook }), "blush3"); +assert.strictEqual(state({ admireAt: NOW - M.T.admireLook - M.T.admireBlush }), "base"); +assert.strictEqual(M.admireAllowed(0, NOW), true); +assert.strictEqual(M.admireAllowed(NOW - M.T.admireCooldown + 1, NOW), false); +assert.strictEqual(M.admireAllowed(NOW - M.T.admireCooldown, NOW), true); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node desktop/companion/test_mood.js` +Expected: FAIL, `ReferenceError: admireAllowed is not defined`. + +- [ ] **Step 3: Implement** + +In `Mood.js` `T`, after `scorched: 3000,`: +```js + admireLook: 1500, // turned to the wallpaper, before the blush + admireBlush: 3000, + admireCooldown: 180000, +``` + +In `pick`, right after the `clicks` return: +```js + if (s.now - s.admireAt < T.admireLook + T.admireBlush) + return st("admire", s.now - s.admireAt < T.admireLook ? "watch" : "blush3"); +``` + +After `mockAllowed`: +```js +function admireAllowed(lastAt, now) { return now - lastAt >= T.admireCooldown; } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node desktop/companion/test_mood.js` +Expected: `ok` + +- [ ] **Step 5: Commit** + +```bash +git add desktop/companion/Mood.js desktop/companion/test_mood.js +git commit -m "companion: admire, turned to a wallpaper and then blushing + +The watch back view for 1.5 s, then blush3 for 3 s, at most every 3 +minutes. Below clicks, so a click during it wins; above the mock and +typing. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 3: Prefs and the Admired singleton + +**Files:** +- Modify: `desktop/Prefs.qml` +- Create: `desktop/companion/Admired.qml` + +- [ ] **Step 1: Prefs.companionBlushFile** + +In `Prefs.qml`, after `setCompanion`: +```qml + // The companion's wallpaper list, "" for the default in + // companion/Admired.qml. + property string companionBlushFile: "" + function setCompanionBlushFile(p) { + p = p.trim(); + if (p === root.companionBlushFile) return; + root.companionBlushFile = p; + root.write(Object.assign({}, root.doc, { companionBlushFile: p })); + } +``` + +In `reparse()`, after `root.companion = ...`: +```qml + root.companionBlushFile = typeof parsed.companionBlushFile === "string" ? parsed.companionBlushFile : ""; +``` + +In `onLoadFailed`, add `root.companionBlushFile = "";` before `root.ready = true;`. + +- [ ] **Step 2: Admired.qml** + +```qml +// 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 Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import ".." +import "ListFile.js" as LF + +// The wallpapers the companion blushes at, and what wallp has on each +// monitor. The companion reads both; the wallpaper picker toggles entries. +// What is on each monitor is read from wallp's own files here rather than +// through the appearance module's Wallpapers, which scans every wallpaper +// on creation, and the companion runs all session. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string defaultPath: + (Quickshell.env("XDG_CONFIG_HOME") || root.home + "/.config") + "/keystone/companion-blush.txt" + readonly property string path: + Prefs.companionBlushFile !== "" ? Prefs.companionBlushFile.replace(/^~(?=\/|$)/, root.home) : root.defaultPath + + property string listText: "" + readonly property var paths: LF.parse(root.listText) + function listed(p) { return p !== "" && root.paths.indexOf(p) >= 0; } + + // The wallpaper on a monitor, by connector name; "" for one wallp does + // not set. + property var outputs: ({}) + property string wallH: "" + property string wallV: "" + function on(monitor) { + const w = root.outputs[monitor]; + return w === "H" ? root.wallH : w === "V" ? root.wallV : ""; + } + + // ponytail: a list file created by hand after start may not be watched + // until a restart (the watch is on a path that did not exist); the + // heart writes through this process, which sets listText itself. + FileView { + path: root.path + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.listText = text() + onLoadFailed: root.listText = "" + } + FileView { + path: root.home + "/.config/wallp/wallp.conf" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.outputs = LF.outputs(text()) + } + FileView { + path: root.home + "/.config/wallp/wall_h" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.wallH = text().trim() + } + FileView { + path: root.home + "/.config/wallp/wall_v" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.wallV = text().trim() + } + + // Through cat rather than FileView.setText, because the list can become + // empty and setText("") on a path the view has not loaded writes no + // file (AGENTS.md). + function toggle(p) { + writer.data = LF.toggle(root.listText, p); + root.listText = writer.data; + writer.command = ["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "sh", root.path]; + writer.stdinEnabled = true; + writer.running = false; + writer.running = true; + } + Process { + id: writer + property string data: "" + onStarted: { + writer.write(writer.data); + writer.stdinEnabled = false; + } + onExited: code => { if (code !== 0) console.warn("Admired: writing", root.path, "failed"); } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add desktop/Prefs.qml desktop/companion/Admired.qml +git commit -m "companion: Admired, the wallpaper list and what each monitor shows + +The list is a plain text file whose path is a preference, the default +under ~/.config/keystone. Written through cat because an emptied list +is an empty file, which FileView.setText does not write. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 4: the trigger in Companion.qml and her lines + +**Files:** +- Modify: `desktop/companion/Companion.qml` +- Modify: `desktop/companion/Lines.js` + +- [ ] **Step 1: Lines** + +In `Lines.js`, after `blush:`: +```js + wallpaper: ["...!", "W-what is this?", "I wasn't staring."], +``` + +- [ ] **Step 2: Input and snapshot** + +In `Companion.qml`, after `property double wakeAt: 0`: +```qml + property double admireAt: 0 +``` + +In the `Mood.pick({...})` snapshot, change +`clicks: root.clicks, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt,` to +`clicks: root.clicks, admireAt: root.admireAt, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt,`. + +- [ ] **Step 3: Events** + +Replace the last branch of `onRawEvent`: +```qml + } else if (["fullscreen", "activewindowv2", "workspacev2"].includes(event.name)) { + Hyprland.refreshToplevels(); + fsSettle.restart(); + } +``` +with: +```qml + } else if (["fullscreen", "activewindowv2", "workspacev2", "closewindow"].includes(event.name)) { + Hyprland.refreshToplevels(); + fsSettle.restart(); + admireSettle.restart(); + } +``` + +- [ ] **Step 4: The check** + +After the `Connections { target: root.asst ... }` block: +```qml + // A listed wallpaper on an empty workspace: she turns to it and blushes. + // Checked after the events above (switching to an empty workspace, + // closing its last window) and whenever she stops being away: shown, + // back from a remap, a hop, a dodge or fullscreen. Toplevels settle a + // moment after the event, as fullscreen does. + onAwayChanged: if (!root.away) admireSettle.restart() + Timer { + id: admireSettle + interval: 100 + onTriggered: { + const t = Date.now(); + if (root.away || root.here !== root.focused || !Mood.admireAllowed(root.admireAt, t)) return; + if ((Hyprland.focusedWorkspace?.toplevels.values.length ?? 1) !== 0) return; + if (!Admired.listed(Admired.on(root.here))) return; + root.admireAt = t; + root.now = t; + admireLine.restart(); + } + } + // The line comes with the blush, not the turn. + Timer { id: admireLine; interval: Mood.T.admireLook; onTriggered: root.say("wallpaper") } +``` + +- [ ] **Step 5: Ask the user to restart, then check the log** + +`Admired.qml` is a new file, which hot reload does not index. Ask the user to run `./reload.sh desktop` in their terminal. Then: + +Run: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -30` +Expected: `Configuration Loaded`, no `ReferenceError`, no `TypeError` mentioning `toplevels` or `Admired`. + +If `toplevels` is undefined on `HyprlandWorkspace`, replace the window count with +`Hyprland.toplevels.values.filter(w => w.workspace === Hyprland.focusedWorkspace).length` and re-check. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/companion/Companion.qml desktop/companion/Lines.js +git commit -m "companion: blushes at a listed wallpaper on an empty workspace + +Checked on switching workspace or closing a window, and whenever she +stops being away, which covers being shown, a remap, a hop to another +monitor, a dodge and fullscreen. The 3 minute cooldown keeps workspace +flicking from repeating it. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 5: the path on the Companion settings page + +**Files:** +- Modify: `desktop/modules/companion/CompanionModule.qml` + +- [ ] **Step 1: Imports and comment** + +Add after `import "../.."`: +```qml +import "../appearance" +import "../../companion" +``` +Change the comment `// The desktop companion's one setting: whether she is shown. The same` / `// preference SUPER+\ toggles, so the switch follows the key.` to: +```qml +// The desktop companion's settings: whether she is shown, the same +// preference SUPER+\ toggles, so the switch follows the key; and where her +// wallpaper list is, marked with the heart in the wallpaper picker. +``` +Add `"wallpaper", "blush"` to `keywords`. + +- [ ] **Step 2: Rows** + +`Page`'s content holder is a plain `Item`, so wrap the existing `Item { ... }` row in +```qml + Column { + width: parent.width + spacing: 6 + + // (the existing Shown row, unchanged) +``` +and after that row, inside the `Column`, add: +```qml + Item { + width: parent.width + height: 34 + Text { + id: lbl2 + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + text: "Wallpaper list" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { + anchors { left: lbl2.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter } + value: Prefs.companionBlushFile + placeholder: Admired.defaultPath.replace(Admired.home, "~") + onEdited: Prefs.setCompanionBlushFile(value) + } + } + Text { + width: parent.width + wrapMode: Text.WordWrap + text: "One path per line. She blushes at these on an empty workspace; the heart in Wallpaper adds and removes them." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + } +``` + +- [ ] **Step 3: Check the log** + +Run: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -15` +Expected: a `Reloading configuration` line after the edit, no errors. If none followed, ask for `./reload.sh desktop`. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/modules/companion/CompanionModule.qml +git commit -m "companion: the wallpaper list path on her settings page + +Empty means the default under ~/.config/keystone, shown as the +placeholder. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 6: the heart in the wallpaper picker + +**Files:** +- Modify: `desktop/modules/appearance/WallpaperTab.qml` + +- [ ] **Step 1: Glyphs** + +Run: `fc-list ':charset=f02d1 f02d5' family | grep -c "Inconsolata Nerd Font"` +Expected: non-zero (`nf-md-heart` U+F02D1, `nf-md-heart_outline` U+F02D5; checked while planning: Propo and Mono both carry them). + +- [ ] **Step 2: Import** + +After `import "../.."`: +```qml +import "../../companion" +``` + +- [ ] **Step 3: The heart** + +In the thumbnail `Rectangle`, after its `Column { ... }` (the image and label), add: +```qml + // The companion blushes at the hearted ones + // (companion/Admired.qml). After the pick + // MouseArea, so it takes the click. + Text { + readonly property bool on: Admired.listed(modelData) + anchors { top: parent.top; right: parent.right; margins: 10 } + visible: on || thumbHover.hovered + text: on ? "\u{F02D1}" : "\u{F02D5}" + color: on ? Theme.red : Theme.text + font { family: Theme.iconFamily; pixelSize: Theme.fontSize } + MouseArea { + anchors { fill: parent; margins: -4 } + cursorShape: Qt.PointingHandCursor + onClicked: Admired.toggle(modelData) + } + } +``` + +- [ ] **Step 4: Check the log** + +Run: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -15` +Expected: a reload after the edit and no errors. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/appearance/WallpaperTab.qml +git commit -m "wallpaper: a heart on each thumbnail, for the companion's list + +Shown on hover, and always on a listed one. It toggles the path in the +list file and leaves the rest of the file as it was. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 7: docs and the hand check + +**Files:** +- Modify: `desktop/companion/README.md` +- Modify: `docs/superpowers/specs/2026-10-23-companion-blush-design.md` + +- [ ] **Step 1: README** + +Add rows to the Files table: +``` +| `Admired.qml` | the wallpaper list she blushes at, and what wallp has on each monitor | +| `ListFile.js` | that list as plain text: parse and toggle | +``` +Under Inputs, add: +``` +- A listed wallpaper (Settings > Desktop > Companion for the file, the + heart in the wallpaper picker for entries) on an empty workspace: she + turns to it with `watch` and blushes with `blush3`, at most every 3 + minutes. Checked on `workspacev2` and `closewindow`, and whenever she + stops being away. Her monitor's wallpaper comes from wallp's + `wallp.conf` outputs and `wall_h`/`wall_v`. +``` + +- [ ] **Step 2: Spec** + +In the spec's Trigger section, change `she appears: shown (SUPER+\ or the settings switch), back from a remap after another layer opened, or moved to another monitor with the focus.` to `she stops being away: shown (SUPER+\ or the settings switch), back from a remap after another layer opened, moved to another monitor with the focus, back from a dodge, or out of fullscreen.` + +- [ ] **Step 3: Test** + +Run: `node desktop/companion/test_mood.js` +Expected: `ok` + +- [ ] **Step 4: Hand check, by the user** + +Ask the user to check, with a wallpaper hearted on DP-1: +1. switch to an empty workspace on DP-1: back view, then blush, then a line; +2. again within 3 minutes: nothing; +3. a workspace with a window: nothing; +4. an unhearted wallpaper: nothing; +5. DP-3 with its wallpaper hearted: same as 1; +6. unheart from the picker, and the list file keeps any comments. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/companion/README.md docs/superpowers/specs/2026-10-23-companion-blush-design.md +git commit -m "companion: README for the wallpaper blush, spec matches the trigger + +The trigger fires whenever she stops being away, which is a dodge and +fullscreen as well as the three appearances the spec named. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` diff --git a/docs/superpowers/plans/2026-10-23-companion-music.md b/docs/superpowers/plans/2026-10-23-companion-music.md new file mode 100644 index 0000000..534d029 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-companion-music.md @@ -0,0 +1,589 @@ +# Companion music Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The companion DJs on a track start/change, grooves instead of looking bored while music plays, and says per-artist lines edited in a settings page. + +**Architecture:** `Mood.js` (pure) gains `djAt`, `music`, `groove` inputs. `Lines.js` gains artist defaults and a substring matcher, with its app-set helpers taking a defaults argument. `Companion.qml` reads the existing `Player` singleton (`modules/sound/Player.qml`) for track changes and drives a generic frame cycle. `PhrasesPage.qml` gets three text properties so a new `ArtistsModule.qml` reuses it, saving through new `Prefs.companionArtists`. + +**Tech Stack:** Quickshell 0.3.1 QML, QML JS libraries tested with node, FLUX.2 klein frames via `gen-frames`. + +Spec: `docs/superpowers/specs/2026-10-23-companion-music-design.md`. + +Rules from AGENTS.md that apply: no `flatMap`, `Object.fromEntries`, `matchAll` in QML JS; never name a property after the enclosing id; editing `shell.qml` needs `./reload.sh desktop` from the user's terminal, never from an agent; process is `qs` (`pgrep -cx qs`). Commits are GPG-signed, end with the Co-Authored-By line, and carry the reasoning. + +All commands run from the repo root `~/Programming/GIT/quickshell`. + +--- + +### Task 1: Mood.js, dj and groove + +**Files:** +- Modify: `desktop/companion/Mood.js` (T table, `pick`) +- Test: `desktop/companion/test_mood.js` + +- [ ] **Step 1: Write the failing tests** + +In `test_mood.js`, extend the `calm` defaults (the object starting `leaving: false, now: NOW, ...`) with `djAt: 0, music: false, groove: ""` on the line that holds `singAt: 0,`: + +```js + repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0, singAt: 0, + djAt: 0, music: false, groove: "", +``` + +In `ladder`, insert directly after `["sing", { singAt: NOW - 10 }],`: + +```js + ["dj", { djAt: NOW - 10 }], +``` + +After the `// Idle levels and their frames.` block, add: + +```js +// Music while bored: she grooves with the picked frame instead, whistling +// when nothing was picked; yawning and lying down are unchanged. +assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true, groove: "headbang" })), { state: "groove", frame: "headbang" }); +assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true })), { state: "groove", frame: "whistle" }); +assert.deepStrictEqual(M.pick(calm({ idle: 1, groove: "headbang" })), { state: "bored", frame: "sleepy" }); +assert.strictEqual(state({ idle: 2, music: true }), "yawn"); +assert.strictEqual(state({ idle: 3, music: true }), "lying"); +// Music while awake is the rest pose; the DJ is only the burst. +assert.strictEqual(state({ music: true }), "base"); +assert.deepStrictEqual(M.pick(calm({ djAt: NOW - 10 })), { state: "dj", frame: "dj" }); +``` + +After `assert.strictEqual(state({ singAt: NOW - M.T.singHold }), "base");` add: + +```js +assert.strictEqual(state({ djAt: NOW - M.T.djHold }), "base"); +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `node desktop/companion/test_mood.js` +Expected: AssertionError on the ladder (`everything from dj down`). + +- [ ] **Step 3: Implement** + +In `Mood.js`, in `T` after `singHold`: + +```js + djHold: 6000, // behind the decks when a track starts or changes +``` + +In `pick`, after the `sing` line: + +```js + if (s.now - s.djAt < T.djHold) return st("dj", "dj"); +``` + +Replace `if (s.idle === 1) return st("bored", "sleepy");` with: + +```js + // Music turns boredom into a groove picked by Companion.qml; the + // deeper idle stages still win, so music does not keep her awake. + if (s.idle === 1) return s.music ? st("groove", s.groove || "whistle") : st("bored", "sleepy"); +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `node desktop/companion/test_mood.js` +Expected: `ok` + +- [ ] **Step 5: Commit** + +```bash +git add desktop/companion/Mood.js desktop/companion/test_mood.js +git commit -m "companion: dj and groove states in Mood + +A DJ burst ranks just below the headphones sing, so both arriving together +sings first. Music turns only the first idle stage into a groove: yawning +and lying down still win, so music does not keep her awake and the +screensaver handoff is unchanged. The groove pick is an input, keeping +pick() pure. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 2: Lines.js, artist lines and groove lines + +**Files:** +- Modify: `desktop/companion/Lines.js` +- Test: `desktop/companion/test_lines.js` + +- [ ] **Step 1: Write the failing tests** + +In `test_lines.js`, change the return list to: + +```js + "\nreturn { lines, apps, artists, monologue, appMap, appList, pickApp, pickArtist, pick, toMap };")(); +``` + +Before `console.log("ok");` add: + +```js +// Artists: a case-insensitive substring of what MPRIS reports, first +// matching key wins, empty phrases skipped. +assert.strictEqual(L.pickArtist("Linkin Park", null, 0), L.artists["linkin park"][0]); +assert.strictEqual(L.pickArtist("LINKIN PARK, Jay-Z", null, 0), L.artists["linkin park"][0]); +assert.strictEqual(L.pickArtist("Nobody", null, 0), ""); +assert.strictEqual(L.pickArtist("", null, 0), ""); +assert.strictEqual(L.pickArtist("Air, Beck", { air: ["a"], beck: ["b"] }, 0), "a"); +assert.strictEqual(L.pickArtist("Airbourne", { air: ["", "a"] }, 0), "a"); +assert.strictEqual(L.pick("artist:Eminem", 0, null, null), L.artists.eminem[0]); +assert.strictEqual(L.pick("artist:Eminem", 0, null, { eminem: ["mine"] }), "mine"); +assert.strictEqual(L.pick("groove", 0), L.lines.groove[0]); + +// The same list helpers serve the artist set. +assert.strictEqual(L.appMap(null, L.artists), L.artists); +assert.strictEqual(L.appList(null, L.artists).length, Object.keys(L.artists).length); +assert.deepStrictEqual(L.toMap(L.appList(null, L.artists)), L.artists); +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `node desktop/companion/test_lines.js` +Expected: `ReferenceError: artists is not defined` + +- [ ] **Step 3: Implement** + +In `Lines.js`, add to `lines` after `headphones`: + +```js + groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."], +``` + +After the `apps` object: + +```js +// The lines for an artist she knows, keyed by a lowercased name matched as +// a substring of the reported artist, so "daft punk" also catches +// collaborations. The Companion artists page edits them into +// Prefs.companionArtists, which then replaces this whole set. +var artists = { + "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."], + "evanescence": ["Wake me up inside. ...Operator, don't laugh."], + "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."], + "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."], +}; +``` + +Replace `appMap` and `appList` with versions taking the defaults (apps when omitted, so existing callers keep working): + +```js +// A set in effect: saved when it is a plain object, the defaults (apps +// unless given) otherwise. A malformed saved value must not leave her mute. +function appMap(saved, defaults) { + return saved && typeof saved === "object" && !Array.isArray(saved) ? saved : (defaults || apps); +} + +// A set as an ordered list of {cls, lines}, for the settings pages; cls is +// the key, a window class or an artist. +function appList(saved, defaults) { + const m = appMap(saved, defaults); + const keys = Object.keys(m); + const out = []; + for (let i = 0; i < keys.length; i++) + out.push({ cls: keys[i], lines: (m[keys[i]] || []).slice() }); + return out; +} +``` + +Replace `pickApp` and `pick` with a shared non-empty filter, plus `pickArtist`: + +```js +function nonEmpty(all) { + const l = []; + for (let i = 0; i < (all || []).length; i++) + if (typeof all[i] === "string" && all[i].trim() !== "") l.push(all[i]); + return l; +} + +function at(l, roll) { return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : ""; } + +// A line for an app class, empty strings skipped; "" when the class has none. +function pickApp(cls, saved, roll) { return at(nonEmpty(appMap(saved)[cls]), roll); } + +// A line for the playing artist: the first key that is a case-insensitive +// substring of it and has a non-empty line. ponytail: substring, so a +// short key like "air" also matches "Airbourne"; whole words if that bites. +function pickArtist(artist, saved, roll) { + const a = String(artist || "").toLowerCase(); + if (a === "") return ""; + const m = appMap(saved, artists); + const keys = Object.keys(m); + for (let i = 0; i < keys.length; i++) { + const l = nonEmpty(m[keys[i]]); + if (keys[i] !== "" && l.length && a.indexOf(keys[i].toLowerCase()) !== -1) return at(l, roll); + } + return ""; +} + +// A line for a reaction kind, or, with an "app:" or "artist:" prefix, for +// that class or artist. +function pick(kind, roll, saved, savedArtists) { + if (kind.slice(0, 4) === "app:") return pickApp(kind.slice(4), saved, roll); + if (kind.slice(0, 7) === "artist:") return pickArtist(kind.slice(7), savedArtists, roll); + return at(lines[kind] || [], roll); +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `node desktop/companion/test_lines.js && node desktop/companion/test_mood.js` +Expected: `ok` twice. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/companion/Lines.js desktop/companion/test_lines.js +git commit -m "companion: artist and groove lines + +Artist keys match as a case-insensitive substring of the MPRIS artist, so +collaborations and feat. credits still hit; the cost is a short key +over-matching, marked as a ponytail ceiling. appMap/appList take their +defaults so the settings page can serve artists with the same helpers. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 3: Prefs.companionArtists + +**Files:** +- Modify: `desktop/Prefs.qml` (after `resetCompanionApps`, parse block near line 155, `onLoadFailed` near line 172) + +- [ ] **Step 1: Add the property and setters** after `resetCompanionApps()`'s closing brace: + +```qml + // The companion's artist lines as saved, keyed by lowercased artist, or + // null for the defaults in companion/Lines.js. The Companion artists + // page edits it. + property var companionArtists: null + function setCompanionArtists(map) { + root.companionArtists = map; + root.write(Object.assign({}, root.doc, { companionArtists: map })); + } + function resetCompanionArtists() { + root.companionArtists = null; + const d = Object.assign({}, root.doc); + delete d.companionArtists; + root.write(d); + } +``` + +- [ ] **Step 2: Parse it** after the `root.companionApps = ca && ...` line: + +```qml + const cr = parsed.companionArtists; + root.companionArtists = cr && typeof cr === "object" && !Array.isArray(cr) ? cr : null; +``` + +- [ ] **Step 3: Reset on load failure**: in the `onLoadFailed` line, after `root.companionApps = null;` insert ` root.companionArtists = null;`. + +- [ ] **Step 4: Check the shell reloads cleanly** + +Run: `qs log -p desktop --log-times | tail -5` +Expected: a `Reloading configuration` / `Configuration Loaded` after the edit's mtime, no errors. If no reload line, see AGENTS.md "Hot reload sometimes does not fire". + +- [ ] **Step 5: Commit** + +```bash +git add desktop/Prefs.qml +git commit -m "prefs: companionArtists, saved like companionApps + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 4: PhrasesPage text properties and ArtistsModule + +**Files:** +- Modify: `desktop/modules/companion/PhrasesPage.qml` (properties near line 27, intro at line 101, placeholder at line 132, button at line 180) +- Create: `desktop/modules/companion/ArtistsModule.qml` +- Modify: `desktop/shell.qml:91` + +- [ ] **Step 1: Parametrise the page.** After `required property QtObject owner`, add: + +```qml + // What the cards are keyed by; the defaults are the app set's, so + // PhrasesModule passes nothing. + property string intro: "When focus moves to a program she recognizes, she says one of its lines at random, a third " + + "of the time and under her 30 s line cooldown. The class is Hyprland's window class, " + + "lowercased (hyprctl activewindow shows it). Save applies at once." + property string keyHint: "window class, e.g. firefox" + property string addText: "Add app" + // The saved set, for Reset's enabled state; null means the defaults. + property var saved: Prefs.companionApps +``` + +Replace the intro `Small`'s `text:` (the three-line string) with `text: page.intro`, the card field's `placeholder: "window class, e.g. firefox"` with `placeholder: page.keyHint`, `Button { text: "Add app"; ...}` with `Button { text: page.addText; onClicked: page.addApp() }`, and in the Reset button's `enabled:` replace `Prefs.companionApps !== null` with `page.saved !== null`. + +- [ ] **Step 2: Create `ArtistsModule.qml`** (copy the GPLv2 header from `PhrasesModule.qml` lines 1-10 verbatim, then): + +```qml +import QtQuick +import "../.." +import "../../companion/Lines.js" as Lines + +// The lines the companion says when a track by an artist she knows starts, +// matched as a case-insensitive substring of the player's artist. Same +// draft, Save and Reset as PhrasesModule, on Prefs.companionArtists and +// Lines.artists; the page's cls field holds the artist. +Module { + id: am + name: "artists" + label: "Companion artists" + // nf-fa-music, present in Inconsolata Nerd Font. + icon: "" + settings: true + group: "Companion" + keywords: ["companion", "artist", "music", "song", "track", "line", "say", "2b"] + searchItems: am.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i })) + + property var draft: null + property bool resetting: false + readonly property var shown: am.draft ?? Lines.appList(Prefs.companionArtists, Lines.artists) + readonly property bool valid: am.shown.every(a => String(a.cls).trim() !== "" + && (a.lines || []).some(l => String(l).trim() !== "")) + dirty: am.draft !== null + + function edit(list) { am.draft = list; am.resetting = false; } + function reset() { am.draft = Lines.appList(null, Lines.artists); am.resetting = true; } + function save() { + if (am.resetting) Prefs.resetCompanionArtists(); + else Prefs.setCompanionArtists(Lines.toMap(am.draft)); + am.discard(); + } + function discard() { am.draft = null; am.resetting = false; } + + page: Component { + Page { + title: "Companion artists" + actions: Component { + SaveBar { dirty: am.dirty; valid: am.valid; note: "every artist needs a name and a phrase" + onSave: am.save(); onDiscard: am.discard() } + } + PhrasesPage { + width: parent.width + owner: am + intro: "When a track by an artist she knows starts or changes, she says one of their lines at " + + "random, under her 30 s line cooldown. The name matches any part of the player's artist, " + + "ignoring case, so \"daft punk\" also catches \"Daft Punk, Pharrell Williams\". " + + "Save applies at once." + keyHint: "artist, e.g. linkin park" + addText: "Add artist" + saved: Prefs.companionArtists + } + } + } +} +``` + +- [ ] **Step 3: Register it** in `desktop/shell.qml`, after `PhrasesModule {},`: + +```qml + ArtistsModule {}, +``` + +- [ ] **Step 4: Verify.** `shell.qml` changed, so ask the user to run `./reload.sh desktop` from their terminal (never from a tool call). Then `qs log -p desktop | tail -20` must show no errors, and the user checks by eye: Settings > Companion artists lists the four seeds; Companion phrases still reads as before. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/companion/PhrasesPage.qml desktop/modules/companion/ArtistsModule.qml desktop/shell.qml +git commit -m "companion: artist lines page + +PhrasesPage takes its intro, key hint and add-button text, so the artist +set reuses it rather than a copy that would drift. ArtistsModule mirrors +PhrasesModule on Prefs.companionArtists and Lines.artists. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 5: Companion.qml, track changes, groove pick, frame cycles + +**Files:** +- Modify: `desktop/companion/Companion.qml` + +- [ ] **Step 1: Import the player.** After `import ".."` add `import "../modules/sound"`. + +- [ ] **Step 2: Inputs.** After `property double singAt: 0`: + +```qml + property double djAt: 0 + property string groove: "" +``` + +- [ ] **Step 3: Groove pick and bored line.** In `onIdleChanged`, replace the `else if (root.idle === 1 && root.wasIdle === 0) { ... }` branch with: + +```qml + } else if (root.idle === 1 && root.wasIdle === 0) { + root.pickGroove(); + if (Player.playing) root.say("groove"); + else if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue(); + else root.say("bored"); + } +``` + +- [ ] **Step 4: Track changes.** After the headphones block (`onPhonesConnectedChanged`), add: + +```qml + // What is playing, from the sound module's player (it already drops the + // playerctld and browser-integration duplicates). A new artist+title + // while playing is a track start or change: she DJs for Mood.T.djHold + // and says the artist's line if she knows them. Paused reads "", and + // resuming the same track matches lastTrack, so it does not DJ again. + readonly property string track: Player.playing ? Player.artist + "\n" + Player.title : "" + property string lastTrack: "" + onTrackChanged: { + if (root.track === "" || root.track === root.lastTrack) return; + root.lastTrack = root.track; + root.djAt = Date.now(); + root.now = root.djAt; + root.say("artist:" + Player.artist); + } + // Music starting while she is already bored needs a groove too. + readonly property bool music: Player.playing + onMusicChanged: if (root.music && root.idle === 1) root.pickGroove() + function pickGroove() { + const g = ["whistle", "headbang", "airguitar"]; + root.groove = g[Math.floor(Math.random() * g.length)]; + } +``` + +- [ ] **Step 5: Feed Mood.** In the `Mood.pick({...})` snapshot, change `singAt: root.singAt, monologue: root.monologue, idle: root.idle,` to: + +```qml + singAt: root.singAt, djAt: root.djAt, music: root.music, groove: root.groove, + monologue: root.monologue, idle: root.idle, +``` + +- [ ] **Step 6: Artist lines reach say.** In `say()`, change the pick to: + +```qml + const line = Lines.pick(kind, Math.random(), Prefs.companionApps, Prefs.companionArtists); +``` + +- [ ] **Step 7: Frame cycles.** After the `waveFlip` Timer, add: + +```qml + // Frames Mood names that are really a loop: the frame names its cycle + // and how fast it turns. Everything else shows as named. + readonly property var cycles: ({ + dj: { frames: ["dj", "dj-scratch"], ms: 450 }, + headbang: { frames: ["headbang-up", "headbang-down"], ms: 220 }, + }) + property int cycleIndex: 0 + readonly property var cycle: root.cycles[root.mood.frame] ?? null + onCycleChanged: root.cycleIndex = 0 + Timer { + interval: root.cycle?.ms ?? 1000 + repeat: true + running: root.cycle !== null && !root.away + onTriggered: root.cycleIndex = (root.cycleIndex + 1) % root.cycle.frames.length + } + readonly property string frame: root.leaving ? root.waveCycle[root.waveIndex] + : root.cycle ? root.cycle.frames[root.cycleIndex % root.cycle.frames.length] : root.mood.frame +``` + +Change the `Figure`'s `source:` to: + +```qml + source: `file://${root.art}/${root.outfit}/${root.frame}.png` +``` + +- [ ] **Step 8: Verify the load.** + +Run: `node desktop/companion/test_mood.js && node desktop/companion/test_lines.js && qs log -p desktop --log-times | tail -10` +Expected: `ok`, `ok`, a reload after the edit with no `TypeError`/`ReferenceError`. Then ask the user to play a track: she should show `base` (frames not generated yet, falls back) for 6s, and say an artist line for one of the seeds; pause and resume must not repeat it. + +- [ ] **Step 9: Commit** + +```bash +git add desktop/companion/Companion.qml +git commit -m "companion: DJ on track change, groove while bored with music + +Reads the sound module's Player, which already filters the MPRIS +duplicates. The track key is artist+title while playing, so a pause and +resume of the same track does not DJ again; a shell start with music on +DJs once, which is harmless. Frame loops are a small table keyed by the +frame Mood names, so dj and headbang need no special case in Mood. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 6: Frames and docs + +**Files:** +- Modify: `desktop/companion/frames.txt` +- Modify: `desktop/companion/README.md` + +- [ ] **Step 1: Dress frames.** In `frames.txt`, after `dress/sing <- ...`, add: + +``` +# Music: behind the decks on a track start or change, and the grooves +# that replace boredom while music plays. +DECK = A small black DJ console with two turntables and a mixer stands in front of her at waist height, hiding her legs. +dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile; she wears black over-ear headphones. ${EYES} ${KEEP} +dress/dj-scratch <- dress/dj : Change only her arms: the hand on the left side of the image scratches a record, the other holds one headphone cup to her ear. Everything else exactly the same. ${KEEP} +dress/whistle <- dress/base : Change only her expression: lips puckered, whistling, a couple of small black musical notes floating beside her mouth. ${POSE} ${EYES} ${KEEP} +dress/headbang-up <- dress/base : She headbangs: head tipped back, hair flying up, eyes shut, fists clenched at her sides. ${BARE} ${KEEP_BARE} +dress/headbang-down <- dress/headbang-up : Change only her head: tipped sharply forward and down, hair thrown forward over her face. Everything else exactly the same. ${KEEP_BARE} +dress/airguitar <- dress/base : She plays an invisible air guitar: one hand strumming at her hip, the other fretting in the air to the side, knees bent, eyes shut, mouth open in a grin. Nothing in her hands. ${BARE} ${KEEP_BARE} +``` + +`KEEP`, `EYES`, `BARE`, `KEEP_BARE` and `LEOTARD` are defined at the top of the file; `DECK` only has to precede its uses, as `SING` does. + +- [ ] **Step 2: Leotard frames.** After the last `leotard/` line add: + +``` +leotard/dj <- dress/dj : ${LEOTARD} +leotard/dj-scratch <- dress/dj-scratch : ${LEOTARD} +leotard/whistle <- dress/whistle : ${LEOTARD} +leotard/headbang-up <- dress/headbang-up : ${LEOTARD} +leotard/headbang-down <- dress/headbang-down : ${LEOTARD} +leotard/airguitar <- dress/airguitar : ${LEOTARD} +``` + +- [ ] **Step 3: Check the parse.** + +Run: `desktop/companion/gen-frames --list` +Expected: the 12 new outputs listed (and nothing else new), no parse error. + +- [ ] **Step 4: README.** In `desktop/companion/README.md`, add to the Files table row for `Lines.js` "and per-artist lines", and add an Inputs bullet after the Sony headphones one: + +``` +- What is playing is `modules/sound/Player.qml`, which already drops the + playerctld and browser-integration duplicates. A new artist+title while + playing makes her DJ for `Mood.T.djHold`, 6 s (`dj` and `dj-scratch` + cycling), and say the artist's line if one matches: lowercased keys in + `Lines.artists`, matched as a substring, edited on Settings > Companion + artists into `Prefs.companionArtists`. Pause and resume of the same + track is not a change. While music plays, the first idle stage is a + groove instead of bored: `whistle`, `headbang` (`headbang-up`/`-down` + cycling) or `airguitar`, picked at random, with a `groove` line; + yawning and lying down are unchanged. +``` + +- [ ] **Step 5: Commit** + +```bash +git add desktop/companion/frames.txt desktop/companion/README.md +git commit -m "companion: music frames to generate, and the README + +Twelve frames, dress and leotard. Prompts are a first draft for klein; the +user generates them, and until then each falls back to base. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +- [ ] **Step 6: Hand off art.** Tell the user: start sd-webui with FLUX.2 klein, run `desktop/companion/gen-frames`, reroll with `--seed N <output>` as needed, record failures in `recipe.txt`. diff --git a/docs/superpowers/specs/2026-10-23-companion-blush-design.md b/docs/superpowers/specs/2026-10-23-companion-blush-design.md new file mode 100644 index 0000000..3ef8c9d --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-companion-blush-design.md @@ -0,0 +1,74 @@ +# Companion: blushing at a wallpaper + +She turns to look at the wallpaper behind her and blushes, for the +wallpapers on a list, when the workspace in front of her is empty enough +for her to see it. + +## Trigger + +Checked at two moments, in `Companion.qml`: + +- the focused workspace changes or loses a window (`workspacev2`, + `closewindow`), so switching to an empty workspace or closing its last + window counts; +- she stops being away: shown (SUPER+\ or the settings switch), back + from a remap after another layer opened, moved to another monitor with + the focus, back from a dodge, or out of fullscreen. + +It fires when all three hold: + +- the focused workspace has no windows (`toplevels.values.length === 0`, + after `Hyprland.refreshToplevels()`); +- the wallpaper on her monitor is on the list; +- at least 3 minutes since the last blush (`T.admireCooldown`). + +Her monitor's wallpaper: wallp's `wallp.conf` maps `OUTPUT_H` and +`OUTPUT_V` to connectors, and `Wallpapers.currentH`/`currentV` say what is +on each. The connector names are read from `wallp.conf`, not hardcoded. A +monitor matching neither has no wallpaper and never fires. + +## Reaction + +A new state in `Mood.js`, `admire`, stamped by `admireAt`: + +| since `admireAt` | frame | +| --- | --- | +| 0 to 1.5 s (`T.admireLook`) | `watch`, the back view: she turns to the wallpaper | +| 1.5 to 4.5 s (`T.admireBlush`) | `blush3` | + +`watch` and `blush3` exist in both outfits, so no new art is generated. + +Priority: below destruct, scorched, dodge, shoved, listen, think, wave and +clicks; above mock, watch (typing), stretch, muse and idle. A click during +it wins, as it does over everything below it now. + +A line from a `wallpaper` set in `Lines.js` as the blush starts, under the +usual 30 s line cooldown. + +## The list + +A plain text file: one absolute path per line, blank lines and lines +starting with `#` skipped. Paths compare exactly with what wallp writes to +`wall_h`/`wall_v`. + +- Default path `~/.config/keystone/companion-blush.txt`; a missing file is + an empty list. +- The path is `Prefs.companionBlushFile`, with a text field on Settings > + Desktop > Companion (`desktop/modules/companion/`). +- Watched with a `FileView`, so a hand edit applies live. +- The wallpaper picker (`WallpaperTab.qml`) draws a heart toggle on each + thumbnail, filled when the path is listed. Toggling rewrites the file + through a `Process` running `cat > <file>`, because + `FileView.setText("")` on an emptied list writes no file (AGENTS.md). + Comments in the file are kept on rewrite; only the toggled path's line is + added or removed. + +The parsed list lives in one place both the companion and the picker read, +a small singleton in `desktop/companion/`. + +## Check + +`test_mood.js` gains: the `watch` then `blush3` sequence and its end, the +3 minute cooldown, a click winning over it, and it winning over typing. +The trigger is checked by hand: an empty workspace with a listed +wallpaper, a non-empty one, an unlisted wallpaper, each monitor. diff --git a/docs/superpowers/specs/2026-10-23-companion-design.md b/docs/superpowers/specs/2026-10-23-companion-design.md index 03feaff..8a8c426 100644 --- a/docs/superpowers/specs/2026-10-23-companion-design.md +++ b/docs/superpowers/specs/2026-10-23-companion-design.md @@ -111,7 +111,7 @@ base, so every frame descends from one reference. | --- | --- | | klein edit of `base-sitting.png` | `base`: "make her stand upright, facing the viewer, arms relaxed at her sides" | | img2img from `base`, strength 0.75 | `smile`, `mad`, `smirk`, `blush1`, `blush2`, `blush3`, `sleepy` | -| klein edit of `base` | `wave`, `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch`, `shoved` | +| klein edit of `base` | `wave`, `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch`, `shoved`, `sing` | | klein edit of the dress frame | every `leotard/<name>`: "her skirt is gone, revealing a plain white leotard; change nothing else" | | klein edit of `leotard/base` | `scorched`, leotard only: the moment after the explosion | @@ -137,11 +137,12 @@ timers. | 2 | Dodge | pointer rests on her 0.6 s with no click | slides out of the corner | returns after 5 s | | 3 | Shoved | a balloon is on screen on her monitor | `shoved`, slid left by the stack's width | balloons gone | | 4 | Assistant | recording / working / reply arrived | `listen` / `think` / `wave` | state ends; wave lasts 3 s | -| 5 | Clicks | clicks 1-6 of a burst; a burst ends after 1.5 s without one | `smile`, `blush1` to `blush3` | 2 s after the last click | -| 6 | Mock | BackSpace, at most once in 20 s | `smirk`, `point` | 2.5 s | -| 7 | Watching | typing pulse in the last 2 s | `watch` | 2 s after typing stops | -| 8 | Idle | no input for 1 / 3 / 5 min | `sleepy` / `yawn` / `lying` | any input: `stretch`, then base | -| 9 | Base | nothing else | `base` | | +| 5 | Singing | Sony headphones connected | `sing` | 4 s | +| 6 | Clicks | clicks 1-6 of a burst; a burst ends after 1.5 s without one | `smile`, `blush1` to `blush3` | 2 s after the last click | +| 7 | Mock | BackSpace, at most once in 20 s | `smirk`, `point` | 2.5 s | +| 8 | Watching | typing pulse in the last 2 s | `watch` | 2 s after typing stops | +| 9 | Idle | no input for 1 / 3 / 5 min | `sleepy` / `yawn` / `lying` | any input: `stretch`, then base | +| 10 | Base | nothing else | `base` | | - **Self-destruct, as in the game.** She glows, says "Self-destruct sequence initiated.", a white flash covers the sprite for about a diff --git a/docs/superpowers/specs/2026-10-23-companion-music-design.md b/docs/superpowers/specs/2026-10-23-companion-music-design.md new file mode 100644 index 0000000..dc30257 --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-companion-music-design.md @@ -0,0 +1,125 @@ +# Companion music: design + +Date: 2026-10-23. Status: designed, not implemented. + +## Goal + +The companion reacts to what is playing, not only to headphones +connecting. When a track starts or changes she DJs behind a console for a +few seconds, and says a line if she knows the artist. While music plays +and she is bored, she grooves instead of looking sleepy: whistling, +headbanging or playing air guitar, with an occasional line. Her artist +lines are edited in Settings > Companion artists, like the app lines. + +## Verified before designing + +- **`modules/sound/Player.qml` already picks the real player.** It is a + singleton the island reads, filters out the playerctld and + plasma-browser-integration duplicates, and exposes `playing`, `artist` + and `title`. The companion reads it; it opens no MPRIS of its own. +- **The app lines page is one parameter away from serving artists.** + `PhrasesPage.qml` edits a list of `{cls, lines}` owned by + `PhrasesModule.qml`, which saves through `Prefs.setCompanionApps` and + falls back to `Lines.apps`. Only the key's label is app specific. + +## Behaviour + +### DJ burst + +A track key is `artist + "\n" + title`. While `Player.playing` is true, a +key different from the last one seen sets `djAt` to now and becomes the +last one seen. Resuming the same track after a pause is not a change, so +it does not DJ. A shell start with music already playing does DJ once: +unlike Bluetooth, MPRIS has no empty startup window to mistake for a +connection, and a DJ on launch is harmless. + +`Mood.T.djHold` is 6000. In `Mood.pick` the DJ ranks directly below +`sing`, so headphones connecting together with music starting sings +first. Its frames are `dj` and `dj-scratch`, cycled by Companion.qml the +way the wave frames are. + +On the burst she says an artist line if one matches, under the 30 s line +cooldown. No match says nothing. + +### Artist matching + +`Lines.artistLine(map, artist)`: the first key of the map that is a +case-insensitive substring of the reported artist wins, and one of its +lines is picked at random. "daft punk" matches "Daft Punk, Pharrell +Williams". Short keys can over-match ("air" in "Airbourne"); accepted. + +### Groove + +`Mood.pick` gains `s.music` (`Player.playing`). At `idle === 1` with +music, the state is `groove` instead of `bored`; yawning and lying down +are unchanged, so music does not stop her dozing off and the screensaver +handoff stays the same. + +Reaching idle stage 1, or music starting while already there, picks one +groove at random: `whistle`, `headbang` or `airguitar`; an unset pick +reads as `whistle`. The pick is passed to `pick` as `s.groove`, so +`Mood.js` stays pure. Headbang cycles `headbang-up` and `headbang-down` +fast; the other two are single frames. + +The bored line becomes a `groove` line when music is playing, same +chance, same cooldown. The opening monologue keeps its bored trigger +only when there is no music. + +## Lines + +New reaction group in `Lines.lines`: + + groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."], + +Default artists, `Lines.artists`: + + "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."], + "evanescence": ["Wake me up inside. ...Operator, don't laugh."], + "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."], + "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."], + +`appList` and `toMap` take the default set as an argument, so the same +functions serve apps and artists; `appMap` becomes `pickMap(saved, +defaults)`. + +## Settings + +- `Prefs.companionArtists`, `setCompanionArtists`, `resetCompanionArtists`, + parsed and defaulted like `companionApps`. +- `PhrasesPage.qml` gains `keyLabel` (default "Class") used for the key + field's placeholder and label. +- `modules/companion/ArtistsModule.qml`: "Companion artists", group + Companion, a copy of `PhrasesModule` bound to the artist prefs and + defaults, `keyLabel: "Artist"`. Registered in `shell.qml`, which means a + restart (see AGENTS.md, Reloading). + +## Frames + +In `frames.txt`, each with its leotard line (`${LEOTARD}`), 12 in all: + + DECK = A small black DJ console with two turntables and a mixer stands in front of her at waist height, hiding her legs. + dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile; she wears black over-ear headphones. ${EYES} ${KEEP} + dress/dj-scratch <- dress/dj : Change only her arms: the hand on the left side of the image scratches a record, the other holds one headphone cup to her ear. Everything else exactly the same. ${KEEP} + dress/whistle <- dress/base : Change only her expression: lips puckered, whistling, a couple of small black musical notes floating beside her mouth. ${POSE} ${EYES} ${KEEP} + dress/headbang-up <- dress/base : She headbangs: head tipped back, hair flying up, eyes shut, fists clenched at her sides. ${BARE} ${KEEP_BARE} + dress/headbang-down <- dress/headbang-up : Change only her head: tipped sharply forward and down, hair thrown forward over her face. Everything else exactly the same. ${KEEP_BARE} + dress/airguitar <- dress/base : She plays an invisible air guitar: one hand strumming at her hip, the other fretting in the air to the side, knees bent, eyes shut, mouth open in a grin. Nothing in her hands. ${BARE} ${KEEP_BARE} + +Prompts are a first draft: klein results get rerolled and reworded as +before, and what failed goes into `recipe.txt`. The user runs +`gen-frames`; a missing frame falls back to `base`, so the code can land +before the art. + +## Tests + +- `test_mood.js`: dj wins over clicks and loses to sing; groove replaces + bored only with music; yawn and lying still win with music; groove + frame follows `s.groove`. +- `test_lines.js`: substring match is case-insensitive and picks the + first matching key; no match returns nothing; `appList`/`toMap` round + trip with the artist defaults. + +## Not doing + +- Window title or URL reactions (separate idea, not this spec). +- Per-artist frames, genre detection, beat sync. |
