# Keybind Reminder 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 `SUPER + k` overlay listing every live Hyprland bind with its description, grouped. **Architecture:** New quickshell component `keybinds/`, shaped like `window-switcher/`. Each opening runs `hyprctl binds -j`; a pure `Binds.js` decodes modmasks and groups binds by the `Group: Label` description. Descriptions are added to every `hl.bind` in `hypr-theme` (and the one in `conky-theme-udt`). **Tech Stack:** Quickshell 0.3.1 QML, plain JS (`.pragma library`), Hyprland 0.56.2 Lua config, `node` for the one check. Spec: `docs/superpowers/specs/2026-09-25-keybinds-design.md`. Three repos are touched, each committed on its own: `quickshell` (this one), `../hypr-theme`, `../conky-theme-udt`. --- ### Task 1: `Binds.js` and its check **Files:** - Create: `keybinds/test_binds.js` - Create: `keybinds/Binds.js` - [ ] **Step 1: Write the failing check** `keybinds/test_binds.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 keybinds/test_binds.js // Binds.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 + "/Binds.js", "utf8") .replace(/^\.pragma library$/m, ""); const B = new Function(src + "\nreturn { mods, keys, group };")(); assert.deepStrictEqual(B.mods(0), []); assert.deepStrictEqual(B.mods(65), ["SUPER", "SHIFT"]); assert.deepStrictEqual(B.mods(13), ["CTRL", "ALT", "SHIFT"]); assert.strictEqual(B.keys({ modmask: 69, key: "F1" }), "SUPER + CTRL + SHIFT + F1"); assert.strictEqual(B.keys({ modmask: 0, key: "XF86AudioMute" }), "XF86AudioMute"); assert.deepStrictEqual(B.group([ { modmask: 64, key: "s", description: "" }, { modmask: 64, key: "w", description: "Apps: Browser" }, { modmask: 0, key: "XF86AudioMute", description: "Media: Mute" }, { modmask: 64, key: "e", description: "Apps: Files" }, { modmask: 8, key: "x", description: "No colon here" }, ]), [ { name: "Apps", rows: [ { keys: "SUPER + w", label: "Browser" }, { keys: "SUPER + e", label: "Files" }, ] }, { name: "Media", rows: [{ keys: "XF86AudioMute", label: "Mute" }] }, { name: "Other", rows: [{ keys: "ALT + x", label: "No colon here" }] }, { name: "Undescribed", rows: [{ keys: "SUPER + s", label: "no description" }] }, ]); console.log("ok"); ``` - [ ] **Step 2: Run it, expect failure** Run: `node keybinds/test_binds.js` Expected: `ENOENT: no such file or directory ... Binds.js` - [ ] **Step 3: Write `keybinds/Binds.js`** ```js .pragma library // 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. // hyprctl binds -j into display groups. Pure, so node can check it: // see test_binds.js. // Modmask bits, in the order they are written in keybindings.lua. const MODS = [[64, "SUPER"], [4, "CTRL"], [8, "ALT"], [1, "SHIFT"]]; function mods(mask) { return MODS.filter(m => mask & m[0]).map(m => m[1]); } function keys(bind) { return mods(bind.modmask).concat([bind.key]).join(" + "); } // A description reads "Group: Label". A bind without one still shows, last, // so a gap in the config is visible rather than silently missing. function group(binds) { const groups = []; const byName = {}; const undescribed = []; for (const b of binds) { const d = b.description || ""; if (!d) { undescribed.push({ keys: keys(b), label: "no description" }); continue; } const i = d.indexOf(": "); const name = i < 0 ? "Other" : d.slice(0, i); const label = i < 0 ? d : d.slice(i + 2); if (!byName[name]) { byName[name] = { name: name, rows: [] }; groups.push(byName[name]); } byName[name].rows.push({ keys: keys(b), label: label }); } if (undescribed.length) groups.push({ name: "Undescribed", rows: undescribed }); return groups; } ``` Note: `.pragma library` must be the first line of a QML JS file, so it sits above the licence header. - [ ] **Step 4: Run it, expect pass** Run: `node keybinds/test_binds.js` Expected: `ok` - [ ] **Step 5: Commit** (in `quickshell`) ```bash git add keybinds/Binds.js keybinds/test_binds.js git commit -m "feat(keybinds): group hyprctl binds by description" ``` --- ### Task 2: the overlay component **Files:** - Create: `keybinds/shell.qml` - Create: `keybinds/Keybinds.qml` - Create: `keybinds/Theme.qml` (symlink) - Create: `keybinds/README.md` - Modify: `README.md` (Implementations list), `AGENTS.md` (component list) - [ ] **Step 1: Theme symlink** ```bash ln -s ../shared/Theme.qml keybinds/Theme.qml ``` - [ ] **Step 2: `keybinds/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 import Quickshell.Io ShellRoot { Keybinds { id: keybinds } // Bound to SUPER + k in Hyprland: // `qs -p ipc call keybinds toggle` IpcHandler { target: "keybinds" function toggle() { keybinds.toggle(); } function show() { keybinds.open = true; } function close() { keybinds.close(); } } } ``` - [ ] **Step 3: `keybinds/Keybinds.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 QtQuick import "Binds.js" as Binds Scope { id: root property bool open: false property string monitor: "DP-1" property var groups: [] // Non-empty while loading or after a failure; the grid shows only when // this is empty, so a stale list from the last opening never shows. property string status: "Loading..." readonly property var screenObj: Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] function toggle() { root.open = !root.open; } function close() { root.open = false; } // Asked afresh on every opening, so the list is what Hyprland has loaded // right now: an edit to keybindings.lua shows after `hyprctl reload`, and // not before, which is when it becomes true. onOpenChanged: if (open) { root.status = "Loading..."; // A Process already running ignores running = true. proc.running = false; proc.running = true; } Process { id: proc command: ["hyprctl", "binds", "-j"] stdout: StdioCollector { onStreamFinished: { try { root.groups = Binds.group(JSON.parse(text)); root.status = ""; } catch (e) { root.status = "hyprctl binds -j gave nothing readable"; } } } } // 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.open PanelWindow { screen: root.screenObj anchors { top: true; left: true; right: true; bottom: true } color: "transparent" exclusionMode: ExclusionMode.Ignore WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "quickshell-keybinds" WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive // Keys reach a focused item, not a window. Item { anchors.fill: parent focus: true Keys.onEscapePressed: root.close() Rectangle { anchors.fill: parent color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82) MouseArea { anchors.fill: parent onClicked: root.close() } Text { anchors.centerIn: parent visible: root.status !== "" text: root.status color: Theme.text font.family: Theme.fontFamily font.pixelSize: 22 } Flow { anchors.centerIn: parent width: parent.width * 0.9 spacing: 40 visible: root.status === "" Repeater { model: root.groups Column { required property var modelData width: 440 spacing: 4 Text { text: modelData.name color: Theme.accent font.family: Theme.fontFamily font.pixelSize: 18 font.bold: true bottomPadding: 6 } Repeater { model: modelData.rows // Fixed widths summing to the column's: // a Row sizes to its children. Row { required property var modelData spacing: 12 Text { width: 240 elide: Text.ElideLeft text: modelData.keys color: Theme.subtext font.family: Theme.fontFamily font.pixelSize: 14 } Text { width: 188 elide: Text.ElideRight text: modelData.label color: Theme.text font.family: Theme.fontFamily font.pixelSize: 14 } } } } } } } } } } } ``` - [ ] **Step 4: `keybinds/README.md`** ````markdown # keybinds Every Hyprland keybind with what it does, grouped, over a dimmed screen. SUPER+k opens it, Escape or a click closes it. The list is `hyprctl binds -j`, asked on every opening, so it is what Hyprland has loaded: binds generated in Lua loops included, and an edit to `keybindings.lua` shows once `hyprctl reload` makes it real, not before. There is no cache to go stale. `hyprctl` reports every Lua action as `__lua` with an opaque number, so the text comes from each bind's `description` option, written `Group: Label`: hl.bind("SUPER + w", hl.dsp.exec_cmd(P.browser), { description = "Apps: Browser" }) The part before `: ` is the heading. A bind with no description is listed last under "Undescribed", so a missing one shows instead of vanishing. ## Running qs -p ./keybinds qs -p ./keybinds ipc call keybinds toggle ## Blur Optional, in the Hyprland config: hl.layer_rule({ name = "blur-keybinds", match = { namespace = "^(quickshell-keybinds)$" }, blur = true, xray = false, ignore_alpha = 0.1, }) ## Check node keybinds/test_binds.js ```` - [ ] **Step 5: list the component** In `README.md` and `AGENTS.md`, add under the component list, after `window-switcher/`: ``` keybinds/ live keybind reminder, on SUPER+k ``` In `README.md` also change "They are three separate shells" to "They are separate shells". - [ ] **Step 6: smoke check** Run in the background, harness-owned (not `&`/`setsid`, see AGENTS.md): `timeout 20 qs -p keybinds` with `run_in_background`, then `qs -p keybinds ipc call keybinds show`, then read its output. Expected: `Configuration Loaded`, no `ReferenceError`/`TypeError`. Then `qs -p keybinds ipc call keybinds close`. - [ ] **Step 7: Commit** (in `quickshell`) ```bash git add keybinds README.md AGENTS.md git commit -m "feat(keybinds): live keybind reminder overlay" ``` --- ### Task 3: descriptions and SUPER+k in `hypr-theme` **Files:** - Modify: `../hypr-theme/sections/keybindings.lua` (every `hl.bind`) - Modify: `../hypr-theme/sections/autostart.lua:30` - Modify: `../hypr-theme/sections/decorations.lua:67` - [ ] **Step 1: add a description to every bind** Append `, { description = "..." }` as the third argument, or add a `description = "..."` field to the existing options table. Exact text: | Bind | Description | | --- | --- | | SUPER + w | Apps: Browser | | SUPER + SHIFT + w | Apps: Private browser | | SUPER + r | Apps: Code editor | | SUPER + SHIFT + r | Apps: Typora | | SUPER + g | Apps: GIMP | | ALT + CTRL + SHIFT + l | Clipboard: Show as QR code | | CTRL + SHIFT + l | Clipboard: History | | ALT + Return | Apps: Terminal | | ALT + SHIFT + Return | Apps: SSH menu | | ALT + h | Layout: Cycle orientation | | CTRL + SUPER + r | Media: Mute microphone input | | ALT + w | Layout: Toggle group | | SUPER + XF86AudioPrev | Layout: Previous layout | | SUPER + XF86AudioNext | Layout: Next layout | | CTRL + SUPER + period | Layout: Move to next column | | CTRL + SUPER + comma | Layout: Move to previous column | | XF86AudioPlay | Media: Play / pause | | XF86AudioPrev | Media: Previous track | | XF86AudioNext | Media: Next track | | SUPER + XF86AudioRaiseVolume | Keyboard: Next layout | | SUPER + XF86AudioLowerVolume | Keyboard: Previous layout | | ALT + TAB | Desktop: Window switcher | | SUPER + h | Apps: GitHub repos | | SUPER + y | Apps: Passwords | | SUPER + l | Session: Lock screen | | SUPER + m | Apps: Signal | | SUPER + e | Apps: File manager | | SUPER + v | Desktop: VM manager | | SUPER + period | Apps: Emoji picker | | SUPER + Home | Apps: LLM chat | | XF86Calculator | Apps: Calculator | | XF86Mail | Apps: Mail | | XF86Search | Apps: Discord | | SUPER + Return | Desktop: Drawer | | SUPER + F5 | Notes: Open note | | SUPER + F6 | Notes: Add note | | SUPER + F7 | Notes: Edit note | | SUPER + F8 | Notes: Delete note | | ALT + SHIFT + r | Session: Reload Hyprland | | ALT + Escape | Session: Kill a window by click | | SUPER + x | Session: Power menu | | SUPER + SHIFT + s | Session: Screenshot menu | | SUPER + c | Windows: Close | | SUPER + SHIFT + Space | Windows: Toggle floating | | ALT + F2 | Apps: Launcher | | SUPER + left/right/up/down | Windows: Focus left / right / up / down | | loop, focus | Workspaces: Go to N (built as `"Workspaces: Go to " .. g.wss[i]`) | | loop, move | Workspaces: Move window to N (`"Workspaces: Move window to " .. g.wss[i]`) | | SUPER + d | Workspaces: Toggle scratchpad | | SUPER + SHIFT + d | Workspaces: Move window to scratchpad | | SUPER + mouse_down | Workspaces: Next | | SUPER + mouse_up | Workspaces: Previous | | SUPER + mouse:272 | Windows: Drag to move | | SUPER + mouse:273 | Windows: Drag to resize | | XF86AudioRaiseVolume | Media: Volume up | | XF86AudioLowerVolume | Media: Volume down | | XF86AudioMute | Media: Mute | | XF86AudioMicMute | Media: Mute microphone | | XF86MonBrightnessUp | Media: Brightness up | | XF86MonBrightnessDown | Media: Brightness down | | CTRL + SUPER + mouse_up | Zoom: In | | CTRL + SUPER + mouse_down | Zoom: Out | | CTRL + SUPER + z | Zoom: Toggle | - [ ] **Step 2: bind SUPER + k**, under APP LAUNCHERS after the drawer bind: (`` is the absolute quickshell path, as in the existing binds; only the live config, not this repo, carries it): ```lua -- Keybind reminder (quickshell): lists `hyprctl binds -j`, so every bind -- above needs a description to show what it does. hl.bind(mainMod .. " + k", hl.dsp.exec_cmd("qs -p /keybinds ipc call keybinds toggle"), { description = "Desktop: Keybind reminder" }) ``` - [ ] **Step 3: autostart**, after the window-switcher line in `autostart.lua`: ```lua hl.exec_cmd("qs -p /keybinds") ``` - [ ] **Step 4: blur rule**, in `decorations.lua` after `blur-window-switcher`: ```lua -- Frosted glass for the quickshell keybind reminder. hl.layer_rule({ name = "blur-keybinds", match = { namespace = "^(quickshell-keybinds)$" }, blur = true, xray = false, ignore_alpha = 0.1, }) ``` - [ ] **Step 5: reload and verify** ```bash hyprctl reload hyprctl binds -j | python3 -c "import json,sys; b=json.load(sys.stdin); print(len(b), [(x['modmask'],x['key']) for x in b if not x['description']])" ``` Expected: `82 [(64, 's')]`, only the dashboard bind undescribed (Task 4). - [ ] **Step 6: Commit** (in `../hypr-theme`) ```bash git add sections/keybindings.lua sections/autostart.lua sections/decorations.lua git commit -m "feat(keybindings): describe every bind for the keybind reminder" ``` --- ### Task 4: dashboard bind in `conky-theme-udt` **Files:** - Modify: `../conky-theme-udt/hypr/dashboard.lua` (the `SUPER + s` bind) - [ ] **Step 1:** ```lua hl.bind("SUPER + s", hl.dsp.workspace.toggle_special("dash"), { description = "Workspaces: Toggle dashboard" }) ``` - [ ] **Step 2: verify**: rerun Task 3 Step 5. Expected: `82 []`. - [ ] **Step 3: Commit** (in `../conky-theme-udt`) ```bash git add hypr/dashboard.lua git commit -m "feat(dashboard): describe the dashboard bind" ``` --- ### Task 5: hand over - [ ] The running session has no `keybinds` shell until next login. The user starts it from their own terminal (`qs -p ~/Programming/GIT/quickshell/keybinds` detached, or `./reload.sh keybinds`), presses SUPER+k, and judges the layout.