# AGENTS.md Guidance for agents working in this repository. ## What this is Quickshell components for a Hyprland desktop, one per directory, each a complete shell in its own right. They are not modules of a single bar: any of them runs alone, and running one does not require the others. volume-osd/ volume for output and input, plus what is playing vm-manager/ libvirt drawer: state, live stats, snapshots appearance/ wallpaper picker and colour scheme switcher mail-overview/ notmuch unread counts per account, waybar icon and drawer They are started from `~/.config/hypr/sections/autostart.lua` and keep running for the whole session. ## The rule that bites first **A quickshell config with no visible window exits.** These components are hidden most of the time, so each holds itself open with a 1x1 transparent `PanelWindow` with `mask: Region {}`, which is click-through and draws nothing. Without it the shell loads, logs `Configuration Loaded`, reports no error, and quits. The symptom is never an error message: it is a keybind that appears to do nothing, or a panel that never paints. This was removed once during development because a process check was measuring the wrong thing, and two components broke in exactly that way. Any new component needs the same window. ## Verifying, and how not to Two traps cost real time in this repo, both about measurement rather than code. **A detached `qs` does not survive an agent's tool call.** Starting one with `&`, `nohup` or `setsid -f` and then checking `pgrep` in a later call reports `DEAD` regardless of whether the config is sound. That reads exactly like the no-visible-window exit above and sent a debugging session in the wrong direction for a dozen calls. Start it so the harness owns the process, and confirm with the log rather than a later `pgrep`. **The process is called `qs`, not `quickshell`.** Both binaries ship, but `qs` is what runs, so `pkill -x quickshell` and `pgrep -x quickshell` match nothing and silently succeed. During development this meant every "stopped" was a lie and every restart stacked another instance: 47 accumulated before the user noticed. It also produced "0 processes" readings that were mistaken twice for the shell exiting, and sent a debugging session after a bug that was not there. Use `pkill -x qs` and `pgrep -cx qs`, and check the count after. **`pkill -f` matches the agent's own shell.** The working directory is in the command line, so `pkill -f qs` or `pkill -f "foo.qml"` kills the caller and returns 144. Always `-x`, never `-f`. For anything visual, ask. Screenshots of a transient OSD are a race, and the user has the screen. ## Per-component notes Each directory's README carries its own, and they are worth reading before changing that component. The ones that generalise: - **PipeWire nodes report their initial volume before `ready` goes true.** The `ready` check alone suppresses the startup values. An extra guard on top of it ate the user's first keypress instead. - **`PwObjectTracker` is not optional.** Node properties only stay current while something binds the node; without it the volume reads once and goes stale. - **playerctld publishes a duplicate of every MPRIS player** under its own bus name. Filter by `dbusName`, not by identity. - **Key events reach a focused item, not a window.** Setting `WlrLayershell.keyboardFocus` is necessary but not sufficient: `Keys.onEscapePressed` on a `PanelWindow` never fires. - **A `Row` sizes to its children, not its parent.** Fixed child widths inside one overflowed the panel and pushed a button past its edge. - **QML's JS engine has no `String.matchAll`.** It throws, and inside a `try` that looks like a parser quietly returning nothing. Use an `exec` loop. - **Assigning `running = true` to a `Process` that is already running does nothing.** Reusing one `Process` for a sequence of commands needs `running = false` immediately before each start. - **`notmuch` fails two different ways and only one is detectable.** A rejected query prints nothing and exits 1; a query Xapian merely misparses returns a plausible wrong number and exits 0 (`tag:unread and ((` gave 41). Validate the output as an integer, which catches the first, where empty output would otherwise render as an empty inbox. Nothing catches the second, so keep such queries as fixed strings. - **notmuch deduplicates by message id, so one message can have several paths.** A message that arrived at two configured addresses is counted by both accounts under a `path:` glob, and per-account counts then sum above the total. The `account-*` tag is a property of the message, so it is singular. - **Xapian replaces files on commit.** A watch held on a filename inside the database directory dies with the file; watch the directory for `close_write,moved_to` instead, and debounce, because one commit touches several files. - **An INI section body is not "everything up to the next `[`".** Values can themselves contain brackets: `qtmaildir.conf` has folders named `[Gmail]/Bozze`, which ended a section before its `label` and made three of five accounts silently display their raw key. Walk lines instead. A first fix using a lazy quantifier and a lookahead dropped every label, which is the argument for the boring version. - **libvirt's own memory and disk figures are not what they look like.** `balloon.current` is memory allocated to the VM and reads full forever; `block.allocation` is qcow2 growth on the host, not usage inside the guest. The real numbers come from qemu-guest-agent, and the panel shows a dash rather than substituting the host-side ones. ## Theme No component defines a palette. `udt-accent`, in the `unified-desktop-theme` repo alongside this one, writes `~/.cache/wal/udt-palette.qml` from its `palette.rasi`, carrying the whole Catppuccin Macchiato palette plus the accent snapped from the current wallpaper. `Theme.qml` parses and watches that file, so a palette edit recolours a running shell with no restart. What is left in `Theme.qml` is a fallback for before the file is read, and for a machine without unified-desktop-theme. Do not grow it into a second palette: that duplication is what generating the file removed. The file is parsed rather than imported because a generated QML singleton cannot be imported without a `qmldir` beside it, and the wal cache has no reason to carry one. There is one `Theme.qml`, in `shared/`, and each component holds a symlink to it. It was four copies that had already drifted: `vm-manager` was missing `surfaceAlt` and `volume-osd` was missing `green`, `yellow` and `surfaceAlt`, so the shared file is the superset and the two thin ones gained properties they never had. A symlink rather than a shared import path because a singleton outside the config directory needs a `qmldir`, which is the same friction that keeps the palette parsed rather than imported; quickshell follows the link and resolves the singleton with no qmldir and no consumer change. Editing any component's `Theme.qml` edits all four. Do not replace a link with a copy. ## Blur Translucency is set in QML; the frosting is the compositor's. Hyprland blurs a layer surface only when a rule names it, matched on the namespace the window sets, in `~/.config/hypr/sections/decorations.lua`. A new component that wants frosting needs its own `hl.layer_rule` and a distinct `WlrLayershell.namespace`. Without the rule it still works, rendering flat translucent. A panel that should sit below waybar rather than over it wants `exclusionMode: ExclusionMode.Normal` on its window, which respects waybar's exclusive zone without the component knowing the bar's height. Measured with `hyprctl layers`: waybar at `y=-540 h=42`, a `Normal` overlay on the same screen at `y=-498 h=1038`, starting exactly where the bar ends, so the backdrop never dims it. `mail-overview` does this; the other three use `ExclusionMode.Ignore` and cover the whole screen. ## Reloading | | How | | --- | --- | | a component's QML | quickshell hot-reloads on save; no restart | | palette | regenerate with `udt-accent `; watched, no restart | | Hyprland binds, layer rules, autostart | `hyprctl reload` | Hot reload covers adding and removing windows too: the keepalive window above was added to a running shell and took effect on save. `qs -p ` runs one directly. `qs -p ipc call ` reaches a running one, which is how `SUPER+v` opens the VM drawer. ## Conventions - GPLv2 only, with the header notice in every source file. - Commits carry the reasoning, not just the change: several of the notes above exist because a commit message explained why something was the way it was. - No home paths in committed files. A gitleaks hook blocks them and has been right every time; `~` in documentation, absolute paths only in the live Hyprland config, which is not in this repo.