aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md19
-rw-r--r--desktop/Prefs.qml71
-rw-r--r--desktop/assistant/Assistant.qml54
-rw-r--r--desktop/assistant/Card.qml68
-rw-r--r--desktop/assistant/Models.qml87
-rw-r--r--desktop/assistant/README.md10
-rw-r--r--desktop/companion/Admired.qml101
-rw-r--r--desktop/companion/Companion.qml551
-rw-r--r--desktop/companion/Figure.qml159
-rw-r--r--desktop/companion/Lines.js146
-rw-r--r--desktop/companion/ListFile.js44
-rw-r--r--desktop/companion/Mood.js114
-rw-r--r--desktop/companion/README.md110
-rw-r--r--desktop/companion/frames.txt27
-rw-r--r--desktop/companion/test_lines.js79
-rw-r--r--desktop/companion/test_mood.js178
-rw-r--r--desktop/modules/appearance/WallpaperTab.qml21
-rw-r--r--desktop/modules/assistant/AssistantModule.qml125
-rw-r--r--desktop/modules/assistant/AssistantPage.qml205
-rw-r--r--desktop/modules/assistant/Config.js87
-rw-r--r--desktop/modules/assistant/README.md24
-rw-r--r--desktop/modules/assistant/test_config.js41
-rw-r--r--desktop/modules/companion/ArtistsModule.qml67
-rw-r--r--desktop/modules/companion/CompanionModule.qml131
-rw-r--r--desktop/modules/companion/PhrasesModule.qml64
-rw-r--r--desktop/modules/companion/PhrasesPage.qml195
-rw-r--r--desktop/shell.qml20
-rw-r--r--docs/superpowers/plans/2026-10-23-assistant-cloud-models.md909
-rw-r--r--docs/superpowers/plans/2026-10-23-companion-blush.md655
-rw-r--r--docs/superpowers/plans/2026-10-23-companion-music.md589
-rw-r--r--docs/superpowers/plans/2026-10-23-companion.md7
-rw-r--r--docs/superpowers/specs/2026-10-23-companion-blush-design.md74
-rw-r--r--docs/superpowers/specs/2026-10-23-companion-design.md19
-rw-r--r--docs/superpowers/specs/2026-10-23-companion-music-design.md125
-rw-r--r--keybinds/Binds.js10
-rw-r--r--keybinds/test_binds.js2
-rw-r--r--notifications/NotificationBalloon.qml24
-rw-r--r--shared/NotifyConfig.js10
-rw-r--r--shared/test_notify_config.js14
39 files changed, 5166 insertions, 70 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 76a7bb8..faf6b18 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,6 +19,7 @@ modules.
island/ top-centre pills on DP-1: clock, volume, tray, layout, privacy,
calendar, OBS
assistant/ the assistant canvas on SUPER+Home, a view over desktop-assistant
+ companion/ a chibi 2B, bottom-right of the focused monitor, on SUPER+\
window-switcher/ expose: open windows scattered as live previews, on ALT+TAB
or the top-right hot corner of DP-1
keybinds/ live keybind reminder, on SUPER+k
@@ -207,6 +208,12 @@ changing that component. The ones that generalise:
`Settings.qml`, which imports Controls, `Button { icon: ... }` became the
Controls button and logged `Unable to assign QString to QQuickIcon`. A file
that imports Controls builds its buttons from a `Rectangle` instead.
+- **Plain `import QtQuick` shadows a directory's own `Sprite.qml` too.**
+ QtQuick ships a `Sprite` type (for `AnimatedSprite`), and an explicit
+ import beats the implicit directory one, so the companion's `Sprite {
+ onSettled: ... }` failed with `Cannot assign to non-existent property
+ "onSettled"` and took the whole desktop shell down on restart. It is
+ `Figure.qml`. Before naming a component, check it is not a QtQuick type.
- **`FloatingWindow.maximized` works under Hyprland.** Setting it fills the
monitor and clearing it restores the old size, checked by eye; the settings
window's titlebar row uses it, since Hyprland draws no decorations.
@@ -358,6 +365,11 @@ changing that component. The ones that generalise:
- **The old waybar notification module shelled out to `dunstctl`.** It and its
`notifications.py` are archived under `~/bin/archive/`, with the rest of the
stock waybar theme; do not resurrect them.
+- **The shell's JS engine has no `Array.prototype.flatMap`.** A binding
+ using it throws `TypeError: Property 'flatMap' of object [object Object]
+ is not a function`, which shows only in `qs log`; a Repeater bound to it
+ simply builds nothing. Use `reduce((a, x) => a.concat(...), [])`. Seen
+ with the assistant page's cloud Prefer rows (2026-10-23).
- **A `Socket` whose connect failed never retries.** Quickshell 0.3.1 keeps
the failed `QLocalSocket`, so setting `connected = true` again (or toggling
it, or changing `path`) is a no-op: `setConnected` only connects when no
@@ -492,6 +504,13 @@ correct and a fresh `qs` process rendered it. Force the rescan by changing a
file that imports the directory (`desktop/shell.qml`), or restart the shell.
Touch alone does not reload: the content must change.
+Changing `desktop/shell.qml` can fail on a type it did not touch. Twice in
+one session, an edit adding an import to it hot-reloaded into `Failed to
+load configuration`, `UdtModule is not a type` and then `SunsetModule is
+not a type`, both existing modules in `modules/appearance/` that a fresh
+process resolved fine. The old config kept running; `./reload.sh desktop`
+loaded the new one cleanly. Expect a restart after any `shell.qml` edit.
+
Switching git branches under a running shell can drop a component until a
restart. Merging the island branch meant a moment on master without
`desktop/island/`; the shell hot-reloaded then, logged `Ignoring unresolvable
diff --git a/desktop/Prefs.qml b/desktop/Prefs.qml
index 0ed6ee9..610e004 100644
--- a/desktop/Prefs.qml
+++ b/desktop/Prefs.qml
@@ -45,6 +45,66 @@ Singleton {
root.write(Object.assign({}, root.doc, { assistant: a }));
}
+ // Whether the desktop companion is shown: SUPER+\ and the Companion
+ // settings page both set it, so it survives a restart. ready is false
+ // until the file has been read, so she does not show and then vanish.
+ property bool companion: true
+ property bool ready: false
+ function setCompanion(on) {
+ if (on === root.companion) return;
+ root.companion = on;
+ root.write(Object.assign({}, root.doc, { companion: on }));
+ }
+
+ // Seconds the companion stays in her leotard after the self-destruct,
+ // 0 for until she is hidden and shown again.
+ property int companionSkirtless: 60
+ function setCompanionSkirtless(s) {
+ if (s === root.companionSkirtless) return;
+ root.companionSkirtless = s;
+ root.write(Object.assign({}, root.doc, { companionSkirtless: s }));
+ }
+
+ // 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 }));
+ }
+
+ // The companion's app lines as saved: a map of lowercased window class
+ // to phrases, or null for the defaults in companion/Lines.js. The
+ // Companion phrases page edits it.
+ property var companionApps: null
+ function setCompanionApps(map) {
+ root.companionApps = map;
+ root.write(Object.assign({}, root.doc, { companionApps: map }));
+ }
+ function resetCompanionApps() {
+ root.companionApps = null;
+ const d = Object.assign({}, root.doc);
+ delete d.companionApps;
+ root.write(d);
+ }
+
+ // 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);
+ }
+
// The settings window's sidebar width, dragged by its edge.
readonly property int sidebarMin: 160
readonly property int sidebarMax: 420
@@ -103,6 +163,15 @@ Singleton {
const w = parsed.settings ? parsed.settings.sidebar : undefined;
root.settingsSidebar = typeof w === "number" && w >= root.sidebarMin && w <= root.sidebarMax
? Math.round(w) : 200;
+ root.companion = parsed.companion !== false;
+ const sk = parsed.companionSkirtless;
+ root.companionSkirtless = Number.isInteger(sk) && sk >= 0 ? sk : 60;
+ root.companionBlushFile = typeof parsed.companionBlushFile === "string" ? parsed.companionBlushFile : "";
+ const ca = parsed.companionApps;
+ root.companionApps = ca && typeof ca === "object" && !Array.isArray(ca) ? ca : null;
+ const cr = parsed.companionArtists;
+ root.companionArtists = cr && typeof cr === "object" && !Array.isArray(cr) ? cr : null;
+ root.ready = true;
}
FileView {
@@ -117,6 +186,6 @@ Singleton {
onFileChanged: reload()
onLoaded: root.reparse()
// Missing file: the defaults, and nothing written.
- onLoadFailed: { root.doc = {}; root.pinnedIp = ""; root.presets = null; root.settingsSidebar = 200; root.assistantModel = ""; }
+ onLoadFailed: { root.doc = {}; root.pinnedIp = ""; root.presets = null; root.settingsSidebar = 200; root.assistantModel = ""; root.companion = true; root.companionBlushFile = ""; root.companionSkirtless = 60; root.companionApps = null; root.companionArtists = null; root.ready = true; }
}
}
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index 33616ad..0b7026d 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -66,6 +66,18 @@ Scope {
// The preferred model being loaded on open, shown in the header until
// the router reports it loaded. Set from shell.qml.
property string swapping: ""
+ // 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
property int ctxUsed: 0
property int ctxMax: 0
property var convId: null
@@ -74,6 +86,9 @@ Scope {
property var convs: []
property var results: []
readonly property bool busy: root.state !== "idle"
+ // A reply finished (not stopped). The canvas notifies through notifyDone;
+ // the desktop companion waves.
+ signal replied()
// A daemon gone mid-reply sends no idle; its hello on return redraws.
onConnectedChanged: if (!root.connected) {
@@ -110,11 +125,12 @@ Scope {
function close() {
root.endRecording(false);
- root.open = false; root.historyOpen = false; root.pickerOpen = false;
+ root.open = false; root.historyOpen = false; root.pickerOpen = false; root.modelsOpen = false;
}
// Escape: a recording, the picker, then the history panel, then the card.
function back() {
if (root.recording) root.endRecording(false);
+ else if (root.modelsOpen) root.modelsOpen = false;
else if (root.pickerOpen) root.pickerOpen = false;
else if (root.historyOpen) root.historyOpen = false;
else root.close();
@@ -255,8 +271,8 @@ Scope {
root.notice = "recording failed";
} else if (rec.send && root.connected) {
root.chatting = true;
- root.send({ type: "ask", text: "", audio: root.micPath,
- files: root.staged.map(f => f.path) });
+ root.send(root.withModel({ type: "ask", text: "", audio: root.micPath,
+ files: root.staged.map(f => f.path) }));
}
rec.destroy();
}
@@ -322,16 +338,37 @@ Scope {
|| (text.trim() === "" && !root.staged.length)) return false;
root.notice = "";
root.chatting = true;
- root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) });
+ root.send(root.withModel({ type: "ask", text: text, files: root.staged.map(f => f.path) }));
return true;
}
function showHistory() {
root.pickerOpen = false;
+ root.modelsOpen = false;
root.historyOpen = true;
root.send({ type: "list" });
}
+ 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;
+ }
+
function row(t, live) {
return { user: t.user ?? "", reply: t.reply ?? "", think: t.think ?? "",
tools: (t.tools ?? []).join(","), error: "", live: live,
@@ -368,15 +405,18 @@ Scope {
root.state = e.state;
root.runningTool = e.state === "tool" ? e.tool : "";
root.model = e.model ?? "";
+ root.chatModel = e.chat_model ?? "";
root.ctxUsed = e.ctx_used;
root.ctxMax = e.ctx_max;
root.load(e.id, e.turns, e.pending);
// A restart or reload counts as active: the conversation it
// reconnects to is not stale just because this process is new.
if (e.pending || e.turns.length) root.lastActive = Date.now();
+ root.send({ type: "models" });
break;
case "conversation":
root.load(e.id, e.turns, null);
+ root.chatModel = e.model ?? "";
// Opened from History on purpose: it counts as active now.
if (e.turns.length) root.lastActive = Date.now();
// Emptied (New, or the current one deleted) with nothing staged:
@@ -405,7 +445,10 @@ Scope {
}
if (!e.stopped) root.convId = e.id;
root.lastActive = Date.now();
- if (!e.stopped && i >= 0) root.notifyDone(turns.get(i).reply);
+ if (!e.stopped && i >= 0) {
+ root.notifyDone(turns.get(i).reply);
+ root.replied();
+ }
break;
case "progress": if (root.opDaemon) root.opChars = e.chars; break;
case "written":
@@ -439,6 +482,7 @@ Scope {
case "ctx": root.ctxUsed = e.used; root.ctxMax = e.max; if (e.model) root.model = e.model; break;
case "conversations": root.convs = e.items; break;
case "results": root.results = e.items; break;
+ case "models": root.models = e.items; break;
}
}
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index a2f8e28..810920f 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -28,7 +28,7 @@ Rectangle {
property real fullHeight: 600 // the chat state's height, set by the window
readonly property string mode: card.canvas.mode
- readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen
+ readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen || card.canvas.modelsOpen
readonly property bool tall: card.mode === "chat" || card.panel
// Bottom-up: the input, then the actions (file state), then the chips.
@@ -49,13 +49,15 @@ Rectangle {
// Keys reach a focused item, never the window (AGENTS.md); the card is
// the ancestor of every text field, so their Escape bubbles here.
Keys.onEscapePressed: card.canvas.back()
- // Ctrl+N and Ctrl+H do what the New and History buttons do.
+ // Ctrl+N, Ctrl+H and Ctrl+M do what New, History and the model menu do.
Keys.onPressed: event => {
if (!(event.modifiers & Qt.ControlModifier)) return;
if (event.key === Qt.Key_N && card.canvas.connected) {
card.canvas.fresh(); card.canvas.historyOpen = false; input.forceActiveFocus();
} else if (event.key === Qt.Key_H) {
card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory();
+ } else if (event.key === Qt.Key_M && card.canvas.connected) {
+ card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels();
} else return;
event.accepted = true;
}
@@ -90,13 +92,40 @@ Rectangle {
anchors { top: parent.top; left: parent.left; right: parent.right; margins: 14 }
height: 32
- Text {
+ // 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 }
- text: !card.canvas.connected ? "assistant not running"
- : card.canvas.swapping ? `loading ${card.canvas.swapping}…`
- : card.canvas.model || "assistant"
- color: card.canvas.connected ? Theme.text : Theme.red
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ 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()
}
Row {
@@ -436,7 +465,7 @@ Rectangle {
text: ""
font.family: Theme.iconFamily
onClicked: {
- card.canvas.historyOpen = false;
+ card.canvas.historyOpen = card.canvas.modelsOpen = false;
card.canvas.pickerOpen = !card.canvas.pickerOpen;
}
}
@@ -466,7 +495,7 @@ Rectangle {
id: box
anchors {
bottom: parent.bottom; left: mic.right; leftMargin: 8
- right: history.left; rightMargin: 8
+ right: modelBtn.left; rightMargin: 8
}
// Grows with the text up to six lines, then scrolls.
height: Math.min(input.contentHeight, 6 * input.font.pixelSize * 1.4) + 16
@@ -524,6 +553,20 @@ Rectangle {
}
}
+ // 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()
+ }
+
// History from every state; in chat the header has it too.
Button {
id: history
@@ -571,5 +614,10 @@ Rectangle {
visible: card.canvas.pickerOpen
canvas: card.canvas
}
+ Models {
+ anchors.fill: parent
+ visible: card.canvas.modelsOpen
+ canvas: card.canvas
+ }
}
}
diff --git a/desktop/assistant/Models.qml b/desktop/assistant/Models.qml
new file mode 100644
index 0000000..b1b2a38
--- /dev/null
+++ b/desktop/assistant/Models.qml
@@ -0,0 +1,87 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import ".."
+
+// The 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 }
+ }
+ }
+}
diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md
index 58fbb14..4c76a66 100644
--- a/desktop/assistant/README.md
+++ b/desktop/assistant/README.md
@@ -18,6 +18,7 @@ what is typed. The protocol is specified in that repo,
| `Segments.qml` | markdown with a copy button per code block |
| `History.qml` | conversation list and full-text search |
| `Picker.qml` | the in-card file picker behind the paperclip |
+| `Models.qml` | the model panel: local and cloud models, the chat's pick |
| `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 |
@@ -38,9 +39,16 @@ model transcribe it and answers the words. The mic is greyed out while the
model in use reports no `audio` input (`AssistantModule.hears`, from the
router's `/models`, read on every open).
+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.
+
Keys: Enter sends, Shift+Enter is a newline, Escape cancels a recording,
then closes the picker, the history panel, then the card. Ctrl+N starts a
-new chat, Ctrl+H toggles the history panel. Stop replaces
+new chat, Ctrl+H toggles the history panel, Ctrl+M the model panel. Stop replaces
Send while a reply streams.
Notes:
diff --git a/desktop/companion/Admired.qml b/desktop/companion/Admired.qml
new file mode 100644
index 0000000..eee76d1
--- /dev/null
+++ b/desktop/companion/Admired.qml
@@ -0,0 +1,101 @@
+// 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 the process below, 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"); }
+ }
+}
diff --git a/desktop/companion/Companion.qml b/desktop/companion/Companion.qml
new file mode 100644
index 0000000..aa0505d
--- /dev/null
+++ b/desktop/companion/Companion.qml
@@ -0,0 +1,551 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import Quickshell.Bluetooth
+import Quickshell.Hyprland
+import QtQuick
+import ".."
+import "../modules/sound"
+import "../NotifyConfig.js" as NC
+import "Mood.js" as Mood
+import "Lines.js" as Lines
+
+// The desktop companion: a chibi 2B in the bottom-right corner of the
+// focused monitor. Inputs and timers live here, the decision in Mood.js,
+// the drawing in Figure.qml. See README.md here and
+// docs/superpowers/specs/2026-10-23-companion-design.md.
+PanelWindow {
+ id: root
+
+ required property var asst
+
+ readonly property string art: Quickshell.env("HOME") + "/.local/share/keystone/companion/2b/frames"
+
+ // SUPER+\ (the IPC below) and the Companion settings page, through Prefs,
+ // so it survives a restart.
+ readonly property bool present: Prefs.ready && Prefs.companion
+ // A hide plays a goodbye first: the wave frames, then the drop.
+ // Re-showing her cancels it and greets as usual.
+ property bool leaving: false
+ property int waveIndex: 0
+ readonly property var waveCycle: ["wave", "wave-l", "wave", "wave-r"]
+ onPresentChanged: {
+ if (root.present) {
+ root.leaving = false;
+ root.destructAt = 0; // the skirt is back
+ root.now = Date.now();
+ greet.restart();
+ } else {
+ root.stopMonologue();
+ // Nothing to wave at when she is already out of view.
+ if (root.fullscreen !== 2 && !root.hopping) {
+ root.leaving = true;
+ root.waveIndex = 0;
+ root.now = Date.now();
+ // A line one hide in four, like the monologue's roll, and not
+ // forced, so the usual 30 s line cooldown still applies. A
+ // silent wave is only as long as it takes to read nothing.
+ const spoke = Math.random() < 0.25 && root.say("bye");
+ goodbyeTimer.interval = spoke ? Mood.T.goodbye : Mood.T.goodbyeSilent;
+ goodbyeTimer.start();
+ }
+ }
+ }
+
+ // The monitor she is on trails the focused one: a change plays her exit
+ // here first, and only then moves the window (settled, below).
+ readonly property string focused: Hyprland.focusedMonitor?.name ?? ""
+ property string here: ""
+ property bool hopping: false
+ // No focused monitor (outputs gone over a suspend) is not a destination.
+ // Already out of view, there is no exit to wait for: settled would never
+ // fire, hopping would stick and hide her for good, so move at once.
+ onFocusedChanged: {
+ if (root.focused === "" || root.focused === root.here) return;
+ if (root.here === "" || root.away) root.here = root.focused;
+ else root.hopping = true;
+ }
+ Component.onCompleted: root.here = root.focused
+
+ screen: Quickshell.screens.find(s => s.name === root.here) ?? Quickshell.screens[0]
+ anchors { right: true; bottom: true }
+ margins { right: 24; bottom: 16 }
+ implicitWidth: 760
+ implicitHeight: 260
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ // Overlay, so she can sit above the other overlays (see remap below).
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "keystone-companion"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ // Her body only: clicks anywhere else, the bubble included, reach the
+ // window below. Out of view the region is empty.
+ mask: Region { item: hit }
+
+ // --- inputs ---------------------------------------------------------
+
+ property double now: Date.now()
+ Timer { interval: 200; repeat: true; running: root.present; onTriggered: root.now = Date.now() }
+
+ property var clicks: ({ count: 0, at: 0 })
+ property double destructAt: 0
+ property bool dodging: false
+ property double repliedAt: 0
+ property double mockAt: 0
+ property double typingAt: 0
+ property double pulseAt: 0
+ property double wakeAt: 0
+ property double admireAt: 0
+ property double singAt: 0
+ property double djAt: 0
+ property string groove: ""
+ property double lastLineAt: 0
+ property double lastMonologueAt: 0
+ property bool monologue: false
+ property string appClass: ""
+
+ // Bored, yawning, lying down at 20, 40 and 70% of the wscreensavers idle
+ // timeout, so she has lain down a while before the saver covers her (1,
+ // 2 and 3.5 minutes at its 5). Read live, so a change in its settings
+ // follows; 5 minutes when the file is missing or unreadable.
+ // Inhibitors are ignored, as wscreensavers ignores them: No lock holds
+ // one for as long as it is on, which kept her awake for hours.
+ // Presentation (and gaming) mode keeps her awake instead, by name.
+ // enabled is set explicitly: left out, it read false on creation.
+ FileView {
+ id: saverConf
+ path: Quickshell.env("HOME") + "/.config/wscreensavers/config.json"
+ watchChanges: true
+ onFileChanged: reload()
+ printErrors: false
+ }
+ readonly property int saverIdle: {
+ try {
+ const m = JSON.parse(saverConf.text()).idleTimeoutMinutes;
+ return m > 0 ? m * 60 : 300;
+ } catch (e) {
+ return 300;
+ }
+ }
+ IdleMonitor { id: idle1; enabled: true; respectInhibitors: false; timeout: Math.round(root.saverIdle * 0.2) }
+ IdleMonitor { id: idle2; enabled: true; respectInhibitors: false; timeout: Math.round(root.saverIdle * 0.4) }
+ IdleMonitor { id: idle3; enabled: true; respectInhibitors: false; timeout: Math.round(root.saverIdle * 0.7) }
+ readonly property int idle: Status.presentation ? 0 : idle3.isIdle ? 3 : idle2.isIdle ? 2 : idle1.isIdle ? 1 : 0
+ property int wasIdle: 0
+ onIdleChanged: {
+ const t = Date.now();
+ if (root.idle === 0 && root.wasIdle > 0) {
+ root.wakeAt = t;
+ root.say("wake");
+ } 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");
+ }
+ root.wasIdle = root.idle;
+ }
+
+ // 2 is real fullscreen; 1 is maximised, which is most windows here and
+ // must not hide her. lastIpcObject is only current after a refresh, and
+ // the island measured that it settles a moment after the event.
+ property int fullscreen: 0
+ Timer {
+ id: fsSettle
+ interval: 60
+ onTriggered: root.fullscreen = Hyprland.activeToplevel?.lastIpcObject?.fullscreen ?? 0
+ }
+
+ Connections {
+ target: Hyprland
+ function onRawEvent(event) {
+ if (event.name === "custom") {
+ // From hypr-theme's sections/companion.lua.
+ const what = event.data.trim();
+ const t = Date.now();
+ if (what === "keystone-typing") {
+ // Pulses come at most once a second, so a lone key (a
+ // shortcut, an arrow) is one pulse and typing is a run
+ // of them: she watches from the second within 2.5 s.
+ if (t - root.pulseAt < 2500) root.typingAt = t;
+ root.pulseAt = t;
+ } else if (what === "keystone-delete") {
+ root.typingAt = t;
+ if (Mood.mockAllowed(root.mockAt, t)) {
+ root.mockAt = t;
+ root.say("mock");
+ }
+ }
+ } else if (event.name === "openlayer" && event.data.trim() !== "keystone-companion") {
+ // Something mapped above her: the assistant's dim, rofi,
+ // the keybind reminder. Map again, after it.
+ root.remapping = true;
+ remap.restart();
+ } else if (event.name === "activewindow") {
+ // "class,title". A remark now and then when focus moves to a
+ // different program, under the usual 30 s line cooldown.
+ // ponytail: one line per class, no frames of her own; add
+ // per-app frames or title matching (a site in firefox) if
+ // lines alone feel thin.
+ const cls = event.data.split(",")[0].trim().toLowerCase();
+ if (cls !== "" && cls !== root.appClass && Math.random() < 1 / 3) root.say("app:" + cls);
+ root.appClass = cls;
+ } else if (["fullscreen", "activewindowv2", "workspacev2", "closewindow"].includes(event.name)) {
+ Hyprland.refreshToplevels();
+ fsSettle.restart();
+ admireSettle.restart();
+ }
+ }
+ }
+
+ Connections {
+ target: root.asst
+ function onReplied() {
+ root.repliedAt = Date.now();
+ root.say("ready");
+ }
+ }
+
+ // Sony headphones connecting, by the island's rule (island/StatusPill.qml):
+ // recognised by their model prefix, since BlueZ gives no class that says
+ // "headphones". A connection makes her sing for Mood.T.singHold. Bluetooth
+ // reads empty for about 2 s after launch, so a shell start with them
+ // already on is not a connection: btReady waits past that.
+ readonly property bool phonesConnected: (Bluetooth.devices?.values ?? [])
+ .some(d => d.connected && /^(WH|WF)-|^LinkBuds/.test(d.deviceName))
+ property bool btReady: false
+ // shortcut: a genuine connection in the first 4 s after a shell start is
+ // missed along with the startup populate; tighten if that bites.
+ Timer { interval: 4000; running: true; onTriggered: root.btReady = true }
+ onPhonesConnectedChanged: {
+ if (!root.phonesConnected || !root.btReady) return;
+ root.singAt = Date.now();
+ root.now = root.singAt;
+ root.say("headphones");
+ }
+
+ // 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)];
+ }
+
+ // 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") }
+
+ // Hyprland stacks a later surface above an earlier one on the same layer
+ // and 0.56.2 has no layer rule to order them, so the assistant canvas's
+ // full-screen dim covered her and her think and wave were never seen.
+ // Whenever another layer surface opens (openlayer, below), she unmaps
+ // and maps again, after it. Unmapped she counts as away, so her figure
+ // drops out unseen and plays its entrance once she is back: a vanish
+ // and a hop in, rather than a flicker.
+ property bool remapping: false
+ visible: !root.remapping
+ Timer { id: remap; interval: 400; onTriggered: root.remapping = false }
+
+ // Balloons stack bottom-right of their screen. When that is her corner,
+ // any balloon on screen shoves her left past the stack.
+ readonly property bool shoved: {
+ const place = Notify.config.balloons;
+ if (place.screen !== root.here || place.corner !== "bottom-right") return false;
+ return (Notify.queue || []).some(n => NC.shown(Notify.config, n, Notify.dnd,
+ Notify.drawerOpen, Notify.snoozeUntil, root.now));
+ }
+ property double shovedAt: 0
+ onShovedChanged: {
+ root.shovedAt = root.shoved ? Date.now() : 0;
+ if (root.shoved) root.say("shoved");
+ }
+
+ // --- state ----------------------------------------------------------
+
+ readonly property var mood: Mood.pick({
+ leaving: root.leaving,
+ now: root.now, destructAt: root.destructAt, dodging: root.dodging, shoved: root.shoved,
+ shovedAt: root.shovedAt,
+ recording: root.asst.recording, busy: root.asst.busy, repliedAt: root.repliedAt,
+ clicks: root.clicks, admireAt: root.admireAt, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt,
+ singAt: root.singAt, djAt: root.djAt, music: root.music, groove: root.groove,
+ monologue: root.monologue, idle: root.idle,
+ })
+ readonly property string outfit: Mood.outfit(root.destructAt, root.now, Prefs.companionSkirtless * 1000)
+ // Anything above the monologue cuts it short.
+ onMoodChanged: if (root.monologue && root.mood.state !== "muse") root.stopMonologue()
+
+ // Hidden: no sprite, no bubble. Away adds the dodge, which hides the
+ // sprite but lets its line show. While waving goodbye she is still on
+ // screen, so a hidden present does not hide her until the wave is done.
+ readonly property bool hidden: (!root.present && !root.leaving) || root.fullscreen === 2 || root.hopping
+ readonly property bool away: root.hidden || root.dodging || root.remapping
+
+ // --- speech ---------------------------------------------------------
+
+ function say(kind, force) {
+ const t = Date.now();
+ if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return false;
+ const line = Lines.pick(kind, Math.random(), Prefs.companionApps, Prefs.companionArtists);
+ if (line === "") return false;
+ root.lastLineAt = t;
+ bubble.show(line, 3000);
+ return true;
+ }
+
+ // The opening monologue: about 15 characters a second, one sentence
+ // per bubble, 1.5 s between sentences, the last held 4 s.
+ property int monoLine: 0
+ function startMonologue() {
+ if (root.hidden) return;
+ root.monologue = true;
+ root.lastMonologueAt = Date.now();
+ root.lastLineAt = root.lastMonologueAt;
+ root.monoLine = 0;
+ typer.begin(Lines.monologue[0]);
+ }
+ function stopMonologue() {
+ root.monologue = false;
+ typer.stop();
+ typerHold.stop();
+ bubble.hide();
+ }
+ Timer {
+ id: typer
+ property string full: ""
+ interval: 66
+ repeat: true
+ function begin(text) { typer.full = text; bubble.typed(""); typer.restart(); }
+ onTriggered: {
+ const n = bubble.text.length + 1;
+ bubble.typed(typer.full.slice(0, n));
+ if (n < typer.full.length) return;
+ typer.stop();
+ typerHold.interval = root.monoLine === Lines.monologue.length - 1 ? 4000 : 1500;
+ typerHold.restart();
+ }
+ }
+ Timer {
+ id: typerHold
+ onTriggered: {
+ root.monoLine++;
+ if (root.monoLine >= Lines.monologue.length) root.stopMonologue();
+ else typer.begin(Lines.monologue[root.monoLine]);
+ }
+ }
+
+ // --- clicks and the dodge -------------------------------------------
+
+ function clicked() {
+ const t = Date.now();
+ root.now = t;
+ root.clicks = Mood.click(root.clicks, t);
+ if (root.clicks.count >= 7) {
+ root.clicks = { count: 0, at: 0 };
+ root.stopMonologue();
+ root.destructAt = t;
+ sprite.explode();
+ root.say("destruct", true);
+ aftermath.restart();
+ } else if (root.clicks.count === 1) {
+ root.say("click", true);
+ } else if (root.clicks.count === 4) {
+ root.say("blush", true);
+ }
+ }
+ Timer { id: aftermath; interval: Mood.T.destruct + 400; onTriggered: root.say("aftermath", true) }
+
+ // She cannot see the pointer coming, only feel it on her: Quickshell has
+ // no global pointer and Hyprland sends no motion events. A click before
+ // the 0.6 s still reaches her.
+ Timer {
+ id: hover
+ interval: 600
+ onTriggered: {
+ if (Math.random() < 0.5) root.say("dodge");
+ root.dodging = true;
+ dodgeBack.restart();
+ }
+ }
+ Timer { id: dodgeBack; interval: 5000; onTriggered: root.dodging = false }
+
+ // --- showing and hiding ----------------------------------------------
+
+ // After the drop-in has landed, on every appearance, the one at login
+ // included.
+ Timer {
+ id: greet
+ interval: 450
+ onTriggered: {
+ if (Mood.monologue("shown", root.lastMonologueAt, Date.now(), Math.random())) root.startMonologue();
+ else root.say("shown", true);
+ }
+ }
+
+ // The goodbye: she waves, then leaves. With a line she holds long enough
+ // to read it. The hand sways side to side while it lasts.
+ Timer { id: goodbyeTimer; onTriggered: root.leaving = false }
+ Timer {
+ id: waveFlip
+ interval: 250
+ repeat: true
+ running: root.leaving
+ onTriggered: root.waveIndex = (root.waveIndex + 1) % root.waveCycle.length
+ }
+
+ // 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
+
+ // qs -p <desktop> ipc call companion toggle -> SUPER+\
+ IpcHandler {
+ target: "companion"
+ function toggle() { Prefs.setCompanion(!Prefs.companion); }
+ function show() { Prefs.setCompanion(true); }
+ function hide() { Prefs.setCompanion(false); }
+ }
+
+ // The exit has played on the old monitor: move, and the entrance plays.
+ function settled(out) {
+ if (out && root.hopping) {
+ root.here = root.focused;
+ root.hopping = false;
+ }
+ }
+
+ // --- drawing --------------------------------------------------------
+
+ // Where she stands: the bottom-right of the window, or left of the
+ // balloon stack. 336 = the stack's 340 px plus its 12 px margin
+ // (notifications/Balloons.qml), minus this window's 24 px right margin,
+ // plus 8 px of air.
+ Item {
+ id: seat
+ width: sprite.width
+ height: sprite.height
+ anchors.bottom: parent.bottom
+ x: root.width - width - (root.shoved ? 336 : 0)
+ Behavior on x { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
+ }
+
+ Figure {
+ id: sprite
+ x: seat.x
+ y: seat.y
+ source: `file://${root.art}/${root.outfit}/${root.frame}.png`
+ fallback: `file://${root.art}/${root.outfit}/base.png`
+ out: root.away
+ trembling: root.mood.state === "admire" && root.mood.frame === "blush3"
+ onSettled: out => root.settled(out)
+ }
+
+ Item {
+ id: hit
+ x: sprite.x
+ y: sprite.y
+ width: root.away ? 0 : sprite.width
+ height: root.away ? 0 : sprite.height
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: hover.restart()
+ onExited: hover.stop()
+ onClicked: { hover.stop(); root.clicked(); }
+ }
+ }
+
+ // Up and to the left of her head, anchored to where she stands rather
+ // than to the sprite, so a dodge line stays where she was.
+ Rectangle {
+ id: bubble
+ property alias text: label.text
+ function show(t, ms) { label.text = t; bubble.opacity = 1; hideTimer.interval = ms; hideTimer.restart(); }
+ function typed(t) { label.text = t; bubble.opacity = 1; hideTimer.stop(); }
+ function hide() { hideTimer.stop(); bubble.opacity = 0; }
+
+ visible: !root.hidden && opacity > 0 && label.text !== ""
+ anchors { right: seat.left; rightMargin: -14; bottom: seat.top; bottomMargin: -22 }
+ width: Math.min(260, label.implicitWidth + 24)
+ height: label.implicitHeight + 16
+ radius: 12
+ color: Qt.alpha(Theme.base, 0.92)
+ border { width: 1; color: Qt.alpha(Theme.text, 0.25) }
+ opacity: 0
+ Behavior on opacity { NumberAnimation { duration: 180 } }
+
+ Timer { id: hideTimer; onTriggered: bubble.opacity = 0 }
+
+ Text {
+ id: label
+ anchors.centerIn: parent
+ width: Math.min(236, implicitWidth)
+ wrapMode: Text.WordWrap
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: 13 }
+ }
+ Rectangle {
+ width: 10; height: 10; rotation: 45
+ color: bubble.color
+ anchors { right: parent.right; rightMargin: 18; bottom: parent.bottom; bottomMargin: -5 }
+ }
+ }
+}
diff --git a/desktop/companion/Figure.qml b/desktop/companion/Figure.qml
new file mode 100644
index 0000000..3401168
--- /dev/null
+++ b/desktop/companion/Figure.qml
@@ -0,0 +1,159 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import ".."
+
+// One frame of the companion, drawn at half its pixel size and standing on
+// its bottom edge: a crossfade between frames, a slow bob, a tremble, the
+// drop out of view and back, and the self-destruct flash. Which frame is Mood.js's
+// business; this only draws it.
+Item {
+ id: s
+
+ // The frame, and what to show when it is missing: the same outfit's
+ // base, never the other outfit's, so a gap in the art cannot put the
+ // skirt back mid-scene.
+ property string source
+ property string fallback
+ // Dropped below the bottom edge and faded out.
+ property bool out: false
+ signal settled(bool out)
+
+ // Two images: the front one stays until the back one has loaded, then
+ // they swap, so a frame change never flashes empty.
+ property bool flip: false
+ readonly property Image front: s.flip ? b : a
+ width: s.front.implicitWidth / 2
+ height: s.front.implicitHeight / 2
+
+ function load(url) {
+ const front = s.flip ? b : a, back = s.flip ? a : b;
+ if (String(front.source) === url) { back.source = ""; return; }
+ if (String(back.source) === url && back.status === Image.Ready) { s.flip = !s.flip; return; }
+ back.source = url;
+ }
+ onSourceChanged: s.load(s.source)
+ Component.onCompleted: s.load(s.source)
+
+ function ready(img) {
+ if (img.status === Image.Error && String(img.source) !== s.fallback) {
+ img.source = s.fallback;
+ return;
+ }
+ if (img.status === Image.Ready && img !== s.front) s.flip = !s.flip;
+ }
+
+ property real drop: 0
+ property real bob: 0
+ NumberAnimation {
+ id: dropAnim
+ target: s; property: "drop"; duration: 320
+ onFinished: s.settled(s.out)
+ }
+ onOutChanged: {
+ dropAnim.stop();
+ dropAnim.to = s.out ? s.height + 24 : 0;
+ dropAnim.easing.type = s.out ? Easing.InCubic : Easing.OutBack;
+ dropAnim.start();
+ }
+ // A quick side to side shiver, for the wallpaper blush. Stopping can
+ // leave it mid-swing, so it is put back to rest.
+ property bool trembling: false
+ property real shake: 0
+ onTremblingChanged: if (!s.trembling) s.shake = 0
+ SequentialAnimation on shake {
+ running: s.trembling && !s.out
+ loops: Animation.Infinite
+ NumberAnimation { to: 1.5; duration: 40 }
+ NumberAnimation { to: -1.5; duration: 80 }
+ NumberAnimation { to: 0; duration: 40 }
+ }
+ SequentialAnimation on bob {
+ running: !s.out
+ loops: Animation.Infinite
+ NumberAnimation { to: 2; duration: 1900; easing.type: Easing.InOutSine }
+ NumberAnimation { to: 0; duration: 2300; easing.type: Easing.InOutSine }
+ }
+
+ Item {
+ anchors.fill: parent
+ opacity: 1 - s.drop / (s.height + 24)
+ transform: Translate { x: s.shake; y: s.drop - s.bob }
+
+ Image {
+ id: a
+ width: implicitWidth / 2; height: implicitHeight / 2
+ anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter }
+ smooth: true; mipmap: true; asynchronous: true
+ opacity: s.flip ? 0 : 1
+ Behavior on opacity { NumberAnimation { duration: 120 } }
+ onStatusChanged: s.ready(a)
+ }
+ Image {
+ id: b
+ width: implicitWidth / 2; height: implicitHeight / 2
+ anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter }
+ smooth: true; mipmap: true; asynchronous: true
+ opacity: s.flip ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 120 } }
+ onStatusChanged: s.ready(b)
+ }
+ }
+
+ // --- self-destruct: the glow swells into a flash, then a ring and
+ // sparks burst out. About 1.1 s, matching Mood.T.destruct, during which
+ // the frame swaps to the leotard under the flash.
+
+ function explode() { boom.restart(); }
+
+ property real burst: 0
+ Rectangle {
+ id: flash
+ anchors.centerIn: parent
+ width: s.height * 1.6; height: width; radius: width / 2
+ color: "white"; opacity: 0; scale: 0
+ }
+ Rectangle {
+ id: ring
+ anchors.centerIn: parent
+ width: s.height * 2; height: width; radius: width / 2
+ color: "transparent"
+ border { width: 3; color: Theme.peach }
+ opacity: 0; scale: 0
+ }
+ Repeater {
+ model: 8
+ Rectangle {
+ required property int index
+ readonly property real angle: index * Math.PI / 4
+ width: 6; height: 6; radius: 3
+ color: index % 2 ? Theme.peach : Theme.yellow
+ x: s.width / 2 - 3 + Math.cos(angle) * s.burst * s.height
+ y: s.height / 2 - 3 + Math.sin(angle) * s.burst * s.height
+ opacity: s.burst > 0 && s.burst < 1 ? 1 - s.burst : 0
+ }
+ }
+ SequentialAnimation {
+ id: boom
+ ParallelAnimation {
+ NumberAnimation { target: flash; property: "scale"; from: 0; to: 1; duration: 600; easing.type: Easing.InQuad }
+ NumberAnimation { target: flash; property: "opacity"; from: 0; to: 0.95; duration: 600 }
+ }
+ ParallelAnimation {
+ NumberAnimation { target: flash; property: "opacity"; to: 0; duration: 400 }
+ NumberAnimation { target: ring; property: "scale"; from: 0.2; to: 1.4; duration: 400; easing.type: Easing.OutCubic }
+ NumberAnimation { target: ring; property: "opacity"; from: 1; to: 0; duration: 400 }
+ NumberAnimation { target: s; property: "burst"; from: 0; to: 1; duration: 500; easing.type: Easing.OutCubic }
+ }
+ PropertyAction { target: s; property: "burst"; value: 0 }
+ }
+}
diff --git a/desktop/companion/Lines.js b/desktop/companion/Lines.js
new file mode 100644
index 0000000..77e7a83
--- /dev/null
+++ b/desktop/companion/Lines.js
@@ -0,0 +1,146 @@
+// 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
+
+// What she says, in English, grouped by the reaction that says it.
+// Companion.qml picks one at random per reaction and keeps to one line in
+// 30 s, except for clicks and the self-destruct (Mood.canSpeak).
+
+var lines = {
+ shown: ["Glory to mankind.", "2B, reporting.", "...Back on duty."],
+ bye: ["2B, signing off.", "Rest while you can.", "Duty complete."],
+ bored: ["...Operator, I'm bored.", "Nothing to report.", "Awaiting orders."],
+ wake: ["I'm awake.", "Systems online."],
+ mock: ["Typo again?", "Delete, delete, delete.", "Were you aiming for that key?"],
+ shoved: ["Hey.", "Rude.", "I was standing here."],
+ ready: ["Answer's ready.", "Your reply came in."],
+ headphones: ["...Good song.", "Music. Finally.", "I'll allow this one.", "Louder."],
+ groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."],
+ click: ["...Yes?", "Can I help you?"],
+ blush: ["S-stop that.", "...Operator."],
+ wallpaper: ["...!", "W-what is this?", "I wasn't staring."],
+ dodge: ["...Fine.", "I'll move."],
+ destruct: ["Self-destruct sequence initiated."],
+ aftermath: ["...That was unpleasant."],
+};
+
+// The lines for a program she recognizes, by lowercased window class: one is
+// said at random when focus moves to it. The settings window's Companion
+// phrases page edits them into Prefs.companionApps, which then replaces this
+// whole set; a class with no entry says nothing.
+var apps = {
+ firefox: ["Researching again?", "Don't open too many tabs.", "The Bunker's network is faster."],
+ kitty: ["A terminal. Serious work.", "Careful with that rm.", "I'll watch your commands."],
+ discord: ["Talking to the Resistance?", "Don't tell them where we are."],
+ signal: ["Encrypted. Good."],
+ steam: ["Training simulation?", "Don't play all night."],
+ gimp: ["Making art?"],
+ "com.obsproject.studio": ["Are we recording?", "I'll stay out of frame."],
+ "md.obsidian.obsidian": ["Writing a report?", "Taking notes. Good habit."],
+ sublime_text: ["Writing code?", "Mind the typos."],
+ qtmaildir: ["Any orders from Command?", "Mail. Anything urgent?"],
+ "virt-manager": ["More machines?", "Androids running androids."],
+ audacious: ["Music. I like this."],
+ llamachat: ["Talking to Pod?"],
+ qtpass: ["I'm not looking."],
+};
+
+// 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."],
+};
+
+// The game's opening, one sentence per bubble.
+var monologue = [
+ "Everything that lives is designed to end.",
+ "We are perpetually trapped in a never-ending spiral of life and death.",
+ "Is this a curse?",
+ "Or some kind of punishment?",
+ "I often think about the god who blessed us with this cryptic puzzle...and wonder if we'll ever get the chance to kill him.",
+];
+
+// 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;
+}
+
+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);
+}
+
+// The page's list back to a map for Prefs: empty classes and phrases dropped,
+// the class lowercased (Hyprland reports it that way), a repeated class
+// keeping its last entry.
+function toMap(list) {
+ const m = {};
+ for (let i = 0; i < list.length; i++) {
+ const cls = String(list[i].cls || "").trim().toLowerCase();
+ if (cls === "") continue;
+ const src = list[i].lines || [];
+ const ls = [];
+ for (let j = 0; j < src.length; j++) {
+ const s = String(src[j] || "").trim();
+ if (s !== "") ls.push(s);
+ }
+ if (ls.length) m[cls] = ls;
+ }
+ return m;
+}
diff --git a/desktop/companion/ListFile.js b/desktop/companion/ListFile.js
new file mode 100644
index 0000000..d074c80
--- /dev/null
+++ b/desktop/companion/ListFile.js
@@ -0,0 +1,44 @@
+// 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;
+}
diff --git a/desktop/companion/Mood.js b/desktop/companion/Mood.js
new file mode 100644
index 0000000..b59c1ab
--- /dev/null
+++ b/desktop/companion/Mood.js
@@ -0,0 +1,114 @@
+// 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
+
+// Which state the companion is in, from a snapshot of her inputs. Pure, so
+// test_mood.js drives it with plain objects and a fake clock. Companion.qml
+// owns the inputs and the timers; this only decides. Times in milliseconds.
+
+var T = {
+ burstGap: 1500, // a click burst ends after this long without a click
+ clickHold: 2000, // a click reaction lasts this long after the last click
+ mockHold: 2500,
+ shoveHold: 500, // knocked off balance, before she turns mad at the balloon
+ mockCooldown: 20000,
+ typingHold: 2000,
+ waveHold: 3000,
+ singHold: 4000, // her singing reaction to the headphones connecting
+ djHold: 6000, // behind the decks when a track starts or changes
+ wakeHold: 1500, // the stretch after idling
+ lineCooldown: 30000,
+ monologueCooldown: 3600000,
+ destruct: 1000, // the glow and the flash; the sprite is hidden under it
+ scorched: 3000,
+ goodbye: 2000, // waving goodbye with a line, enough to read it
+ goodbyeSilent: 1000, // waving goodbye with nothing to read
+ admireLook: 1500, // turned to the wallpaper, before the blush
+ admireBlush: 3000,
+ admireCooldown: 180000,
+};
+
+function st(state, frame) { return { state: state, frame: frame }; }
+
+// The burst after a click at now.
+function click(prev, now) {
+ if (prev.count > 0 && now - prev.at <= T.burstGap) return { count: prev.count + 1, at: now };
+ return { count: 1, at: now };
+}
+
+// Click 1 smiles; 2 to 6 blush harder, in three steps. The 7th is the
+// self-destruct, which Companion.qml starts rather than shows.
+function clickFrame(count) {
+ if (count <= 1) return "smile";
+ if (count <= 3) return "blush1";
+ if (count <= 5) return "blush2";
+ return "blush3";
+}
+
+// The spec's priority table, top to bottom: the first that applies wins.
+// idle is 0 awake, 1 bored, 2 yawning, 3 lying down.
+function pick(s) {
+ // The user hid her: the goodbye outranks whatever she was doing. The
+ // frame here is a placeholder; Companion.qml cycles the wave frames.
+ if (s.leaving) return st("bye", "wave");
+ const since = s.destructAt ? s.now - s.destructAt : Infinity;
+ if (since < T.destruct) return st("destruct", "base");
+ if (since < T.destruct + T.scorched) return st("scorched", "scorched");
+ if (s.dodging) return st("dodge", "base");
+ // shovedAt 0 is the instant before Companion.qml stamps it.
+ if (s.shoved) return st("shoved", s.shovedAt && s.now - s.shovedAt >= T.shoveHold ? "mad" : "shoved");
+ if (s.recording) return st("listen", "listen");
+ if (s.busy) return st("think", "think");
+ if (s.now - s.repliedAt < T.waveHold) return st("wave", "wave");
+ if (s.now - s.singAt < T.singHold) return st("sing", "sing");
+ if (s.now - s.djAt < T.djHold) return st("dj", "dj");
+ if (s.clicks.count > 0 && s.now - s.clicks.at < T.clickHold)
+ return st("clicks", clickFrame(s.clicks.count));
+ if (s.now - s.admireAt < T.admireLook + T.admireBlush)
+ return st("admire", s.now - s.admireAt < T.admireLook ? "watch" : "blush3");
+ if (s.now - s.mockAt < T.mockHold)
+ return st("mock", s.now - s.mockAt < T.mockHold / 2 ? "point" : "smirk");
+ if (s.now - s.typingAt < T.typingHold) return st("watch", "watch");
+ if (s.now - s.wakeAt < T.wakeHold) return st("stretch", "stretch");
+ if (s.monologue) return st("muse", "muse");
+ if (s.idle >= 3) return st("lying", "lying");
+ if (s.idle === 2) return st("yawn", "yawn");
+ // 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");
+ return st("base", "base");
+}
+
+// The self-destruct takes the skirt off once the flash is over, for the
+// scorched moment and skirtless ms after it (Prefs.companionSkirtless),
+// or with 0 until she is shown again, which sets destructAt back to 0 and
+// also restores it early.
+function outfit(destructAt, now, skirtless) {
+ if (!destructAt) return "dress";
+ const since = now - destructAt;
+ if (since < T.destruct) return "dress";
+ return skirtless === 0 || since < T.destruct + T.scorched + skirtless ? "leotard" : "dress";
+}
+
+function mockAllowed(lastMockAt, now) { return now - lastMockAt >= T.mockCooldown; }
+
+function admireAllowed(lastAt, now) { return now - lastAt >= T.admireCooldown; }
+
+function canSpeak(lastLineAt, now) { return now - lastLineAt >= T.lineCooldown; }
+
+// Whether the opening monologue plays: a chance per trigger, and never
+// twice within the hour. roll is Math.random(), passed in for the test.
+function monologue(trigger, lastAt, now, roll) {
+ if (now - lastAt < T.monologueCooldown) return false;
+ const chance = trigger === "bored" ? 1 / 4 : trigger === "shown" ? 1 / 3 : 0;
+ return roll < chance;
+}
diff --git a/desktop/companion/README.md b/desktop/companion/README.md
new file mode 100644
index 0000000..4fbe887
--- /dev/null
+++ b/desktop/companion/README.md
@@ -0,0 +1,110 @@
+# companion
+
+A chibi 2B in the bottom-right corner of the focused monitor, on SUPER+\.
+The design, with what was measured on the way, is
+`docs/superpowers/specs/2026-10-23-companion-design.md`.
+
+## Files
+
+| File | |
+| --- | --- |
+| `Companion.qml` | the window, inputs, timers, speech, IPC (`companion toggle/show/hide`) |
+| `Figure.qml` | draws a frame: crossfade, bob, drop out and back, explosion |
+| `Mood.js` | which state wins, from a snapshot; all the timings are its `T` |
+| `Lines.js` | her reaction, per-app and per-artist lines, and the opening monologue |
+| `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 |
+| `prep-frames` | generated images to display frames |
+| `gen-frames`, `frames.txt` | the generated images, through sd-server |
+
+`Figure.qml` is not `Sprite.qml` because QtQuick has a `Sprite` type, and
+it wins over the directory's own file (see AGENTS.md).
+
+Whether she is shown is `companion` in `Prefs.qml`, so it survives a
+restart: SUPER+\ and the switch on Settings > Companion
+(`desktop/modules/companion/`) both set it.
+
+A hide by SUPER+\ or that switch plays a goodbye first: she waves (`wave`,
+`wave-l` and `wave-r` cycling) and then drops out. One hide in four she also
+says a `bye` line, under the usual 30 s line cooldown; with a line she holds
+`Mood.T.goodbye` (2 s, enough to read it), silently only
+`Mood.T.goodbyeSilent` (0.5 s). Fullscreen, the lock screen and the monitor
+hop keep their own exits and do not wave.
+
+## Art
+
+Not in the repo: the character is Square Enix's and this repo is GPLv2.
+It lives in `~/.local/share/keystone/companion/2b/`:
+
+ dress/<name>.png, leotard/<name>.png generated, 1024x1024 on white
+ frames/<outfit>/<name>.png what she shows, from prep-frames
+ recipe.txt how each was made, and what failed
+
+Run `prep-frames` after adding or replacing any generated image. A missing
+frame falls back to its outfit's `base`.
+
+## Inputs
+
+- Typing and BackSpace come from `sections/companion.lua` in the
+ hypr-theme repo, as `custom>>keystone-typing` and
+ `custom>>keystone-delete` on socket2. Nothing else about the keyboard
+ reaches her. The BackSpace bind is `non_consuming`; checked by hand that
+ it still deletes in a terminal and in the browser.
+- The typing pulse skips modifiers and any key pressed with Ctrl, Alt or
+ Super held, so a shortcut is not typing; Shift and AltGr still count.
+ She watches only from the second pulse within 2.5 s, so a lone key is
+ not typing either.
+- She is on the Overlay layer and remaps whenever another layer surface
+ opens (Hyprland's `openlayer` event: the assistant's dim, rofi, the
+ keybind reminder), vanishing and hopping back in, because Hyprland
+ stacks a later surface above an earlier one on a layer, and 0.56.2 has
+ no layer rule to order them (no `order` key in the binary; an unknown
+ key raises no config error).
+- The program in front of her is Hyprland's `activewindow` event,
+ `class,title`. It also fires on every title change, so she reacts
+ only when the class changes, a third of the time, under the 30 s line
+ cooldown. Lines are by lowercased class in `Lines.js`; a class with
+ none says nothing. Settings > Companion > Companion phrases edits them
+ into `Prefs.companionApps`, which then replaces the defaults.
+- A listed wallpaper (Settings > 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`.
+- Sony headphones connecting are the island's rule (`island/StatusPill.qml`:
+ `WH-`/`WF-`/`LinkBuds` by name, since BlueZ has no headphones class): she
+ sings once for `Mood.T.singHold`, 4 s, and says a line if the usual 30 s
+ cooldown allows it. A shell start with them already on is not a connection:
+ Bluetooth reads empty for ~2 s, then populates.
+- 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; a shell start with music on DJs once. 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.
+- She only feels the pointer while it is over her: Quickshell has no
+ global pointer and Hyprland sends no motion events. Hence the 0.6 s
+ dodge rather than moving away as the pointer approaches.
+- Idle stages are 20, 40 and 70% of wscreensavers' `idleTimeoutMinutes`,
+ read live from its config, so she lies down before the saver starts.
+- `IdleMonitor.enabled` read false on creation when left out, so each of
+ the three sets it. It also reads false at `Component.onCompleted` even
+ when set, and true a moment later; do not trust that first reading.
+- The monitors ignore inhibitors. No lock holds an idle inhibitor for as
+ long as it is on, and with `respectInhibitors: true` she never idled
+ (measured: a 5 s monitor respecting them never fired, one ignoring them
+ did). Presentation and gaming mode keep her awake by name instead.
+- Fullscreen is `lastIpcObject.fullscreen === 2`. Maximised is 1, and
+ most windows here are maximised.
+- The lock screen covers every layer, so locking needs no code.
+
+## Check
+
+ node desktop/companion/test_mood.js
+ node desktop/companion/test_lines.js
diff --git a/desktop/companion/frames.txt b/desktop/companion/frames.txt
index 0019db1..5a67f40 100644
--- a/desktop/companion/frames.txt
+++ b/desktop/companion/frames.txt
@@ -34,6 +34,9 @@ EYES = Her black blindfold stays on as one solid band of cloth fully covering bo
# After the self-destruct the blindfold is gone with the skirt: the whole
# leotard set shows her eyes.
LEOTARD = Her skirt is gone, revealing a plain white leotard underneath, and her blindfold is gone too: both eyes uncovered and clearly visible, pale blue-grey, matching her expression. Everything else stays exactly the same: pose, expression, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips, the small mole under her mouth, chibi proportions, art style and white background.
+# Singing along to whatever is playing: headphones on, eyes shut. The
+# blindfold is gone, but BARE does not fit because it says the eyes show.
+SING = Her black blindfold is gone: nothing over her eyes, no blindfold, no mask; her black hairband stays. Both her eyes are closed, softly shut. Her mouth is open wide as if she is singing loudly. She wears a pair of black over-ear headphones over her head and ears. A couple of small black musical notes float in the air beside her head.
# --- expressions
dress/smile <- dress/base : Change only her expression to a small warm closed-mouth smile; ${POSE} ${EYES} ${KEEP}
@@ -46,6 +49,10 @@ dress/sleepy <- dress/base : Change only her expression to sleepy and drowsy, h
# --- poses
dress/wave <- dress/base : Raise only the hand on the left side of the image beside her head, waving, with a small smile; her other arm stays at her side. ${KEEP}
+# The goodbye wave sways side to side. Edited from wave, the centre of the
+# sway, so klein keeps the arm up and moves only the hand.
+dress/wave-l <- dress/wave : Change only the hand she holds up: bend it at the wrist toward the left side of the image, fingers pointing left, as if in the middle of a goodbye wave; keep the arm raised beside her head, her small smile, and everything else exactly the same. ${KEEP}
+dress/wave-r <- dress/wave : Change only the hand she holds up: bend it at the wrist toward the right side of the image, fingers pointing right, as if in the middle of a goodbye wave; keep the arm raised beside her head, her small smile, and everything else exactly the same. ${KEEP}
# Shown while typing: she faces into the screen, away from the viewer, as if
# reading over the user's shoulder. A profile read as posing, not watching.
dress/watch <- dress/base : Turn her completely around into a back view: we see her from behind, the back of her head with her white bob, crossed by her black hairband and the small black cloth knot of her black blindfold (black, not white, no bow), her back, the back of her dress and the backs of her legs, as if she is facing a screen and watching it; arms relaxed at her sides. Same character, black gloves, puffy black juliet sleeves, black dress with its skirt, black thighhigh boots, black hairband, chibi proportions, art style and white background.
@@ -67,6 +74,17 @@ dress/stretch <- dress/base : She stretches both arms up above her head, waking
# and face were right: refs/shoved-7.png, made from dress/base with
# --seed 7 by the earlier standing prompt (see generated.log), kept in refs/.
dress/shoved <- refs/shoved-7 : Keep her face, her scared expression, her uncovered eyes and both raised hands exactly as they are. Change her body and legs: she is in the air, lifted completely off the ground, knocked flying toward the left side of the image by a strong blast from the right; both feet off the ground, legs trailing behind her toward the right side, her body tilted, her hair and skirt blown toward the left. No ground, no blast effect drawn. ${KEEP_BARE}
+# Headphones connecting: a few seconds of her singing along.
+dress/sing <- dress/base : ${SING} ${POSE} ${KEEP_BARE}
+# 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}
# --- the leotard set: the same frames, after the self-destruct
leotard/base <- dress/base : ${LEOTARD}
@@ -80,6 +98,8 @@ leotard/blush2 <- dress/blush2 : ${LEOTARD}
leotard/blush3 <- dress/blush3 : ${LEOTARD}
leotard/sleepy <- dress/sleepy : ${LEOTARD}
leotard/wave <- dress/wave : ${LEOTARD}
+leotard/wave-l <- dress/wave-l : ${LEOTARD}
+leotard/wave-r <- dress/wave-r : ${LEOTARD}
# From dress/watch, klein kept the blindfold's band at the back of her head
# through three prompts, by name and by position; from leotard/base, which
# has no blindfold, the back view is the usual pose edit.
@@ -96,4 +116,11 @@ leotard/think <- dress/think : ${LEOTARD}
leotard/muse <- dress/muse : ${LEOTARD}
leotard/stretch <- dress/stretch : ${LEOTARD}
leotard/shoved <- dress/shoved : ${LEOTARD}
+leotard/sing <- dress/sing : Her skirt is gone, revealing a plain white leotard underneath. Change nothing else: she keeps her closed eyes, her black over-ear headphones, the musical notes beside her head, her pose, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
leotard/scorched <- leotard/base : Right after an explosion: her clothes lightly scorched and smudged with soot, a few wisps of smoke rising from her, hair slightly messy, a dazed flat expression; standing, arms at her sides. Her eyes stay uncovered, no blindfold. Her top stays black: the black bodice with its chest cutout and the puffy black juliet sleeves, exactly as in the image; the plain white leotard shows only below her waist, where the skirt was. She keeps her black gloves, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
+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}
diff --git a/desktop/companion/test_lines.js b/desktop/companion/test_lines.js
new file mode 100644
index 0000000..72addf2
--- /dev/null
+++ b/desktop/companion/test_lines.js
@@ -0,0 +1,79 @@
+// 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/companion/test_lines.js
+// Lines.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 + "/Lines.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const L = new Function(src +
+ "\nreturn { lines, apps, artists, monologue, appMap, appList, pickApp, pickArtist, pick, toMap };")();
+
+// The defaults are what she says until a set is saved.
+assert.strictEqual(L.pickApp("firefox", null, 0), L.apps.firefox[0]);
+assert.strictEqual(L.pickApp("firefox", null, 0.99), L.apps.firefox[L.apps.firefox.length - 1]);
+assert.strictEqual(L.pickApp("nope", null, 0), "");
+assert.strictEqual(L.appMap(null), L.apps);
+// A hand edit with a typo reads as the defaults, not as silence.
+assert.strictEqual(L.appMap("typo"), L.apps);
+assert.strictEqual(L.appMap(["not", "a", "map"]), L.apps);
+
+// A saved set replaces the defaults, class by class.
+assert.strictEqual(L.pickApp("firefox", { firefox: ["hi"] }, 0), "hi");
+assert.strictEqual(L.pickApp("kitty", { firefox: ["hi"] }, 0), "");
+assert.strictEqual(L.pick("app:firefox", 0, { firefox: ["custom"] }), "custom");
+assert.strictEqual(L.pick("app:firefox", 0), L.apps.firefox[0]);
+
+// Empty phrases are skipped when picking; a class with only empty ones says
+// nothing.
+assert.strictEqual(L.pickApp("firefox", { firefox: ["", " ", "only"] }, 0), "only");
+assert.strictEqual(L.pickApp("firefox", { firefox: ["", " "] }, 0), "");
+
+// Reactions still come from lines.
+assert.strictEqual(L.pick("shown", 0), L.lines.shown[0]);
+assert.strictEqual(L.pick("nope", 0), "");
+
+// The saved set as an ordered list, for the settings page.
+const list = L.appList(null);
+assert.strictEqual(list.length, Object.keys(L.apps).length);
+assert.deepStrictEqual(list[0], { cls: "firefox", lines: L.apps.firefox.slice() });
+assert.deepStrictEqual(L.appList({ kitty: ["a"] }), [{ cls: "kitty", lines: ["a"] }]);
+
+// Back to a map: trim, lowercase the class, drop empty classes and empty
+// phrases, and a repeated class keeps its last entry.
+assert.deepStrictEqual(L.toMap([
+ { cls: " Firefox ", lines: [" one ", "", " ", "two"] },
+ { cls: "", lines: ["dropped"] },
+ { cls: "kitty", lines: ["", " "] },
+ { cls: "firefox", lines: ["last"] },
+]), { firefox: ["last"] });
+
+// 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);
+console.log("ok");
diff --git a/desktop/companion/test_mood.js b/desktop/companion/test_mood.js
new file mode 100644
index 0000000..3439f7c
--- /dev/null
+++ b/desktop/companion/test_mood.js
@@ -0,0 +1,178 @@
+// 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/companion/test_mood.js
+// Mood.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 + "/Mood.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const M = new Function(src +
+ "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue, admireAllowed };")();
+const lsrc = fs.readFileSync(__dirname + "/ListFile.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const L = new Function(lsrc + "\nreturn { parse, toggle, outputs };")();
+
+const NOW = 10000000;
+// A snapshot with nothing going on, overridden per case.
+const calm = over => Object.assign({
+ leaving: false, now: NOW, destructAt: 0, dodging: false, shoved: false, shovedAt: 0, recording: false, busy: false,
+ repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0, singAt: 0,
+ djAt: 0, music: false, groove: "",
+ monologue: false, idle: 0,
+}, over);
+const state = over => M.pick(calm(over)).state;
+const frame = over => M.pick(calm(over)).frame;
+
+// Nothing going on: the rest pose.
+assert.deepStrictEqual(M.pick(calm({})), { state: "base", frame: "base" });
+
+// The priority table, top to bottom: with every trigger from a row down
+// active at once, that row wins. Merged so a higher row's values win.
+const ladder = [
+ ["bye", { leaving: true }],
+ ["destruct", { destructAt: NOW - 10 }],
+ ["scorched", { destructAt: NOW - M.T.destruct - 10 }],
+ ["dodge", { dodging: true }],
+ ["shoved", { shoved: true }],
+ ["listen", { recording: true }],
+ ["think", { busy: true }],
+ ["wave", { repliedAt: NOW - 10 }],
+ ["sing", { singAt: NOW - 10 }],
+ ["dj", { djAt: NOW - 10 }],
+ ["clicks", { clicks: { count: 2, at: NOW - 10 } }],
+ ["admire", { admireAt: NOW - 10 }],
+ ["mock", { mockAt: NOW - 10 }],
+ ["watch", { typingAt: NOW - 10 }],
+ ["stretch", { wakeAt: NOW - 10 }],
+ ["muse", { monologue: true }],
+ ["lying", { idle: 3 }],
+];
+for (let i = 0; i < ladder.length; i++) {
+ const all = Object.assign({}, ...ladder.slice(i).map(l => l[1]).reverse());
+ assert.strictEqual(state(all), ladder[i][0], "everything from " + ladder[i][0] + " down");
+}
+
+// A hide: she waves goodbye, above everything else, whatever she was doing.
+assert.deepStrictEqual(
+ M.pick(calm({ leaving: true, idle: 3, clicks: { count: 3, at: NOW - 10 } })),
+ { state: "bye", frame: "wave" });
+
+// Shoved: knocked off balance for a moment, then mad beside the balloon
+// for as long as it stays.
+assert.deepStrictEqual(M.pick(calm({ shoved: true, shovedAt: NOW - 10 })), { state: "shoved", frame: "shoved" });
+assert.deepStrictEqual(M.pick(calm({ shoved: true, shovedAt: NOW - M.T.shoveHold })), { state: "shoved", frame: "mad" });
+assert.strictEqual(frame({ shoved: true, shovedAt: 0 }), "shoved");
+
+// Idle levels and their frames.
+assert.deepStrictEqual(M.pick(calm({ idle: 1 })), { state: "bored", frame: "sleepy" });
+assert.deepStrictEqual(M.pick(calm({ idle: 2 })), { state: "yawn", frame: "yawn" });
+assert.deepStrictEqual(M.pick(calm({ idle: 3 })), { state: "lying", frame: "lying" });
+
+// 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" });
+
+// Holds end on time.
+assert.strictEqual(state({ repliedAt: NOW - M.T.waveHold }), "base");
+assert.strictEqual(state({ singAt: NOW - M.T.singHold }), "base");
+assert.strictEqual(state({ djAt: NOW - M.T.djHold }), "base");
+assert.strictEqual(state({ typingAt: NOW - M.T.typingHold }), "base");
+assert.strictEqual(state({ wakeAt: NOW - M.T.wakeHold }), "base");
+assert.strictEqual(state({ clicks: { count: 3, at: NOW - M.T.clickHold } }), "base");
+assert.strictEqual(state({ destructAt: NOW - M.T.destruct - M.T.scorched }), "base");
+// The mock points first, then smirks.
+assert.strictEqual(frame({ mockAt: NOW - 10 }), "point");
+assert.strictEqual(frame({ mockAt: NOW - M.T.mockHold / 2 - 10 }), "smirk");
+assert.strictEqual(state({ mockAt: NOW - M.T.mockHold }), "base");
+
+// A click burst: within burstGap it grows, past it it starts over.
+let c = { count: 0, at: 0 };
+c = M.click(c, 1000);
+assert.deepStrictEqual(c, { count: 1, at: 1000 });
+c = M.click(c, 1000 + M.T.burstGap);
+assert.strictEqual(c.count, 2);
+c = M.click(c, c.at + M.T.burstGap + 1);
+assert.strictEqual(c.count, 1);
+c = { count: 0, at: 0 };
+for (let i = 0; i < 7; i++) c = M.click(c, 5000 + i * 300);
+assert.strictEqual(c.count, 7);
+// 1 smiles, 2-6 blush harder.
+assert.deepStrictEqual([1, 2, 3, 4, 5, 6].map(M.clickFrame),
+ ["smile", "blush1", "blush1", "blush2", "blush2", "blush3"]);
+
+// Outfit: the dress until the flash is over, then the leotard for
+// scorched + skirtless, then the dress again.
+const SK = 60000;
+assert.strictEqual(M.outfit(0, NOW, SK), "dress");
+assert.strictEqual(M.outfit(NOW - 10, NOW, SK), "dress");
+assert.strictEqual(M.outfit(NOW - M.T.destruct, NOW, SK), "leotard");
+const bare = M.T.destruct + M.T.scorched + SK;
+assert.strictEqual(M.outfit(NOW - bare + 10, NOW, SK), "leotard");
+assert.strictEqual(M.outfit(NOW - bare, NOW, SK), "dress");
+// A skirtless time of 0 keeps the leotard until she is shown again, which
+// resets destructAt.
+assert.strictEqual(M.outfit(NOW - 10, NOW, 0), "dress");
+assert.strictEqual(M.outfit(NOW - 3600000, NOW, 0), "leotard");
+assert.strictEqual(M.outfit(0, NOW, 0), "dress");
+
+// Cooldowns.
+assert.strictEqual(M.mockAllowed(0, NOW), true);
+assert.strictEqual(M.mockAllowed(NOW - M.T.mockCooldown + 1, NOW), false);
+assert.strictEqual(M.mockAllowed(NOW - M.T.mockCooldown, NOW), true);
+assert.strictEqual(M.canSpeak(0, NOW), true);
+assert.strictEqual(M.canSpeak(NOW - M.T.lineCooldown + 1, NOW), false);
+assert.strictEqual(M.canSpeak(NOW - M.T.lineCooldown, NOW), true);
+
+// The monologue: 1 in 4 on boredom, 1 in 3 on being shown, once an hour.
+assert.strictEqual(M.monologue("bored", 0, NOW, 0.24), true);
+assert.strictEqual(M.monologue("bored", 0, NOW, 0.25), false);
+assert.strictEqual(M.monologue("shown", 0, NOW, 0.33), true);
+assert.strictEqual(M.monologue("shown", 0, NOW, 0.34), false);
+assert.strictEqual(M.monologue("other", 0, NOW, 0), false);
+assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown + 1, NOW, 0), false);
+assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown, NOW, 0), true);
+
+// 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);
+
+// 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(""), {});
+
+console.log("ok");
diff --git a/desktop/modules/appearance/WallpaperTab.qml b/desktop/modules/appearance/WallpaperTab.qml
index a3b3c55..d9876ed 100644
--- a/desktop/modules/appearance/WallpaperTab.qml
+++ b/desktop/modules/appearance/WallpaperTab.qml
@@ -12,6 +12,7 @@
import Quickshell
import QtQuick
import "../.."
+import "../../companion"
// Wallpapers by folder, staged per screen and set together with Apply, beside
// a mock of the two screens above them. Sized by its host: the thumbnail list scrolls
@@ -338,6 +339,26 @@ Column {
color: Theme.subtext
}
}
+
+ // The companion blushes at the hearted ones
+ // (companion/Admired.qml). After the pick
+ // MouseArea, so it takes the click. Outlined,
+ // so it reads on a light image too.
+ 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
+ style: Text.Outline
+ styleColor: Qt.alpha(Theme.base, 0.6)
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize + 8 }
+ MouseArea {
+ anchors { fill: parent; margins: -4 }
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Admired.toggle(modelData)
+ }
+ }
}
}
}
diff --git a/desktop/modules/assistant/AssistantModule.qml b/desktop/modules/assistant/AssistantModule.qml
index 1e3c1e0..a05556d 100644
--- a/desktop/modules/assistant/AssistantModule.qml
+++ b/desktop/modules/assistant/AssistantModule.qml
@@ -27,7 +27,7 @@ Module {
settings: true
group: "Assistant"
keywords: ["llm", "model", "llama", "server", "prompt", "temperature", "thinking",
- "rounds", "tools", "environment", "daemon", "restart"]
+ "rounds", "tools", "environment", "daemon", "restart", "cloud", "provider"]
readonly property string home: Quickshell.env("HOME")
readonly property string daemonDir: home + "/Programming/GIT/desktop-assistant"
@@ -57,14 +57,34 @@ Module {
onDraftChanged: {
const k = C.env(am.draft).map(e => e.key);
if (JSON.stringify(k) !== JSON.stringify(am.envKeys)) am.envKeys = k;
+ const n = (C.clouds(am.draft) ?? []).length;
+ if (n !== am.cloudCount) am.cloudCount = n;
}
function envValue(key) { return (am.envList.find(e => e.key === key) ?? { value: "" }).value; }
- // Tool switches flipped but not saved: name -> wanted on.
+ // 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) ?? []
+ // Their models as the canvas names them, "provider:model". reduce, not
+ // flatMap: the shell's JS engine has no flatMap.
+ readonly property var cloudIds: am.savedClouds.reduce(
+ (a, c) => a.concat(c.models.map(m => c.name + ":" + m)), [])
+ 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: ""
+ // Tool switches flipped but not saved: name -> wanted on; and the same
+ // for each tool's cloud = true, which offers it to cloud chats.
property var toolsWanted: ({})
+ property var cloudWanted: ({})
// Fields holding a value that cannot be written; Save waits for none.
property var invalid: ({})
- readonly property bool valid: Object.keys(am.invalid).length === 0
+ readonly property bool valid: Object.keys(am.invalid).length === 0 && am.cloudError === ""
dirty: am.draft !== am.base || Object.keys(am.toolsWanted).length > 0
+ || Object.keys(am.cloudWanted).length > 0
// The daemon's built-in values, from assistant.py --defaults.
property var defaults: ({})
// [{name, on, description}]
@@ -163,14 +183,21 @@ Module {
// The config first, then the tool renames, each only when there is
// something to do; any failure stops there and says why.
- function save() {
+ 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 tools = () => am.applyTools(Object.keys(am.toolsWanted), () => { am.pending = true; am.listTools(); });
+ const changed = t !== am.text || Object.keys(am.toolsWanted).length > 0
+ || Object.keys(am.cloudWanted).length > 0;
+ const tools = () => am.applyTools(Object.keys(am.toolsWanted), () =>
+ am.applyCloud(Object.keys(am.cloudWanted), () => {
+ 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; }
@@ -183,6 +210,7 @@ Module {
am.error = "";
am.invalid = ({});
am.toolsWanted = ({});
+ am.cloudWanted = ({});
am.base = am.text;
am.draft = am.text;
}
@@ -197,15 +225,41 @@ Module {
function setEnv(key, value) { am.draft = C.setEnv(am.draft, key, value); }
function quote(s) { return C.quote(s); }
+ // 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 });
+ });
+ });
+ }
+
// Every tools/*.toml is loaded; *.toml.off is the same file switched off.
readonly property string lister: "import glob, json, os, sys, tomllib\n"
+ "out = []\n"
+ "for p in glob.glob(sys.argv[1] + '/*.toml') + glob.glob(sys.argv[1] + '/*.toml.off'):\n"
+ " on = p.endswith('.toml')\n"
+ " name = os.path.basename(p).removesuffix('.off').removesuffix('.toml')\n"
- + " try:\n d = tomllib.load(open(p, 'rb')).get('description', '')\n"
- + " except Exception as e:\n d = f'unreadable: {e}'\n"
- + " out.append({'name': name, 'on': on, 'description': d})\n"
+ + " try:\n t = tomllib.load(open(p, 'rb'))\n"
+ + " d, cloud = t.get('description', ''), t.get('cloud') is True\n"
+ + " except Exception as e:\n d, cloud = f'unreadable: {e}', False\n"
+ + " out.append({'name': name, 'on': on, 'cloud': cloud, 'description': d})\n"
+ "print(json.dumps(sorted(out, key=lambda t: t['name'])))\n"
function listTools() {
@@ -221,6 +275,45 @@ Module {
if (!am.toolOn(t) === t.on) delete next[t.name]; else next[t.name] = !am.toolOn(t);
am.toolsWanted = next;
}
+ // Whether a tool goes to cloud chats in the draft, as toolOn.
+ function toolCloud(t) { return am.cloudWanted[t.name] ?? t.cloud; }
+ function toggleCloud(t) {
+ const next = Object.assign({}, am.cloudWanted);
+ if (!am.toolCloud(t) === t.cloud) delete next[t.name]; else next[t.name] = !am.toolCloud(t);
+ am.cloudWanted = next;
+ }
+
+ // Sets or drops a tool file's top-level cloud line, validated with
+ // tomllib and replaced atomically, like config.toml. The file is
+ // name.toml or, switched off, name.toml.off. Added as the first line:
+ // a top-level key is valid there whatever the file holds below.
+ readonly property string clouder: "import os, re, sys, tomllib\n"
+ + "p, on = sys.argv[1], sys.argv[2] == '1'\n"
+ + "if not os.path.exists(p):\n p += '.off'\n"
+ + "lines = open(p).read().split('\\n')\n"
+ + "head = next((i for i, l in enumerate(lines) if l.lstrip().startswith('[')), len(lines))\n"
+ + "lines = [l for i, l in enumerate(lines) if i >= head or not re.match(r'\\s*cloud\\s*=', l)]\n"
+ + "if on:\n lines.insert(0, 'cloud = true')\n"
+ + "t = '\\n'.join(lines)\n"
+ + "try:\n tomllib.loads(t)\n"
+ + "except tomllib.TOMLDecodeError as e:\n sys.exit(f'{os.path.basename(p)}: not valid TOML: {e}')\n"
+ + "with open(p + '.tmp', 'w') as f:\n f.write(t)\n"
+ + "os.replace(p + '.tmp', p)\n"
+
+ // One file at a time, after the renames, then done().
+ function applyCloud(names, done) {
+ if (names.length === 0) { done(); return; }
+ const name = names[0];
+ am.run([am.python, "-c", am.clouder, am.toolsDir + "/" + name + ".toml",
+ am.cloudWanted[name] ? "1" : "0"], null, (code, out, err) => {
+ if (code !== 0) { am.error = err.trim() || "could not change " + name; am.listTools(); return; }
+ const next = Object.assign({}, am.cloudWanted);
+ delete next[name];
+ am.cloudWanted = next;
+ am.applyCloud(names.slice(1), done);
+ });
+ }
+
// One rename at a time, then done(). -n never overwrites a file that
// already holds the other name.
function applyTools(names, done) {
@@ -269,17 +362,17 @@ Module {
else { poll.left = 300; poll.start(); }
});
}
- // Called when the assistant canvas opens: the preferred model, if one is
- // set, still listed, and neither loaded nor loading, is loaded. One model
- // and one client at a time is how this desktop runs, so opening the
- // assistant is the moment to bring its model back. The list is read on
- // every open, preferred model or not, so hears is current.
- function ensurePreferred() {
+ // 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 id = Prefs.assistantModel;
const m = am.models.find(x => x.id === id);
- if (id !== "" && m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id);
+ if (m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id);
});
}
diff --git a/desktop/modules/assistant/AssistantPage.qml b/desktop/modules/assistant/AssistantPage.qml
index e088241..bf35763 100644
--- a/desktop/modules/assistant/AssistantPage.qml
+++ b/desktop/modules/assistant/AssistantPage.qml
@@ -166,9 +166,9 @@ Column {
Small {
width: page.width
wrapMode: Text.Wrap
- text: "The assistant answers with whichever model llama-server has loaded. Opening it loads "
- + "the preferred model if another one is in. Load swaps a model in now, for every client, "
- + "without changing the preference."
+ 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."
}
Small {
@@ -231,6 +231,50 @@ Column {
}
}
+ // The saved cloud models: Prefer only, there is nothing to load.
+ Repeater {
+ model: page.asst.cloudIds
+
+ 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)
+ }
+ }
+ }
+
Small {
width: page.width
visible: page.asst.modelError !== ""
@@ -368,6 +412,141 @@ Column {
// --- env ---
+ // --- 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, {})
+ }
+
Heading { text: "Tool environment" }
Small {
width: page.width
@@ -461,7 +640,8 @@ Column {
Small {
width: page.width
wrapMode: Text.Wrap
- text: "What the model may call. Off renames the file to .toml.off, which the daemon skips."
+ text: "What the model may call. Off renames the file to .toml.off, which the daemon skips. "
+ + "Cloud also offers it to cloud chats, which then receive its results."
}
Repeater {
@@ -480,9 +660,24 @@ Column {
checked: page.asst.toolOn(toolRow.modelData)
onToggled: page.asst.toggleTool(toolRow.modelData)
}
+ // nf-fa-cloud, then the tool's cloud = true.
+ Switch {
+ id: cloudSw
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ checked: page.asst.toolCloud(toolRow.modelData)
+ onToggled: page.asst.toggleCloud(toolRow.modelData)
+ }
+ Text {
+ id: cloudMark
+ anchors { right: cloudSw.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ text: "\uf0c2"
+ color: page.asst.toolCloud(toolRow.modelData) ? Theme.text : Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
Column {
id: toolText
- anchors { left: toolSw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
+ anchors { left: toolSw.right; leftMargin: 10; right: cloudMark.left; rightMargin: 10
+ verticalCenter: parent.verticalCenter }
Text {
text: toolRow.modelData.name
color: page.asst.toolOn(toolRow.modelData) ? Theme.text : Theme.subtext
diff --git a/desktop/modules/assistant/Config.js b/desktop/modules/assistant/Config.js
index c30ea8b..4c71b14 100644
--- a/desktop/modules/assistant/Config.js
+++ b/desktop/modules/assistant/Config.js
@@ -17,7 +17,8 @@
// The file is edited line by line so its comments survive: the example it
// is copied from documents every key in place. Only the shapes this page
// writes are understood: top-level `key = value` before the first table,
-// and `KEY = "value"` lines in [env]. A top-level key that is unset sits
+// and `KEY = "value"` lines in [env]. [[cloud]] tables are read when they
+// hold only what setClouds writes, and rewritten whole. A top-level key that is unset sits
// commented out (`# key = value`), which is how the example ships, so
// setting one activates its line and unsetting comments it back out.
// ponytail: no multi-line strings or arrays; the page never writes one,
@@ -140,3 +141,87 @@ function setEnv(text, key, val) {
lines.splice(last + 1, 0, key + " = " + quote(val));
return lines.join("\n");
}
+
+// [[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.` : "";
+}
diff --git a/desktop/modules/assistant/README.md b/desktop/modules/assistant/README.md
index 3c35dc6..a1ce840 100644
--- a/desktop/modules/assistant/README.md
+++ b/desktop/modules/assistant/README.md
@@ -13,7 +13,17 @@ are never copied here. A refused number is outlined and holds Save back.
The system prompt also has "Use the default". The tool environment
(`[env]`) is one row per key, with an add row below. A tool's switch, once
saved, renames `tools/<name>.toml`
-to `<name>.toml.off`, which the daemon's `*.toml` glob skips.
+to `<name>.toml.off`, which the daemon's `*.toml` glob skips. Its cloud switch sets or drops
+`cloud = true` at the top of the file, which offers the tool to cloud
+chats; the file is checked with `tomllib` and replaced atomically.
+
+**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.
The page also lists llama-server's models with their state. The daemon
always answers with whichever model is loaded and never asks for one, so
@@ -24,12 +34,12 @@ Prefer is the other, separate choice: the model the assistant should use,
saved in `Prefs`. A first version made Load also set the preference, so a
model loaded for another client became the assistant's too.
-Opening the assistant canvas (SUPER+Home) loads the preferred 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
-opening the assistant is when its model should come back; the daemon still
-never asks for a model itself. With no preferred model set, nothing is
-swapped.
+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.
The daemon reads all of this only at startup, so after a change the page
says so and offers Restart, which runs `assistant.sh restart`; a daemon that
diff --git a/desktop/modules/assistant/test_config.js b/desktop/modules/assistant/test_config.js
index cc610a8..f79f4e1 100644
--- a/desktop/modules/assistant/test_config.js
+++ b/desktop/modules/assistant/test_config.js
@@ -18,7 +18,7 @@ const assert = require("assert");
const src = fs.readFileSync(__dirname + "/Config.js", "utf8")
.replace(/^\.pragma library$/m, "");
-const C = new Function(src + "\nreturn { scalars, setScalar, env, setEnv, quote };")();
+const C = new Function(src + "\nreturn { scalars, setScalar, env, setEnv, quote, clouds, setClouds, cloudError };")();
const KEYS = ["llm", "system", "tool_rounds", "temperature", "thinking"];
const file = [
@@ -86,4 +86,43 @@ assert.ok(/\n\[env\]\nX = "1"\n$/.test(t), JSON.stringify(t));
// An env key in another table is not the env one.
assert.deepStrictEqual(C.env('[voice]\nlang = "it"\n'), []);
+// [[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]), "");
+
console.log("test_config: ok");
diff --git a/desktop/modules/companion/ArtistsModule.qml b/desktop/modules/companion/ArtistsModule.qml
new file mode 100644
index 0000000..a0d23f4
--- /dev/null
+++ b/desktop/modules/companion/ArtistsModule.qml
@@ -0,0 +1,67 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+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: "\uf001"
+ 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
+ }
+ }
+ }
+}
diff --git a/desktop/modules/companion/CompanionModule.qml b/desktop/modules/companion/CompanionModule.qml
new file mode 100644
index 0000000..8d6a68d
--- /dev/null
+++ b/desktop/modules/companion/CompanionModule.qml
@@ -0,0 +1,131 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+import "../appearance"
+import "../../companion"
+
+// 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.
+Module {
+ name: "companion"
+ label: "Companion"
+ // nf-md-account_heart, present in Inconsolata Nerd Font.
+ icon: "\u{F0899}"
+ settings: true
+ group: "Companion"
+ keywords: ["companion", "2b", "mascot", "character", "show", "hide", "wallpaper", "blush", "leotard", "timeout"]
+
+ page: Component {
+ Page {
+ title: "Companion"
+ Column {
+ width: parent.width
+ spacing: 6
+
+ Item {
+ width: parent.width
+ height: 34
+ Text {
+ id: lbl
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 160
+ text: "Shown"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Switch {
+ id: sw
+ anchors { left: lbl.right; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ checked: Prefs.companion
+ onToggled: on => Prefs.setCompanion(on)
+ }
+ Text {
+ anchors { left: sw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
+ elide: Text.ElideRight
+ text: "Also SUPER+\\. Kept across restarts."
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ }
+ }
+ // After the self-destruct. Off keeps the leotard until she is
+ // hidden and shown again; turning it back on starts from the
+ // seconds in the field.
+ Item {
+ width: parent.width
+ height: 34
+ Text {
+ id: lblSk
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 160
+ text: "Leotard timeout"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Switch {
+ id: swSk
+ anchors { left: lblSk.right; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ checked: Prefs.companionSkirtless > 0
+ onToggled: on => Prefs.setCompanionSkirtless(on ? (parseInt(fSk.value) || 60) : 0)
+ }
+ Field {
+ id: fSk
+ anchors { left: swSk.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ width: 60
+ enabled: swSk.checked
+ opacity: enabled ? 1 : 0.4
+ value: String(Prefs.companionSkirtless || 60)
+ placeholder: "60"
+ onEdited: {
+ const n = parseInt(value);
+ if (n >= 1 && String(n) === value.trim()) Prefs.setCompanionSkirtless(n);
+ else value = String(Prefs.companionSkirtless || 60);
+ }
+ }
+ Text {
+ anchors { left: fSk.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
+ elide: Text.ElideRight
+ text: swSk.checked ? "seconds" : "until she is hidden and shown again"
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ }
+ }
+ 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 }
+ }
+ }
+ }
+ }
+}
diff --git a/desktop/modules/companion/PhrasesModule.qml b/desktop/modules/companion/PhrasesModule.qml
new file mode 100644
index 0000000..7e4895a
--- /dev/null
+++ b/desktop/modules/companion/PhrasesModule.qml
@@ -0,0 +1,64 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+import "../../companion/Lines.js" as Lines
+
+// The lines the companion says when focus moves to a program she
+// recognizes, by its lowercased window class. They live in Prefs and she
+// reads them live, so a save needs no restart. The page edits a draft here;
+// Save hands it to Prefs, Reset drops the saved set so Lines.js' defaults
+// show again.
+Module {
+ id: pm
+ name: "phrases"
+ label: "Companion phrases"
+ // nf-fa-comment, present in Inconsolata Nerd Font.
+ icon: "\uf075"
+ settings: true
+ group: "Companion"
+ keywords: ["companion", "phrase", "line", "say", "speak", "app", "program",
+ "window", "class", "firefox", "2b"]
+ searchItems: pm.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i }))
+
+ // null while there are no edits: the page then shows the saved set, or
+ // Lines.js' defaults when nothing is saved.
+ property var draft: null
+ // Reset was pressed: Save drops the saved set rather than saving a copy
+ // of the defaults, so later default changes still arrive.
+ property bool resetting: false
+ readonly property var shown: pm.draft ?? Lines.appList(Prefs.companionApps)
+ // Every app needs a class and at least one non-empty phrase; Save waits.
+ readonly property bool valid: pm.shown.every(a => String(a.cls).trim() !== ""
+ && (a.lines || []).some(l => String(l).trim() !== ""))
+ dirty: pm.draft !== null
+
+ function edit(list) { pm.draft = list; pm.resetting = false; }
+ function reset() { pm.draft = Lines.appList(null); pm.resetting = true; }
+ function save() {
+ if (pm.resetting) Prefs.resetCompanionApps();
+ else Prefs.setCompanionApps(Lines.toMap(pm.draft));
+ pm.discard();
+ }
+ function discard() { pm.draft = null; pm.resetting = false; }
+
+ page: Component {
+ Page {
+ title: "Companion phrases"
+ actions: Component {
+ SaveBar { dirty: pm.dirty; valid: pm.valid; note: "every app needs a class and a phrase"
+ onSave: pm.save(); onDiscard: pm.discard() }
+ }
+ PhrasesPage { width: parent.width; owner: pm }
+ }
+ }
+}
diff --git a/desktop/modules/companion/PhrasesPage.qml b/desktop/modules/companion/PhrasesPage.qml
new file mode 100644
index 0000000..1971171
--- /dev/null
+++ b/desktop/modules/companion/PhrasesPage.qml
@@ -0,0 +1,195 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// One card per recognized program: its window class and the lines she says
+// when focus moves to it. Every change edits the module's draft; the
+// header's Save hands the set to Prefs. Reset fills the draft with the
+// built-in set from Lines.js.
+Column {
+ id: page
+ spacing: 10
+
+ // The module, holding the draft. Not named pm, its id (it would bind to
+ // itself, AGENTS.md).
+ required property QtObject owner
+ // 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
+ readonly property var list: owner.shown
+ // The app card awaiting a second click on Remove.
+ property int confirming: -1
+
+ // Saves a copy of the list with app i replaced by fn(app), or removed
+ // when fn returns null.
+ function edit(i, fn) {
+ const out = [];
+ for (let j = 0; j < page.list.length; j++) {
+ const a = page.list[j];
+ const c = { cls: a.cls, lines: a.lines.slice() };
+ if (j !== i) { out.push(c); continue; }
+ const r = fn(c);
+ if (r) out.push(r);
+ }
+ page.confirming = -1;
+ page.owner.edit(out);
+ }
+
+ function addApp() { page.owner.edit(page.list.concat([{ cls: "", lines: [""] }])); }
+ function removeApp(i) { page.edit(i, () => null); }
+ function setCls(i, t) { page.edit(i, a => Object.assign(a, { cls: t })); }
+ function addLine(i) { page.edit(i, a => Object.assign(a, { lines: a.lines.concat([""]) })); }
+ function setLine(i, j, t) {
+ page.edit(i, a => {
+ const ls = a.lines.slice();
+ ls[j] = t;
+ return Object.assign(a, { lines: ls });
+ });
+ }
+ function removeLine(i, j) { page.edit(i, a => Object.assign(a, { lines: a.lines.filter((_, k) => k !== j) })); }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ // Reports every keystroke into the draft. Not bound to value, which
+ // would reset the cursor; it follows the value only when the draft moves
+ // under it (Discard, Reset). Empty is outlined, since Save refuses it.
+ component Field: Rectangle {
+ id: field
+ property string value: ""
+ property string placeholder: ""
+ signal edited(string text)
+ height: 32
+ radius: 8
+ color: Qt.alpha(Theme.base, 0.6)
+ border.width: ti.text.trim() === "" ? 1 : 0
+ border.color: Theme.red
+ onValueChanged: if (ti.text !== field.value) ti.text = field.value
+ TextInput {
+ activeFocusOnTab: true
+ id: ti
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ Component.onCompleted: text = field.value
+ onTextEdited: field.edited(text)
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: field.placeholder
+ color: Theme.overlay
+ font: parent.font
+ }
+ }
+ }
+
+ Small {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: page.intro
+ }
+
+ Repeater {
+ // A count, not the list: a new list on every keystroke would rebuild
+ // every card and drop the focus of the field being typed in.
+ model: page.list.length
+
+ delegate: Rectangle {
+ id: card
+ required property int index
+ readonly property var modelData: page.list[index] ?? { cls: "", lines: [] }
+ objectName: "app:" + index
+ width: page.width
+ height: body.implicitHeight + 20
+ radius: 10
+ color: Qt.alpha(Theme.surface, 0.45)
+
+ Column {
+ id: body
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 }
+ spacing: 8
+
+ Item {
+ width: parent.width
+ height: 32
+ Field {
+ anchors { left: parent.left; right: del.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
+ value: card.modelData.cls
+ placeholder: page.keyHint
+ onEdited: t => page.setCls(card.index, t)
+ }
+ Button {
+ id: del
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ danger: true
+ text: page.confirming === card.index ? "Confirm" : "Remove"
+ onClicked: {
+ if (page.confirming === card.index) page.removeApp(card.index);
+ else page.confirming = card.index;
+ }
+ }
+ }
+
+ Repeater {
+ model: card.modelData.lines.length
+ delegate: Row {
+ id: phraseRow
+ required property int index
+ width: body.width
+ spacing: 6
+ Field {
+ width: phraseRow.width - removeBtn.width - phraseRow.spacing
+ value: card.modelData.lines[phraseRow.index] ?? ""
+ placeholder: "a line she says"
+ onEdited: t => page.setLine(card.index, phraseRow.index, t)
+ }
+ Button {
+ id: removeBtn
+ // nf-fa-xmark, present in Inconsolata Nerd Font.
+ icon: "\uf00d"
+ danger: true
+ onClicked: page.removeLine(card.index, phraseRow.index)
+ }
+ }
+ }
+
+ Button {
+ text: "Add phrase"
+ onClicked: page.addLine(card.index)
+ }
+ }
+ }
+ }
+
+ Row {
+ spacing: 8
+ Button { text: page.addText; onClicked: page.addApp() }
+ Button {
+ text: "Reset to defaults"
+ danger: true
+ enabled: !page.owner.resetting && (page.saved !== null || page.owner.dirty)
+ onClicked: page.owner.reset()
+ }
+ }
+}
diff --git a/desktop/shell.qml b/desktop/shell.qml
index 8579709..35dbe3f 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -25,6 +25,7 @@ import "modules/qtmaildir"
import "modules/sbbuilder"
import "modules/bluetooth"
import "modules/clipboard"
+import "modules/companion"
import "modules/git"
import "modules/kdeconnect"
import "modules/mail"
@@ -40,6 +41,7 @@ import "modules/user"
import "modules/vm"
import "island"
import "assistant"
+import "companion"
ShellRoot {
// Quickshell exits once no window is visible, and the drawer is closed
@@ -85,6 +87,9 @@ ShellRoot {
IconsModule {},
SunsetModule {},
IdleModule {},
+ CompanionModule {},
+ PhrasesModule {},
+ ArtistsModule {},
NotificationsModule {},
AutostartModule {},
ObsScenesModule {},
@@ -123,13 +128,20 @@ ShellRoot {
// The assistant canvas on SUPER+Home, a view over desktop-assistant's
// socket. See assistant/README.md.
- Assistant { id: assistant; swapping: asstSettings.loading; hears: asstSettings.hears }
+ Assistant { id: assistant; swapping: asstSettings.loading; hears: asstSettings.hears
+ preferred: Prefs.assistantModel }
- // Opening the assistant loads its preferred model if another one is in
- // VRAM. See modules/assistant/README.md.
+ // The desktop companion, bottom-right of the focused monitor, on SUPER+\.
+ // She reads the assistant's state. See companion/README.md.
+ Companion { asst: assistant }
+
+ // 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.ensurePreferred(); }
+ function onOpenChanged() { if (assistant.open) asstSettings.ensureModel(assistant.wanted); }
+ function onWantedChanged() { if (assistant.open) asstSettings.ensureModel(assistant.wanted); }
}
// The waybar launcher and the deep-link keybinds all reach this:
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/plans/2026-10-23-companion.md b/docs/superpowers/plans/2026-10-23-companion.md
index 3543b88..b6fb24a 100644
--- a/docs/superpowers/plans/2026-10-23-companion.md
+++ b/docs/superpowers/plans/2026-10-23-companion.md
@@ -34,7 +34,8 @@
| `desktop/companion/Mood.js` | create | which state wins; timings; outfit; cooldowns |
| `desktop/companion/test_mood.js` | create | node check of `Mood.js` |
| `desktop/companion/Lines.js` | create | her English lines and the monologue |
-| `desktop/companion/prep-frames` | create | generated images to display frames |
+| `desktop/companion/prep-frames` | done | generated images to display frames |
+| `desktop/companion/gen-frames`, `frames.txt` | done | make the frames through sd-server |
| `desktop/assistant/Assistant.qml` | modify | `signal replied()` |
| `desktop/companion/Sprite.qml` | create | frame, crossfade, bob, drop, explosion |
| `desktop/companion/Companion.qml` | create | window, inputs, timers, speech, IPC |
@@ -534,6 +535,8 @@ EOF
### Task 5: prep-frames, and a placeholder base
+> **Done before implementation (2026-10-23), skip it.** `prep-frames` was committed with `gen-frames` and `frames.txt` (14a2c75), and every frame exists: 18 dress, 19 leotard, in `~/.local/share/keystone/companion/2b/frames/`. Run `desktop/companion/prep-frames` once to confirm `37 frames`.
+
**Files:**
- Create: `desktop/companion/prep-frames` (executable)
@@ -1367,6 +1370,8 @@ Until the frames are made, every state shows the base: that is the fallback work
### Task 11: The frames (the user's, with sd-webui)
+> **Done (2026-10-23).** Made with `desktop/companion/gen-frames` from `frames.txt`, not by hand in the web UI; `shoved` was added as an 18th frame per set. The list below is kept as the record of what the set contains.
+
Not code: a checklist for generating the art with the recipes in `recipe.txt`. Each image goes into `~/.local/share/keystone/companion/2b/dress/` or `leotard/` under the frame's name, then `desktop/companion/prep-frames`.
- [x] `dress/base.png`: klein edit of `base-sitting.png`, the standing prompt (delivered 2026-10-23, before Task 5, so its placeholder step keeps it)
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 8acffbf..8a8c426 100644
--- a/docs/superpowers/specs/2026-10-23-companion-design.md
+++ b/docs/superpowers/specs/2026-10-23-companion-design.md
@@ -59,10 +59,10 @@ assistant, so she reads its state directly. The cost, accepted: restarting
```
desktop/companion/
Companion.qml the window, inputs, timers, IPC
- Sprite.qml draws a frame; crossfade, bob, squash, explosion
+ Figure.qml draws a frame; crossfade, bob, squash, explosion
Mood.js which state wins, as a pure function (.pragma library)
test_mood.js node check of Mood.js, like assistant/test_md.js
- lines.js her English lines, grouped by reaction
+ Lines.js her English lines, grouped by reaction
prep-frames bash + ImageMagick: generated images to frames/
README.md the component's notes
```
@@ -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
@@ -169,7 +170,7 @@ timers.
### Speech
-Fixed English lines from `lines.js`, picked at random per reaction, for
+Fixed English lines from `Lines.js`, picked at random per reaction, for
example "...Operator, I'm bored.", "Typo again?", "Hey.", "Answer's
ready.", "Glory to mankind.". A bubble shows 3 s. At most one line per
30 s, except clicks and the self-destruct.
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.
diff --git a/keybinds/Binds.js b/keybinds/Binds.js
index 1f809cf..604a53d 100644
--- a/keybinds/Binds.js
+++ b/keybinds/Binds.js
@@ -42,8 +42,16 @@ const NAMES = Object.assign(Object.create(null), {
"XF86Search": "Fn+F11",
});
+// Hyprland 0.56.2 reports a Lua `code:N` bind with key "" and keycode 0, so
+// such a bind is named here by its description.
+// ponytail: one entry per keycode bind; a new `code:` bind needs a line here.
+const UNNAMED = Object.assign(Object.create(null), {
+ "Desktop: Companion": "\\", // code:49, left of 1
+});
+
function keys(bind) {
- return mods(bind.modmask).concat([NAMES[bind.key] ?? bind.key]).join(" + ");
+ const k = bind.key || UNNAMED[bind.description] || "?";
+ return mods(bind.modmask).concat([NAMES[k] ?? k]).join(" + ");
}
// Where a bind sits on the macropad, keyed by the combo as keys() writes it,
diff --git a/keybinds/test_binds.js b/keybinds/test_binds.js
index df1e4b5..7b2289a 100644
--- a/keybinds/test_binds.js
+++ b/keybinds/test_binds.js
@@ -27,6 +27,8 @@ assert.strictEqual(B.keys({ modmask: 69, key: "F1" }), "Win + CTRL + SHIFT + F1"
assert.strictEqual(B.keys({ modmask: 0, key: "XF86AudioMute" }), "Mute");
assert.strictEqual(B.keys({ modmask: 64, key: "mouse:272" }), "Win + LMB");
assert.strictEqual(B.keys({ modmask: 0, key: "F5" }), "F5");
+// A keycode bind reports no key at all; it is named by its description.
+assert.strictEqual(B.keys({ modmask: 64, key: "", keycode: 0, description: "Desktop: Companion" }), "Win + \\");
// A key named like an Object.prototype member is not renamed.
assert.strictEqual(B.keys({ modmask: 0, key: "constructor" }), "constructor");
diff --git a/notifications/NotificationBalloon.qml b/notifications/NotificationBalloon.qml
index 6a674b8..a82f5f6 100644
--- a/notifications/NotificationBalloon.qml
+++ b/notifications/NotificationBalloon.qml
@@ -27,18 +27,28 @@ Rectangle {
property var borderColors: []
property real borderAngle: 0
+ // Slides in from the screen edge it sits against. The layer surface maps
+ // once for the whole stack, so a Hyprland layer animation would only
+ // play for the first balloon. The window clips it, so it appears from
+ // the stack's edge rather than from beyond the screen's.
+ transform: Translate { id: slide }
+ NumberAnimation {
+ id: enter
+ target: slide; property: "x"; duration: 260; easing.type: Easing.OutCubic
+ to: 0
+ }
+ Component.onCompleted: {
+ enter.from = Notify.config.balloons.corner.endsWith("left") ? -b.width - 12 : b.width + 12;
+ enter.start();
+ }
+
// Driven by the shell's tick. The balloon drops itself once its expiry
// passes, or while a suppression says so: the drawer and snooze withhold
// everything; a muted app and DND are config.json's call (DND passes
// critical unless dndPassCritical is off). The drawer still lists what
// was withheld, because a list the user opened is not an interruption.
- visible: {
- if (Notify.drawerOpen) return false;
- if (Notify.snoozeUntil > b.now) return false;
- if (NC.withheld(Notify.config, b.notification, Notify.dnd)) return false;
- if (b.notification.expires !== 0 && b.now >= b.notification.expires) return false;
- return true;
- }
+ visible: NC.shown(Notify.config, b.notification, Notify.dnd, Notify.drawerOpen,
+ Notify.snoozeUntil, b.now)
width: parent ? parent.width : 340
implicitHeight: texts.implicitHeight + 20 + (preview.visible ? preview.height + 8 : 0)
diff --git a/shared/NotifyConfig.js b/shared/NotifyConfig.js
index 143168f..6fc98e6 100644
--- a/shared/NotifyConfig.js
+++ b/shared/NotifyConfig.js
@@ -71,6 +71,16 @@ function withheld(cfg, n, dnd) {
return dnd && !(n.urgency === "critical" && cfg.dndPassCritical);
}
+// Whether a queued notification's balloon is on screen at `now`: the drawer
+// and snooze withhold everything, then mute and DND, then its own expiry.
+// The balloon shell and the desktop companion both ask this, so they cannot
+// disagree about what is showing.
+function shown(cfg, n, dnd, drawerOpen, snoozeUntil, now) {
+ if (drawerOpen || snoozeUntil > now) return false;
+ if (withheld(cfg, n, dnd)) return false;
+ return n.expires === 0 || now < n.expires;
+}
+
// The file a balloon plays, "" for none: the app's suppress-sound wins, then
// the app's own sound (notifyd resolved its hints), then the urgency's.
function soundFor(cfg, n) {
diff --git a/shared/test_notify_config.js b/shared/test_notify_config.js
index c7bc2cc..4e8db23 100644
--- a/shared/test_notify_config.js
+++ b/shared/test_notify_config.js
@@ -18,7 +18,7 @@ const assert = require("assert");
const src = fs.readFileSync(__dirname + "/NotifyConfig.js", "utf8")
.replace(/^\.pragma library$/m, "");
-const C = new Function(src + "\nreturn { defaults, resolve, withheld, soundFor };")();
+const C = new Function(src + "\nreturn { defaults, resolve, withheld, soundFor, shown };")();
// Nothing, or garbage, is the defaults.
assert.deepStrictEqual(C.resolve(undefined), C.defaults());
@@ -64,4 +64,16 @@ assert.strictEqual(C.soundFor(sc, sn("critical", "/app.oga", true)), "");
// An old daemon publishes neither field.
assert.strictEqual(C.soundFor(sc, { app: "x", urgency: "critical" }), "/c.oga");
+// On screen: the drawer, snooze, mute, DND and expiry, in one check that
+// the balloons and the desktop companion share.
+const live = { app: "ok", urgency: "normal", expires: 0 };
+assert.strictEqual(C.shown(cfg, live, false, false, 0, 5000), true);
+assert.strictEqual(C.shown(cfg, live, false, true, 0, 5000), false); // drawer open
+assert.strictEqual(C.shown(cfg, live, false, false, 6000, 5000), false); // snoozed
+assert.strictEqual(C.shown(cfg, { app: "spam", urgency: "normal", expires: 0 }, false, false, 0, 5000), false);
+assert.strictEqual(C.shown(cfg, live, true, false, 0, 5000), false); // DND
+const timed = { app: "ok", urgency: "normal", expires: 5000 };
+assert.strictEqual(C.shown(cfg, timed, false, false, 0, 4999), true);
+assert.strictEqual(C.shown(cfg, timed, false, false, 0, 5000), false); // expired
+
console.log("ok");