# Assistant Overlay 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:** A quickshell overlay that shows the assistant's state, context use, running tool and real audio levels, fed by a JSON-lines Unix socket the assistant owns; ignored presses send a notification instead. **Architecture:** `assistant.py` gains a socket broadcaster (stdlib `socket` + threads), state/ctx/level events at the points the voice loop already passes through, a playback-clocked level emitter for the reply, and `notify-send` for ignored presses. A new standalone component `assistant/` in the quickshell repo connects to the socket and draws the circle and the wave ring. With no overlay running, the assistant behaves exactly as before. **Tech Stack:** Python 3.12 stdlib (+ numpy, already present through kokoro-onnx), `notify-send`, Quickshell 0.3.1 QML (`Quickshell.Io.Socket`, `Canvas`), node for the QML library test. **Spec:** `docs/superpowers/specs/2026-09-26-overlay-design.md` **Conventions (both repos):** - No em dashes. No personal data or home paths in committed files (gitleaks hooks enforce it). GPLv2-only header in every new source file. - Commits GPG-signed (default config), `Co-Authored-By: Claude Opus 5.5 ` at the end, pushed after each commit. Commit only with the user's go-ahead for this plan's work. - desktop-assistant self-check: `python3 assistant.py --test` prints `ok`. - quickshell repo: read its `AGENTS.md` first. Process is `qs` (never `pkill -f`, never `pkill -x qs`, which kills every component: stop only this one with `qs kill -p `). A detached `qs` dies with the agent's tool call: run it as a harness-owned background command and read its log. For anything visual, ask the user. Commit subjects there use the `feat(component): ...` style. **Deviation from the spec, decided while planning:** `speaking` is emitted by the reply thread when it queues the first sentence of an answer (and again after a tool round), not by the speaker when audio starts. If the speaker emitted it, the tool announcement would overwrite the `tool` state and hide the tool icon while the tool is still running. `idle` still comes from the speaker when the reply's audio has finished. --- ## File structure | File | Repo | Status | Responsibility | |------|------|--------|----------------| | `assistant.py` | desktop-assistant | modify | level maths, events socket, notifications, wiring | | `examples/config.toml`, `README.md`, `AGENTS.md` | desktop-assistant | modify | `[overlay]` options, docs, findings | | `assistant/shell.qml` | quickshell | create | shell root | | `assistant/Assistant.qml` | quickshell | create | socket client, state, overlay window | | `assistant/Place.js`, `assistant/test_place.js` | quickshell | create | position maths and its node test | | `assistant/Theme.qml` | quickshell | symlink | `-> ../shared/Theme.qml` (repo rule: never a copy) | | `assistant/README.md` | quickshell | create | what it is, protocol pointer, run/test | --- ### Task 1: level maths **Files:** Modify `assistant.py` (imports, constants after `TOOL_SCHEMA`, helpers after `latin()`, `test()`) - [ ] **Step 1: failing test.** Append to `test()` before `print("ok")`: ```python import numpy as np assert scale(0) == 0.0 and scale(1) == 0.0 # -90 dBFS clamps to 0 assert scale(32768) == 1.0 assert abs(scale(32768 * 10 ** (-30 / 20)) - 0.5) < 1e-9 assert pcm_level(b"") == 0.0 assert pcm_level(array.array("h", [1000] * 100).tobytes()) == scale(1000) assert pcm_level(b"\x00") == 0.0 # half a sample is ignored env = envelope(np.zeros(24000, dtype=np.float32), 24000) assert len(env) == 30 and max(env) == 0.0 # 1 s at 30 frames/s env = envelope(np.full(24000, 0.5, dtype=np.float32), 24000) assert all(abs(v - scale(16384)) < 1e-6 for v in env) ``` - [ ] **Step 2:** Run `python3 assistant.py --test`. Expected: `NameError: name 'scale' is not defined`. - [ ] **Step 3: implement.** Add `import array` (before `import ctypes`) and `import math` (after `import json`) to the imports. After `TOOL_SCHEMA = schema(TOOLS) or None` add: ```python FRAME = 1 / 30 # one audio level per frame, seconds ``` After `latin()` add: ```python def scale(rms): # Loudness as the overlay draws it: -60 dBFS and below is 0, full scale # is 1. Logarithmic, so quiet speech still moves the ring. if rms <= 0: return 0.0 return max(0.0, min(1.0, (20 * math.log10(rms / 32768) + 60) / 60)) def pcm_level(data): # 16-bit little-endian mono, as arecord writes it; a trailing odd byte is # half a sample and ignored. samples = array.array("h", data[: len(data) // 2 * 2]) if not samples: return 0.0 return scale(math.sqrt(sum(s * s for s in samples) / len(samples))) def envelope(audio, sr): # One level per FRAME of float audio in -1..1 (Kokoro's output). n = int(sr * FRAME) frames = audio[: len(audio) // n * n].reshape(-1, n) return [scale(float(r) * 32768) for r in (frames.astype("float64") ** 2).mean(axis=1) ** 0.5] ``` - [ ] **Step 4:** Run `python3 assistant.py --test`. Expected: `ok`. - [ ] **Step 5: commit** ```bash git add assistant.py git commit -m "Compute audio levels for the overlay Co-Authored-By: Claude Opus 5.5 " git push ``` --- ### Task 2: say why a press was ignored **Files:** Modify `assistant.py` (`presenting()`, new `notify_ignored()`, `main()`, `test()`) - [ ] **Step 1: failing test.** In `test()`, replace the existing presenting block: ```python with tempfile.TemporaryDirectory() as d: assert not presenting(d) for name, value, want in (("gaming", "1", True), ("gaming", "0", False), ("presentation", "1\n", True)): with open(os.path.join(d, f"status.{name}"), "w") as f: f.write(value) assert presenting(d) == want, (name, value) ``` with: ```python with tempfile.TemporaryDirectory() as d: assert presenting(d) == "" for name, value, want in (("gaming", "1", "gaming"), ("gaming", "0", ""), ("presentation", "1\n", "presentation")): with open(os.path.join(d, f"status.{name}"), "w") as f: f.write(value) assert presenting(d) == want, (name, value) ``` - [ ] **Step 2:** Run `python3 assistant.py --test`. Expected: `AssertionError` (`presenting` returns `False`, not `""`). - [ ] **Step 3: implement.** In `presenting()`, replace `return flag("presentation") or flag("gaming")` with: ```python # Why the press is ignored, for the notification; "" when it is not. return "gaming" if flag("gaming") else "presentation" if flag("presentation") else "" ``` After `presenting()` add: ```python notify_id = "" def notify_ignored(reason): # Normal urgency: DND (on during presentation mode) holds it back from # popping over a call or a game, and it waits in the notification list. # Each press replaces the last (-r), so one game makes one entry. global notify_id cmd = ["notify-send", "-a", "desktop-assistant", "-u", "normal", "-p"] if notify_id: cmd += ["-r", notify_id] cmd += ["Voice assistant paused", f"The button press was ignored: {reason} mode is on."] r = subprocess.run(cmd, capture_output=True, text=True) notify_id = r.stdout.strip() or notify_id ``` In `main()`, replace: ```python if on and presenting(): log("presentation mode: ignored") elif on: ``` with: ```python reason = presenting() if on else "" if reason: log(f"{reason} mode: ignored") notify_ignored(reason) elif on: ``` - [ ] **Step 4:** Run `python3 assistant.py --test`. Expected: `ok`. Then check the notification by hand: `python3 -c "import assistant as a; a.notify_ignored('gaming'); a.notify_ignored('gaming'); print(a.notify_id)"`. Expected: an id printed, and one "Voice assistant paused" notification (the second replaced the first; with DND on it is in the drawer list, not popped). Ask the user to confirm there is one entry, not two. - [ ] **Step 5: commit** ```bash git add assistant.py git commit -m "Notify when a press is ignored in presentation or gaming mode Co-Authored-By: Claude Opus 5.5 " git push ``` --- ### Task 3: events socket **Files:** Modify `assistant.py` (`DEFAULTS`, imports, new block after `notify_ignored()`, `ask()`, `test()`) - [ ] **Step 1: failing test.** Append to `test()` before `print("ok")`: ```python import socket a, b = socket.socketpair() a.setblocking(False) clients.append(a) emit({"type": "ctx", "used": 1, "max": 2}) assert json.loads(b.recv(4096)) == {"type": "ctx", "used": 1, "max": 2} emit({"type": "state", "state": "tool", "tool": "search"}) assert now_state == {"state": "tool", "tool": "search"} b.recv(4096) b.close() emit({"type": "state", "state": "idle"}) # the dead client is dropped emit({"type": "state", "state": "idle"}) assert a not in clients and now_state == {"state": "idle"} with tempfile.TemporaryDirectory() as d: path = os.path.join(d, "s.sock") serve(path) assert os.stat(path).st_mode & 0o777 == 0o600 c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) c.connect(path) f = c.makefile() first = json.loads(f.readline()) assert first["type"] == "hello" and first["name"] == CFG["overlay"]["name"] assert first["state"] == "idle" and "ctx_max" in first for _ in range(100): # accept() appends just after hello if clients: break time.sleep(0.01) emit({"type": "level", "who": "user", "value": 0.5}) assert json.loads(f.readline()) == {"type": "level", "who": "user", "value": 0.5} c.close() ``` - [ ] **Step 2:** Run `python3 assistant.py --test`. Expected: `NameError: name 'clients' is not defined`. - [ ] **Step 3: implement.** Add `import socket` (after `import signal`) and `import urllib.parse` (after `import unicodedata`). In `DEFAULTS`, after `"env": {},` add: ```python "overlay": {"name": "Assistant", "position": "center", "hide_delay": 3.0, "user_color": "accent", "assistant_color": "green"}, ``` After `FRAME = ...` add: ```python SOCK = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "desktop-assistant.sock") ``` After `notify_ignored()` add: ```python # The overlay's feed: JSON lines on a Unix socket, one event per line. With # no client connected every event is dropped, so the voice loop never waits # on a UI. clients, clients_lock = [], threading.Lock() now_state = {"state": "idle"} # repeated in hello for a late client ctx = {"model": None, "max": 0} # context size of the model in use def emit(event): if event["type"] == "state": now_state.clear() now_state.update({k: v for k, v in event.items() if k != "type"}) line = (json.dumps(event) + "\n").encode() with clients_lock: for c in clients[:]: try: c.sendall(line) # non-blocking: a stalled client errors except OSError: clients.remove(c) c.close() def hello(): return {"type": "hello", **CFG["overlay"], "ctx_max": ctx["max"], **now_state} def serve(path=SOCK): try: os.unlink(path) # left over from a crash except FileNotFoundError: pass srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) old = os.umask(0o177) # 0600: only this user reads the stream try: srv.bind(path) finally: os.umask(old) srv.listen() def accept(): while True: c, _ = srv.accept() try: c.sendall((json.dumps(hello()) + "\n").encode()) except OSError: c.close() continue c.setblocking(False) with clients_lock: clients.append(c) threading.Thread(target=accept, daemon=True).start() def context_size(model): # The router can swap models, so ask again whenever the id changes. if ctx["model"] != model: url = f"{CFG['llm'].removesuffix('/v1')}/props?model={urllib.parse.quote(model)}" try: with urllib.request.urlopen(url, timeout=5) as r: ctx["max"] = json.load(r)["default_generation_settings"]["n_ctx"] except (OSError, KeyError, ValueError): ctx["max"] = 0 ctx["model"] = model return ctx["max"] ``` In `ask()`, replace: ```python body = {"model": llm_model(), "messages": messages, "stream": True, ``` with: ```python model = llm_model() context_size(model) body = {"model": model, "messages": messages, "stream": True, ``` and replace: ```python log(f"context: {chunk['usage']['prompt_tokens']} prompt tokens") ``` with: ```python used = chunk["usage"]["prompt_tokens"] log(f"context: {used} prompt tokens") emit({"type": "ctx", "used": used, "max": ctx["max"]}) ``` - [ ] **Step 4:** Run `python3 assistant.py --test`. Expected: `ok`. Then: `python3 -c "import assistant as a; print(a.context_size(a.llm_model()))"`. Expected: `32768` with the resident Gemma. - [ ] **Step 5: commit** ```bash git add assistant.py git commit -m "Broadcast assistant events on a Unix socket for the overlay Co-Authored-By: Claude Opus 5.5 " git push ``` --- ### Task 4: emit state and levels from the voice loop **Files:** Modify `assistant.py` (`speaker()`, new `user_levels()` and `level_emitter()`, `reply()`, `main()`) - [ ] **Step 1: user levels and the emitter.** After `speaker()` add: ```python def user_levels(rec, mine): # The user's waves: the loudness of what arecord wrote since last time. pos = 0 while rec.poll() is None and turn == mine: time.sleep(FRAME) try: with open(RAW, "rb") as f: f.seek(pos) data = f.read() except OSError: continue if len(data) >= 2: pos += len(data) // 2 * 2 emit({"type": "level", "who": "user", "value": pcm_level(data)}) def level_emitter(lq): # The reply's waves, each frame sent when its audio plays rather than when # it was synthesised (synthesis runs ahead of playback). while True: t, due, value = lq.get() while t == turn and (wait := due - time.monotonic()) > 0: time.sleep(min(wait, 0.05)) if t == turn: emit({"type": "level", "who": "assistant", "value": value}) ``` - [ ] **Step 2: speaker clock.** Change `def speaker(q):` to `def speaker(q, lq):`. Replace ` play, playing = None, None # aplay and the turn it speaks for` with: ```python play, playing = None, None # aplay and the turn it speaks for start, written = 0.0, 0 # playback clock of the open stream ``` Replace the end-of-reply block: ```python if item is None: # end of reply if play: play.stdin.close() play.wait() play = None continue ``` with: ```python if item is None: # end of reply if play: play.stdin.close() play.wait() play = None if t == turn: emit({"type": "state", "state": "idle"}) continue ``` Replace the silence block's write line ` play.stdin.write(bytes(int(sr * item) * 2))` with: ```python play.stdin.write(bytes(int(sr * item) * 2)) written += int(sr * item) ``` Replace: ```python playing, player = t, play # A new stream wakes the suspended sink and the first ~0.2 s is # lost: lead with silence so it eats that, not the first word. play.stdin.write(bytes(int(sr * 0.3) * 2)) # The pipe buffer lets synthesis of the next sentence overlap playback. play.stdin.write((audio.clip(-1, 1) * 32767).astype("/events.log` (levels filtered to keep it readable) and count levels separately with `socat -u UNIX-CONNECT:... - | grep -c level` over one question. Ask the user for a search question. Expected sequence in events.log: `hello`, `listening`, `transcribing`, `thinking`, `ctx`, `tool` with `"tool": "search"`, `thinking`, `ctx`, `speaking`, `idle`. Expected levels: user frames during the press, assistant frames during speech, roughly 30 per second of each. Also check barge-in still works by ear. - [ ] **Step 7: commit** ```bash git add assistant.py git commit -m "Emit state, context and audio levels from the voice loop speaking comes from the reply thread, not the speaker: the tool announcement would otherwise replace the tool state while the tool is still running. Co-Authored-By: Claude Opus 5.5 " git push ``` --- ### Task 5: quickshell position helper **Files (quickshell repo, `../quickshell`):** Create `assistant/Place.js`, `assistant/test_place.js` - [ ] **Step 1: failing test.** Create `assistant/test_place.js`: ```js // Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // Run: node assistant/test_place.js // Place.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 + "/Place.js", "utf8") .replace(/^\.pragma library$/m, ""); const P = new Function(src + "\nreturn { at };")(); assert.deepStrictEqual(P.at("center", 1920, 1080, 360, 24), { x: 780, y: 360 }); assert.deepStrictEqual(P.at("top", 1920, 1080, 360, 24), { x: 780, y: 24 }); assert.deepStrictEqual(P.at("bottom", 1920, 1080, 360, 24), { x: 780, y: 696 }); assert.deepStrictEqual(P.at("top-left", 1920, 1080, 360, 24), { x: 24, y: 24 }); assert.deepStrictEqual(P.at("top-right", 1920, 1080, 360, 24), { x: 1536, y: 24 }); assert.deepStrictEqual(P.at("bottom-left", 1920, 1080, 360, 24), { x: 24, y: 696 }); assert.deepStrictEqual(P.at("bottom-right", 1920, 1080, 360, 24), { x: 1536, y: 696 }); // An unknown position is the centre, not an error. assert.deepStrictEqual(P.at("middle", 1920, 1080, 360, 24), { x: 780, y: 360 }); console.log("ok"); ``` - [ ] **Step 2:** Run `node assistant/test_place.js` from the quickshell repo. Expected: `ENOENT` for `Place.js`. - [ ] **Step 3: implement.** Create `assistant/Place.js`: ```js // Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. .pragma library // Top-left corner of a size x size square on a W x H screen, margin away // from the edges its position names. Anything unknown is the centre. function at(position, W, H, size, margin) { const x = position.endsWith("left") ? margin : position.endsWith("right") ? W - size - margin : (W - size) / 2; const y = position.startsWith("top") ? margin : position.startsWith("bottom") ? H - size - margin : (H - size) / 2; return { x, y }; } ``` - [ ] **Step 4:** Run `node assistant/test_place.js`. Expected: `ok`. - [ ] **Step 5:** No commit yet; the component is committed whole in Task 6. --- ### Task 6: the overlay component **Files (quickshell repo):** Create `assistant/shell.qml`, `assistant/Assistant.qml`, `assistant/README.md`; symlink `assistant/Theme.qml`. - [ ] **Step 1: theme link.** From the quickshell repo: `ln -s ../shared/Theme.qml assistant/Theme.qml`. - [ ] **Step 2: shell.qml** ```qml // Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell ShellRoot { Assistant {} } ``` - [ ] **Step 3: Assistant.qml** ```qml // Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell import Quickshell.Io import Quickshell.Wayland import Quickshell.Hyprland import QtQuick import "Place.js" as Place // The voice assistant's overlay. desktop-assistant broadcasts its state, // context use and audio levels as JSON lines on a Unix socket; this only // listens, and keeps reconnecting while the assistant is down. Scope { id: root // Replaced by the assistant's hello; these render only before it. property string name: "Assistant" property string position: "center" property real hideDelay: 3 property string userColor: "accent" property string assistantColor: "green" property int ctxUsed: 0 property int ctxMax: 0 property string state: "idle" property string tool: "" property var levels: [] // newest last, at most 64 property bool shown: false property var shownScreen: Quickshell.screens[0] // Faded, not switched: the window stays loaded until the fade has run. property real fade: shown ? 1 : 0 Behavior on fade { NumberAnimation { duration: 200 } } // Colour roles are palette names from udt, so a theme change recolours // the waves live. An unknown name falls back to the accent. function colorOf(role) { return Theme[role] ?? Theme.accent; } function handle(line) { let e; try { e = JSON.parse(line); } catch (err) { return; } if (e.type === "hello") { root.name = e.name; root.position = e.position; root.hideDelay = e.hide_delay; root.userColor = e.user_color; root.assistantColor = e.assistant_color; root.ctxMax = e.ctx_max; root.setState(e.state, e.tool ?? ""); } else if (e.type === "state") { root.setState(e.state, e.tool ?? ""); } else if (e.type === "ctx") { root.ctxUsed = e.used; root.ctxMax = e.max; } else if (e.type === "level") { const l = root.levels.slice(-63); l.push({ who: e.who, value: e.value }); root.levels = l; } } function setState(s, t) { root.state = s; root.tool = t; if (s === "listening" && !root.shown) { // The monitor in focus when it appears, not wherever it last was. const focused = Hyprland.focusedMonitor?.name; root.shownScreen = Quickshell.screens.find(x => x.name === focused) ?? Quickshell.screens[0]; root.levels = []; root.shown = true; } if (s === "idle") hideTimer.restart(); else hideTimer.stop(); } Timer { id: hideTimer interval: root.hideDelay * 1000 onTriggered: root.shown = false } Socket { id: sock path: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/desktop-assistant.sock` connected: true onConnectionStateChanged: if (!connected) root.shown = false parser: SplitParser { onRead: line => root.handle(line) } } // The assistant may start after this shell, or restart: keep trying. Timer { interval: 2000 repeat: true running: !sock.connected onTriggered: sock.connected = true } // A config with no visible window exits, reporting nothing. This 1x1 // click-through window holds the shell open while the overlay is hidden. PanelWindow { anchors { top: true; left: true } implicitWidth: 1 implicitHeight: 1 color: "transparent" exclusionMode: ExclusionMode.Ignore mask: Region {} WlrLayershell.keyboardFocus: WlrKeyboardFocus.None } LazyLoader { active: root.fade > 0 PanelWindow { screen: root.shownScreen anchors { top: true; bottom: true; left: true; right: true } color: "transparent" exclusionMode: ExclusionMode.Ignore mask: Region {} WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "quickshell-assistant" WlrLayershell.keyboardFocus: WlrKeyboardFocus.None Item { id: orb readonly property int size: 360 readonly property var spot: Place.at(root.position, parent.width, parent.height, size, 24) x: spot.x y: spot.y width: size height: size opacity: root.fade // The waves: one radial bar per level, newest at the top and // older ones following clockwise, so the ring moves like a // waveform. Bar colour says who was talking. Canvas { id: waves anchors.fill: parent onPaint: { const ctx = getContext("2d"); ctx.reset(); const c = width / 2, r0 = 86, reach = 90, n = 64; const ls = root.levels; ctx.lineWidth = 4; ctx.lineCap = "round"; ls.forEach((l, i) => { const a = -Math.PI / 2 + (ls.length - 1 - i) * 2 * Math.PI / n; const len = 3 + l.value * reach; ctx.strokeStyle = `${root.colorOf(l.who === "user" ? root.userColor : root.assistantColor)}`; ctx.beginPath(); ctx.moveTo(c + r0 * Math.cos(a), c + r0 * Math.sin(a)); ctx.lineTo(c + (r0 + len) * Math.cos(a), c + (r0 + len) * Math.sin(a)); ctx.stroke(); }); } Connections { target: root function onLevelsChanged() { waves.requestPaint(); } } } Rectangle { anchors.centerIn: parent width: 160 height: 160 radius: 80 color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.8) border.color: root.colorOf(root.assistantColor) border.width: 2 Column { anchors.centerIn: parent spacing: 4 Text { anchors.horizontalCenter: parent.horizontalCenter text: root.name color: Theme.text font.family: Theme.fontFamily font.pixelSize: Theme.fontSize + 2 font.bold: true } // nf-fa-wrench, present in Inconsolata Nerd Font. Text { anchors.horizontalCenter: parent.horizontalCenter visible: root.state === "tool" text: "" color: Theme.peach font.family: Theme.iconFamily font.pixelSize: 22 } Text { anchors.horizontalCenter: parent.horizontalCenter text: root.state === "tool" ? root.tool : root.ctxMax > 0 ? `${root.ctxUsed} / ${root.ctxMax}` : `${root.ctxUsed}` color: Theme.subtext font.family: Theme.fontFamily font.pixelSize: Theme.fontSize - 3 } } } } } } } ``` - [ ] **Step 4: README.md** for the component: ```markdown # assistant Overlay for [desktop-assistant](https://example.org/desktop-assistant), the local voice assistant. It appears when the mic button is pressed and shows the assistant's name, its model context use, the tool running, and a ring of sound waves that follows the real audio: the user's voice in one palette colour, the reply in another. It fades out a few seconds after the reply. The assistant owns a Unix socket, `$XDG_RUNTIME_DIR/desktop-assistant.sock`, and writes one JSON event per line (`hello`, `state`, `ctx`, `level`); this component only reads it and reconnects every 2 s while the assistant is down. Name, position, fade delay and the two colour roles come from the assistant's `config.toml` `[overlay]` table, in the `hello` event. The protocol is specified in the desktop-assistant repo, `docs/superpowers/specs/2026-09-26-overlay-design.md`. Run: `qs -p `. Test the position maths: `node assistant/test_place.js`. ``` Replace `https://example.org/desktop-assistant` with the real public URL if the user gives one; otherwise leave the example.org placeholder (no personal host names in this repo). - [ ] **Step 5: load check.** Run `node assistant/test_place.js` (expect `ok`). Start the component as a harness-owned background command: `qs -p /assistant 2>&1 | tee /qs-assistant.log`. Expected in the log: `Configuration Loaded` and no QML errors (a `Socket` error while the assistant is down is expected and retried). If `Socket`'s `connected`, `connectionStateChanged` or `parser` do not exist under those names, the log names the property: fix it against Quickshell 0.3.1's Socket and note the real names in the component README. - [ ] **Step 6: visual check with the user.** With the assistant running (Task 4), ask the user to press, speak, and ask a search question. Ask them to confirm by eye: overlay appears on the focused monitor, waves follow their voice in the accent colour and the reply in green, the tool icon and "search" show during the search, the context line reads `N / 32768`, it fades about 3 s after the reply, a barge-in keeps it up, a press during a game shows nothing (only the notification), and it reconnects after the assistant restarts. Fix what they report before committing. - [ ] **Step 7: commit in the quickshell repo** ```bash git add assistant/ git commit -m "feat(assistant): overlay for the voice assistant Reads desktop-assistant's event socket: state, context use, the tool running, and audio levels drawn as a wave ring around a circle, the user's voice and the reply in two palette colours. Standalone like the other components; it reconnects while the assistant is down, and the assistant runs the same without it. Co-Authored-By: Claude Opus 5.5 " git push ``` (Check the quickshell repo's recent log for its attribution style first and match it.) --- ### Task 7: autostart and docs **Files:** the user's `~/.config/hypr/sections/autostart.lua` (live config, not in any repo), desktop-assistant `examples/config.toml`, `README.md`, `AGENTS.md`; quickshell `AGENTS.md` component list. - [ ] **Step 1: autostart.** Show the user the line to add next to the other quickshell components and add it only after they confirm (outward-facing, their live config): ```lua hl.exec_cmd("qs -p /home//Programming/GIT/quickshell/assistant") ``` (written with the real absolute path, matching the neighbouring lines). - [ ] **Step 2: example config.** In desktop-assistant `examples/config.toml`, before the `[env]` block, add: ```toml # The quickshell overlay (quickshell repo, assistant/). udt palette names # for the colours. # [overlay] # name = "Assistant" # position = "center" # center, top, bottom, top-left, top-right, bottom-left, bottom-right # hide_delay = 3.0 # seconds idle before it fades out # user_color = "accent" # assistant_color = "green" ``` - [ ] **Step 3: README.md** (desktop-assistant): add a short "Overlay" section after "Configuration and tools": the socket path, that the assistant runs the same with no overlay, that the overlay lives in the quickshell repo's `assistant/` component, and the `[overlay]` options. - [ ] **Step 4: quickshell AGENTS.md:** add `assistant/ overlay for the voice assistant, fed by its event socket` to the component list at the top. - [ ] **Step 5: AGENTS.md findings** (desktop-assistant): a "### Overlay (2026-09-26)" section before "### Barge-in" with what the live checks in Tasks 4 and 6 measured (event sequence, level rate, anything the user reported and how it was fixed). Remove nothing else. - [ ] **Step 6: commit both repos** (desktop-assistant: examples/config.toml, README.md, AGENTS.md; quickshell: AGENTS.md), each with its own message, and push.