From 1bdc4b51650e4ff5246db8a8cf4f766d2841e018 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 16 Jul 2026 09:15:02 -0700 Subject: [PATCH 1/5] spec: niri minimize/stash design Mod+M and Mod+Shift+M reference a "minimized" named workspace that was never declared, so both binds have always been silent no-ops. Design covers the declaration plus a wofi restore picker. Constraints verified against niri 26.04 in a nested instance: undeclared named workspaces are no-ops, declared ones are pinned to index 1 and cannot be moved (hence the +1 offset on Mod+1..9), and focus=false is required or focus follows the window into the stash. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-16-niri-minimize-design.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-niri-minimize-design.md diff --git a/docs/superpowers/specs/2026-07-16-niri-minimize-design.md b/docs/superpowers/specs/2026-07-16-niri-minimize-design.md new file mode 100644 index 0000000..ed88221 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-niri-minimize-design.md @@ -0,0 +1,228 @@ +# Niri Minimize / Stash Design + +**Date:** 2026-07-16 +**Status:** Approved + +## Summary + +Give niri a working minimize: `Mod+M` stashes the focused window out of sight, +`Mod+Shift+M` opens a wofi picker to bring any stashed window back to the +workspace you are currently on. Any number of windows can be stashed at once. + +The stash is a declared named workspace, `minimized`. Restore is a new script, +`scripts/window-restore.py`, driven by niri's IPC. + +Both keybinds already exist in `niri/config.kdl` — and **both are silently +broken today** (see below). This design fixes the root cause and builds the +restore half that was never there. + +## Current Behaviour + +- **Compositor:** niri 26.04, single internal panel `eDP-1`. Config at + `niri/config.kdl`. +- **The existing binds are no-ops:** + + ```kdl + Mod+M { move-window-to-workspace "minimized"; } + Mod+Shift+M { focus-workspace "minimized"; } + ``` + + `minimized` is referenced in these binds but **never declared** at the top + level. niri only creates named workspaces that are declared, so both actions + resolve to nothing and do nothing. `niri validate` reports the config as valid + because the name is just a string to the parser — which is why this has failed + quietly rather than erroring. +- **Workspaces are dynamic** and unnamed; `Mod+1..9` focus them by index + (`focus-workspace 1..9`), `Mod+Shift+1..9` move windows to them by index. +- **waybar** runs `niri/workspaces` in `modules-left` with no module-specific + config, so it renders every workspace niri reports. +- **No `jq` installed.** `python3` is already a dependency via + `scripts/gamemode-watch.py`, which also establishes the test convention + (`scripts/tests/gamemode-watch.test.py`). +- **Script convention:** `scripts/.{sh,py}` symlinked to + `~/.local/bin/` (no extension in the bin name) by `install.sh`. + +## Verified niri constraints + +Each of these was tested against niri 26.04 in a nested instance, not inferred +from documentation. They are the load-bearing facts behind the design: + +1. **An undeclared named workspace is a silent no-op.** `move-window-to-workspace + minimized` left the window where it was; `focus-workspace minimized` did not + change focus and created nothing. +2. **Declaring it makes both work.** With `workspace "minimized"` at the top + level, a window moved from a dynamic workspace into the stash by name. +3. **A declared named workspace is pinned to index 1 and cannot be moved.** + It always sorts first, ahead of every dynamic workspace. + `move-workspace-to-index --reference minimized` refused at every target index, + and `open-on-output` pointing at a non-existent output did not dislodge it. + **This is why the `Mod+1..9` binds must be offset by one.** +4. **`focus=false` is required on minimize.** The default (`focus=true`) makes + focus follow the window into the stash — the opposite of minimizing. With + `--focus false` the window moved to the stash and focus stayed put. The + `focus=false` property parses in a KDL bind. +5. **The IPC needed for restore exists:** `move-window-to-workspace + --window-id ` and `focus-window --id `. + +## Target Behaviour + +| Key | Action | +|---|---| +| `Mod+M` | Stash the focused window. Focus stays where it is. | +| `Mod+Shift+M` | wofi picker of stashed windows → restore the pick to the **current** workspace and focus it. | + +Restoring to the current workspace ("bring it to me") is deliberate: the stash +workspace itself is the entire state, so nothing needs to track where a window +came from and nothing can go stale. + +The `minimized` workspace is permanent and always visible as the leftmost pill in +waybar. This is kept on purpose — it is the only visual cue that windows are +stashed. + +## Implementation + +### 1. `niri/config.kdl` + +Declare the stash at the top level, with a comment carrying the reason for the +offset so the next reader does not "fix" it: + +```kdl +// Stash for minimized windows (Mod+M). niri pins declared named workspaces to +// index 1 and refuses to move them, so real workspaces start at index 2 — hence +// the +1 offset on the Mod+1..9 binds below. Do not remove this declaration: +// without it the "minimized" binds silently do nothing. +workspace "minimized" +``` + +Binds: + +```kdl +Mod+M { move-window-to-workspace "minimized" focus=false; } +Mod+Shift+M { spawn "window-restore"; } +``` + +`focus=false` is new (constraint 4). `Mod+Shift+M` changes from +`focus-workspace "minimized"` to spawning the picker. + +Offset every index-based workspace bind by one (constraint 3): + +```kdl +Mod+1 { focus-workspace 2; } ... Mod+9 { focus-workspace 10; } +Mod+Shift+1 { move-window-to-workspace 2; } ... Mod+Shift+9 { move-window-to-workspace 10; } +``` + +The offset is exact rather than heuristic: the stash is *provably* always index 1, +because niri will not let it be anywhere else. + +### 2. `scripts/window-restore.py` (new) + +Python, because it parses `niri msg -j` JSON and there is no `jq`. + +``` +main: + workspaces = niri msg -j workspaces + stash = the workspace whose name == "minimized" + if stash is None: → notify "minimized workspace not declared"; exit 1 + current = the workspace where is_focused + if current.id == stash.id: → notify "Already on the minimized workspace"; exit 0 + + windows = [w for w in (niri msg -j windows) if w.workspace_id == stash.id] + if not windows: → notify "No minimized windows"; exit 0 + + choice = wofi --dmenu over "" lines + if not choice: → exit 0 # cancelled + win = first window whose label == choice + + niri msg action move-window-to-workspace --window-id win.id <current.idx> + niri msg action focus-window --id win.id +``` + +Split into small pure functions so the logic is testable without a compositor: + +- `stash_workspace(workspaces)` → the stash dict or `None` +- `current_workspace(workspaces)` → the focused dict +- `stashed_windows(windows, stash_id)` → list +- `label(window)` → display string. `"<app_id> — <title>"`; if either is missing + or empty (XWayland windows can lack `app_id`) fall back to whichever exists, + and to `"window <id>"` if neither does — a label must never render as a bare + dash or an empty row. +- `pick(labels, chooser)` → selected label (chooser injected, so tests pass a fake) +- `resolve(windows, label)` → window id + +Only `main()` shells out to `niri msg` / `wofi` / `notify-send`. + +wofi flags follow `powermenu.sh` house style: `--dmenu --prompt "Restore:" +--width 600 --height 400 --insensitive`. + +### 3. `install.sh` + +Add alongside the existing symlink block: + +```bash +ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore +``` + +### 4. waybar + +No change. The `minimized` pill appears automatically. + +## Failure / edge cases + +- **Not running under niri / IPC unavailable:** `niri msg` fails → notify and + exit non-zero rather than tracebacking. +- **Stash workspace missing** (declaration removed, or config not reloaded): the + script says so explicitly, naming the declaration. This is exactly the trap the + current config is in, so the failure must be loud rather than silent. +- **Nothing stashed:** `notify-send "No minimized windows"`, exit 0. +- **Picker cancelled** (empty wofi output): exit 0, do nothing. +- **Already focused on the stash:** after the offset no keybind reaches the + `minimized` workspace, but clicking its waybar pill still does. Restoring + "to the current workspace" would then move a window to the stash it is already + on — a silent no-op. Detect `current.id == stash.id` and + `notify-send "Already on the minimized workspace"`, exit 0. Windows there can + be moved out with the normal `Mod+Shift+1..9` binds. +- **Duplicate labels:** two windows with the same `app_id` and title are + indistinguishable in the menu; the first match is restored. Accepted — such + windows are indistinguishable to the user too, and repeated restores still + drain the stash. Rejected the alternative (printing window IDs in the menu) as + visual noise for a case that barely matters. +- **Minimizing the only window on a dynamic workspace:** that workspace + disappears, shifting the indices of later ones. This is pre-existing niri + behaviour for dynamic workspaces and is unaffected by the stash, which stays at + index 1 regardless. +- **Fullscreen / floating windows:** move into the stash like any other window. + +## Testing + +**Unit** — `scripts/tests/window-restore.test.py`, mirroring +`gamemode-watch.test.py`: feed the pure functions recorded `niri msg -j` JSON +fixtures and assert stash lookup (present and absent), filtering, labelling +(including the missing-`app_id` fallback), cancelled-pick, empty stash, +focused-on-stash, and duplicate-label resolution. The chooser is injected, so no +wofi. + +**Manual** — the parts no unit test can reach: + +Throughout, "first workspace" means the one `Mod+1` reaches (niri index 2, since +the stash holds index 1). + +1. `niri validate -c niri/config.kdl` passes. +2. On the first workspace, `Mod+M` on a window: it vanishes and **focus stays on + the first workspace** (regression test for constraint 4 — if focus jumps to + the stash, `focus=false` is not working). +3. waybar shows the `minimized` pill. +4. `Mod+2` to the second workspace, `Mod+Shift+M`, pick the window: it appears + there and is focused. +5. `Mod+Shift+M` with an empty stash: "No minimized windows", no hang. +6. `Mod+1` lands on the first real workspace, **not** the stash — confirming the + offset. Spot-check `Mod+3` and `Mod+Shift+2` likewise. +7. Click the `minimized` pill in waybar to land on the stash, then `Mod+Shift+M`: + it declines rather than no-op'ing (see edge cases). + +## Out of scope + +- A dedicated scratchpad/dropdown terminal (one designated window toggled by a + single key). Considered and explicitly deferred; it is a separate feature. +- Restoring to a window's *original* workspace. Rejected during design: it needs + per-window origin tracking that can go stale. +- A waybar module showing a stash count — the workspace pill already signals it. From 9ae1d88937efee56919f08a9d5bbd81c9aac927f Mon Sep 17 00:00:00 2001 From: funman300 <funman300@gmail.com> Date: Thu, 16 Jul 2026 09:19:49 -0700 Subject: [PATCH 2/5] plan: niri minimize/stash implementation Two tasks: window-restore.py + unit tests, then the niri config declaration, bind rewiring and install.sh symlink. Script precedes config so Mod+Shift+M is never wired to a missing script. Plan code verified by extracting both blocks and running the test suite against the script: 18/18 pass, and the step-2 failure mode is confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../plans/2026-07-16-niri-minimize.md | 452 ++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-niri-minimize.md diff --git a/docs/superpowers/plans/2026-07-16-niri-minimize.md b/docs/superpowers/plans/2026-07-16-niri-minimize.md new file mode 100644 index 0000000..283fd08 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-niri-minimize.md @@ -0,0 +1,452 @@ +# Niri Minimize / Stash 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:** `Mod+M` stashes the focused window on a declared `minimized` workspace; `Mod+Shift+M` opens a wofi picker that restores any stashed window to the workspace you are currently on. + +**Architecture:** The stash is a declared named workspace — declaring it is what makes the two existing (currently no-op) binds start working. Restore is a new Python script driven by niri's IPC, with pure logic split from the subprocess calls so it is testable without a compositor. + +**Tech Stack:** niri 26.04 KDL config, Python 3 (stdlib only), wofi, `niri msg -j` IPC, libnotify. + +**Spec:** `docs/superpowers/specs/2026-07-16-niri-minimize-design.md` + +## Global Constraints + +- **The stash workspace name is exactly `minimized`** — it must match between `niri/config.kdl` and the `STASH` constant in `scripts/window-restore.py`. +- **No `jq` on this system.** Parse `niri msg -j` JSON with Python 3 stdlib only. Do not add dependencies. +- **Script convention:** `scripts/<name>.py` is symlinked to `~/.local/bin/<name>` (no extension in the bin name) by `install.sh`. +- **Test convention:** plain `python3` scripts under `scripts/tests/`, no pytest. Load the hyphenated module via `importlib`, assert with a local `check(desc, got, want)` helper, `sys.exit(fails)`. Mirror `scripts/tests/gamemode-watch.test.py`. +- **Style:** pure logic as module-level functions, subprocess calls confined to `main()` and thin wrappers — mirror `scripts/gamemode-watch.py`. +- **Task order matters:** Task 1 (script) precedes Task 2 (config). Wiring `Mod+Shift+M` to a script that does not exist yet would leave the bind broken between commits. + +--- + +### Task 1: `window-restore` script and its unit tests + +**Files:** +- Create: `scripts/window-restore.py` +- Test: `scripts/tests/window-restore.test.py` + +**Interfaces:** +- Produces, for Task 2: the executable `window-restore` (via symlink), invoked with no arguments by `Mod+Shift+M { spawn "window-restore"; }`. Exits 0 on success, on an empty stash, and on a cancelled picker; exits 1 when niri IPC is unavailable or the `minimized` workspace is not declared. +- Pure functions (used only by the test): `stash_workspace(workspaces)`, `current_workspace(workspaces)`, `stashed_windows(windows, stash_id)`, `label(window)`, `resolve(windows, chosen)`, `pick(labels, chooser)`. + +- [ ] **Step 1: Write the failing test** + +Create `scripts/tests/window-restore.test.py`: + +```python +#!/usr/bin/env python3 +"""Unit tests for window-restore's pure logic (no niri, no wofi, no subprocess). +Loads the hyphenated script by path and drives the pure functions with the JSON +shapes `niri msg -j workspaces` / `windows` actually return.""" +import importlib.util +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +MOD_PATH = os.path.join(HERE, "..", "window-restore.py") +spec = importlib.util.spec_from_file_location("window_restore", MOD_PATH) +wr = importlib.util.module_from_spec(spec) +spec.loader.exec_module(wr) + +fails = 0 + + +def check(desc, got, want): + global fails + if got == want: + print(f"ok - {desc}") + else: + print(f"FAIL - {desc} (got {got!r} want {want!r})") + fails = 1 + + +# Shapes mirror real `niri msg -j` output: the stash is always idx 1. +WS_WITH_STASH = [ + {"id": 1, "idx": 1, "name": "minimized", "is_focused": False}, + {"id": 2, "idx": 2, "name": None, "is_focused": True}, + {"id": 3, "idx": 3, "name": None, "is_focused": False}, +] +WS_NO_STASH = [ + {"id": 2, "idx": 1, "name": None, "is_focused": True}, + {"id": 3, "idx": 2, "name": None, "is_focused": False}, +] +WS_ON_STASH = [ + {"id": 1, "idx": 1, "name": "minimized", "is_focused": True}, + {"id": 2, "idx": 2, "name": None, "is_focused": False}, +] + +WINDOWS = [ + {"id": 10, "title": "Balatro", "app_id": "steam_app_2379780", "workspace_id": 1}, + {"id": 11, "title": "Pi-hole", "app_id": "firefox", "workspace_id": 1}, + {"id": 12, "title": "notes", "app_id": "Alacritty", "workspace_id": 2}, +] + +# --- stash_workspace / current_workspace ----------------------------------- +check("stash found when declared", wr.stash_workspace(WS_WITH_STASH)["id"], 1) +check("stash is None when undeclared", wr.stash_workspace(WS_NO_STASH), None) +check("stash is None for empty list", wr.stash_workspace([]), None) +check("current workspace is the focused one", wr.current_workspace(WS_WITH_STASH)["id"], 2) +check("current is None when none focused", wr.current_workspace([]), None) +check("focused-on-stash is detectable", + wr.current_workspace(WS_ON_STASH)["id"] == wr.stash_workspace(WS_ON_STASH)["id"], True) + +# --- stashed_windows ------------------------------------------------------- +check("only windows on the stash", [w["id"] for w in wr.stashed_windows(WINDOWS, 1)], [10, 11]) +check("empty stash yields nothing", wr.stashed_windows(WINDOWS, 99), []) + +# --- label ----------------------------------------------------------------- +check("label joins app_id and title", wr.label(WINDOWS[1]), "firefox — Pi-hole") +check("label falls back to title when app_id missing", + wr.label({"id": 5, "app_id": None, "title": "Bare Title"}), "Bare Title") +check("label falls back to app_id when title empty", + wr.label({"id": 5, "app_id": "firefox", "title": ""}), "firefox") +check("label never renders empty", + wr.label({"id": 5, "app_id": None, "title": None}), "window 5") + +# --- resolve --------------------------------------------------------------- +check("resolve maps a label back to its window", + wr.resolve(WINDOWS, "firefox — Pi-hole")["id"], 11) +check("resolve returns None for no match", wr.resolve(WINDOWS, "nope"), None) + +DUPES = [ + {"id": 20, "title": "same", "app_id": "Alacritty", "workspace_id": 1}, + {"id": 21, "title": "same", "app_id": "Alacritty", "workspace_id": 1}, +] +check("duplicate labels resolve to the first", wr.resolve(DUPES, "Alacritty — same")["id"], 20) + +# --- pick ------------------------------------------------------------------ +check("pick returns the chooser's selection", + wr.pick(["a", "b"], lambda text: "b"), "b") +check("pick passes newline-joined labels to the chooser", + wr.pick(["a", "b"], lambda text: text), "a\nb") +check("cancelled pick returns empty", wr.pick(["a"], lambda text: ""), "") + +sys.exit(fails) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 scripts/tests/window-restore.test.py` +Expected: FAIL, non-zero exit — `FileNotFoundError` / `No such file or directory: '.../scripts/window-restore.py'`, because the script does not exist yet. + +- [ ] **Step 3: Write the implementation** + +Create `scripts/window-restore.py`: + +```python +#!/usr/bin/env python3 +"""window-restore — bring a minimized window back from the stash workspace. + +Mod+M moves the focused window to the declared "minimized" workspace; this is +Mod+Shift+M: list what is stashed in a wofi picker, then move the pick to the +workspace you are currently on and focus it. + +Restore targets the *current* workspace by design — the stash workspace is the +entire state, so nothing tracks where a window came from and nothing goes stale. + +The stash must be declared (`workspace "minimized"` in niri/config.kdl). niri +silently ignores moves to an undeclared named workspace, so if the declaration +goes missing this script says so rather than failing quietly. +""" +import json +import subprocess +import sys + +STASH = "minimized" + + +# --- pure logic ------------------------------------------------------------- +def stash_workspace(workspaces): + """The declared stash workspace, or None if it is not declared.""" + return next((w for w in workspaces if w.get("name") == STASH), None) + + +def current_workspace(workspaces): + return next((w for w in workspaces if w.get("is_focused")), None) + + +def stashed_windows(windows, stash_id): + return [w for w in windows if w.get("workspace_id") == stash_id] + + +def label(window): + """Display string for the picker. XWayland windows can lack app_id, so fall + back rather than rendering a bare dash or an empty row.""" + app = (window.get("app_id") or "").strip() + title = (window.get("title") or "").strip() + if app and title: + return f"{app} — {title}" + return app or title or f"window {window['id']}" + + +def resolve(windows, chosen): + """First window whose label matches. Windows with identical labels are + indistinguishable in the menu anyway, and restoring the first still drains + the stash on repeat.""" + return next((w for w in windows if label(w) == chosen), None) + + +def pick(labels, chooser): + """chooser(text) -> selected line. Injected so tests need no wofi.""" + return chooser("\n".join(labels)) + + +# --- io --------------------------------------------------------------------- +def notify(message): + subprocess.run( + ["notify-send", "-a", "window-restore", message], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def niri_json(what): + """`niri msg -j <what>` parsed, or None if niri/IPC is unavailable.""" + try: + out = subprocess.run( + ["niri", "msg", "-j", what], capture_output=True, text=True + ) + except FileNotFoundError: + return None + if out.returncode != 0: + return None + try: + return json.loads(out.stdout) + except json.JSONDecodeError: + return None + + +def niri_action(*args): + subprocess.run( + ["niri", "msg", "action", *args], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def wofi_chooser(text): + try: + out = subprocess.run( + ["wofi", "--dmenu", "--prompt", "Restore:", "--width", "600", + "--height", "400", "--insensitive"], + input=text, capture_output=True, text=True, + ) + except FileNotFoundError: + return "" + return out.stdout.strip() + + +def main(): + workspaces = niri_json("workspaces") + if workspaces is None: + notify("niri IPC unavailable") + return 1 + + stash = stash_workspace(workspaces) + if stash is None: + notify(f'no "{STASH}" workspace — declare it in niri/config.kdl') + return 1 + + current = current_workspace(workspaces) + if current is None: + notify("no focused workspace") + return 1 + + # Restoring here would move a window to the workspace it is already on. + # Reachable by clicking the waybar pill; no keybind goes here. + if current["id"] == stash["id"]: + notify("Already on the minimized workspace") + return 0 + + windows = niri_json("windows") + if windows is None: + notify("niri IPC unavailable") + return 1 + + stashed = stashed_windows(windows, stash["id"]) + if not stashed: + notify("No minimized windows") + return 0 + + chosen = pick([label(w) for w in stashed], wofi_chooser) + if not chosen: + return 0 # cancelled + + window = resolve(stashed, chosen) + if window is None: + return 0 + + # The window is not focused (it is in the stash), so --focus does not apply; + # focus it explicitly afterwards. + niri_action("move-window-to-workspace", "--window-id", str(window["id"]), + str(current["idx"])) + niri_action("focus-window", "--id", str(window["id"])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 scripts/tests/window-restore.test.py` +Expected: every line prints `ok - ...`, exit code 0. Confirm with `echo $?` → `0`. + +- [ ] **Step 5: Commit** + +```bash +chmod +x scripts/window-restore.py +git add scripts/window-restore.py scripts/tests/window-restore.test.py +git commit -m "window-restore: wofi picker to restore minimized windows + +Pure logic split from the niri/wofi calls so it unit-tests without a +compositor. Restores to the current workspace, so no origin tracking." +``` + +--- + +### Task 2: Declare the stash, rewire the binds, install the symlink + +**Files:** +- Modify: `niri/config.kdl` (top level; binds at lines ~62-108) +- Modify: `install.sh:49-57` (the symlink block) + +**Interfaces:** +- Consumes from Task 1: the `window-restore` executable, spawned by `Mod+Shift+M`. + +- [ ] **Step 1: Declare the stash workspace** + +In `niri/config.kdl`, add above the `binds {` block (after the `spawn-at-startup` lines): + +```kdl +// Stash for minimized windows (Mod+M). niri pins declared named workspaces to +// index 1 and refuses to move them, so real workspaces start at index 2 — hence +// the +1 offset on the Mod+1..9 binds below. Do not remove this declaration: +// without it the "minimized" binds silently do nothing (niri validate still +// passes, because the name is just a string to the parser). +workspace "minimized" +``` + +- [ ] **Step 2: Rewire the two minimize binds** + +In `niri/config.kdl`, replace: + +```kdl + Mod+M { move-window-to-workspace "minimized"; } + Mod+Shift+M { focus-workspace "minimized"; } +``` + +with: + +```kdl + // focus=false or focus follows the window into the stash — the opposite of + // minimizing. + Mod+M { move-window-to-workspace "minimized" focus=false; } + Mod+Shift+M { spawn "window-restore"; } +``` + +- [ ] **Step 3: Offset the index-based workspace binds** + +In `niri/config.kdl`, replace the `Mod+1..9` and `Mod+Shift+1..9` blocks with (note the `+1` — index 1 is the stash): + +```kdl + // +1 offset: the "minimized" workspace is always index 1 (see the + // declaration above), so the first real workspace is index 2. + Mod+1 { focus-workspace 2; } + Mod+2 { focus-workspace 3; } + Mod+3 { focus-workspace 4; } + Mod+4 { focus-workspace 5; } + Mod+5 { focus-workspace 6; } + Mod+6 { focus-workspace 7; } + Mod+7 { focus-workspace 8; } + Mod+8 { focus-workspace 9; } + Mod+9 { focus-workspace 10; } + + Mod+Shift+1 { move-window-to-workspace 2; } + Mod+Shift+2 { move-window-to-workspace 3; } + Mod+Shift+3 { move-window-to-workspace 4; } + Mod+Shift+4 { move-window-to-workspace 5; } + Mod+Shift+5 { move-window-to-workspace 6; } + Mod+Shift+6 { move-window-to-workspace 7; } + Mod+Shift+7 { move-window-to-workspace 8; } + Mod+Shift+8 { move-window-to-workspace 9; } + Mod+Shift+9 { move-window-to-workspace 10; } +``` + +- [ ] **Step 4: Validate the config** + +Run: `niri validate -c niri/config.kdl` +Expected: `config is valid`, exit 0. + +Note: a passing validate does **not** prove the stash works — that is exactly the trap the old config was in. Step 6 is the real check. + +- [ ] **Step 5: Add the symlink and install it** + +In `install.sh`, after the `gamemode-watch` line (`install.sh:57`), add: + +```bash +ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore +``` + +Then create it for the running session: + +```bash +ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore +command -v window-restore +``` + +Expected: prints `/home/alex/.local/bin/window-restore`. + +- [ ] **Step 6: Verify by hand** + +niri live-reloads on save; confirm with +`journalctl --user --since "1 minute ago" | grep "loaded config"`. + +"First workspace" below means the one `Mod+1` reaches (niri index 2 — the stash holds index 1). + +1. `Mod+1`. Confirm it lands on a **real workspace, not the stash** — this is the offset working. +2. On a window there, press `Mod+M`. The window vanishes and **focus stays put**. If the view jumps to an empty workspace, `focus=false` is not working — stop and fix. +3. waybar shows a `minimized` pill on the far left. +4. `Mod+2`, then `Mod+Shift+M`. The picker lists the stashed window; pick it. It appears on this workspace and is focused. +5. `Mod+Shift+M` again with the stash now empty: a "No minimized windows" toast, no hang. +6. Click the `minimized` pill in waybar, then `Mod+Shift+M`: "Already on the minimized workspace". Leave with `Mod+1`. + +Confirm the stash is actually at index 1 and holding windows: + +```bash +niri msg -j workspaces | python3 -c "import sys,json; print([(w['idx'], w['name']) for w in json.load(sys.stdin)])" +``` + +Expected: a tuple `(1, 'minimized')` in the list. + +- [ ] **Step 7: Commit** + +```bash +git add niri/config.kdl install.sh +git commit -m "niri: working minimize via declared stash workspace + +Mod+M referenced an undeclared 'minimized' workspace, so it silently did +nothing. Declare it, add focus=false so focus does not follow the window +into the stash, and point Mod+Shift+M at the restore picker. + +niri pins declared named workspaces to index 1 and will not move them, so +Mod+1..9 are offset by one." +``` + +--- + +## Verification + +Full check after both tasks: + +```bash +python3 scripts/tests/window-restore.test.py # all ok, exit 0 +niri validate -c niri/config.kdl # config is valid +bash scripts/tests/gamemode-session.test.sh # unchanged, still passes +python3 scripts/tests/gamemode-watch.test.py # unchanged, still passes +``` + +Then the Task 2 Step 6 manual sequence, which is the only thing that proves the stash actually stashes. From 5979eb229f26b576139d6287f6b68753601632a3 Mon Sep 17 00:00:00 2001 From: funman300 <funman300@gmail.com> Date: Thu, 16 Jul 2026 09:58:25 -0700 Subject: [PATCH 3/5] window-restore: wofi picker to restore minimized windows Pure logic split from the niri/wofi calls so it unit-tests without a compositor. Restores to the current workspace, so no origin tracking. --- scripts/tests/window-restore.test.py | 88 ++++++++++++++++ scripts/window-restore.py | 152 +++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 scripts/tests/window-restore.test.py create mode 100755 scripts/window-restore.py diff --git a/scripts/tests/window-restore.test.py b/scripts/tests/window-restore.test.py new file mode 100644 index 0000000..9a61ce7 --- /dev/null +++ b/scripts/tests/window-restore.test.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Unit tests for window-restore's pure logic (no niri, no wofi, no subprocess). +Loads the hyphenated script by path and drives the pure functions with the JSON +shapes `niri msg -j workspaces` / `windows` actually return.""" +import importlib.util +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +MOD_PATH = os.path.join(HERE, "..", "window-restore.py") +spec = importlib.util.spec_from_file_location("window_restore", MOD_PATH) +wr = importlib.util.module_from_spec(spec) +spec.loader.exec_module(wr) + +fails = 0 + + +def check(desc, got, want): + global fails + if got == want: + print(f"ok - {desc}") + else: + print(f"FAIL - {desc} (got {got!r} want {want!r})") + fails = 1 + + +# Shapes mirror real `niri msg -j` output: the stash is always idx 1. +WS_WITH_STASH = [ + {"id": 1, "idx": 1, "name": "minimized", "is_focused": False}, + {"id": 2, "idx": 2, "name": None, "is_focused": True}, + {"id": 3, "idx": 3, "name": None, "is_focused": False}, +] +WS_NO_STASH = [ + {"id": 2, "idx": 1, "name": None, "is_focused": True}, + {"id": 3, "idx": 2, "name": None, "is_focused": False}, +] +WS_ON_STASH = [ + {"id": 1, "idx": 1, "name": "minimized", "is_focused": True}, + {"id": 2, "idx": 2, "name": None, "is_focused": False}, +] + +WINDOWS = [ + {"id": 10, "title": "Balatro", "app_id": "steam_app_2379780", "workspace_id": 1}, + {"id": 11, "title": "Pi-hole", "app_id": "firefox", "workspace_id": 1}, + {"id": 12, "title": "notes", "app_id": "Alacritty", "workspace_id": 2}, +] + +# --- stash_workspace / current_workspace ----------------------------------- +check("stash found when declared", wr.stash_workspace(WS_WITH_STASH)["id"], 1) +check("stash is None when undeclared", wr.stash_workspace(WS_NO_STASH), None) +check("stash is None for empty list", wr.stash_workspace([]), None) +check("current workspace is the focused one", wr.current_workspace(WS_WITH_STASH)["id"], 2) +check("current is None when none focused", wr.current_workspace([]), None) +check("focused-on-stash is detectable", + wr.current_workspace(WS_ON_STASH)["id"] == wr.stash_workspace(WS_ON_STASH)["id"], True) + +# --- stashed_windows ------------------------------------------------------- +check("only windows on the stash", [w["id"] for w in wr.stashed_windows(WINDOWS, 1)], [10, 11]) +check("empty stash yields nothing", wr.stashed_windows(WINDOWS, 99), []) + +# --- label ----------------------------------------------------------------- +check("label joins app_id and title", wr.label(WINDOWS[1]), "firefox — Pi-hole") +check("label falls back to title when app_id missing", + wr.label({"id": 5, "app_id": None, "title": "Bare Title"}), "Bare Title") +check("label falls back to app_id when title empty", + wr.label({"id": 5, "app_id": "firefox", "title": ""}), "firefox") +check("label never renders empty", + wr.label({"id": 5, "app_id": None, "title": None}), "window 5") + +# --- resolve --------------------------------------------------------------- +check("resolve maps a label back to its window", + wr.resolve(WINDOWS, "firefox — Pi-hole")["id"], 11) +check("resolve returns None for no match", wr.resolve(WINDOWS, "nope"), None) + +DUPES = [ + {"id": 20, "title": "same", "app_id": "Alacritty", "workspace_id": 1}, + {"id": 21, "title": "same", "app_id": "Alacritty", "workspace_id": 1}, +] +check("duplicate labels resolve to the first", wr.resolve(DUPES, "Alacritty — same")["id"], 20) + +# --- pick ------------------------------------------------------------------ +check("pick returns the chooser's selection", + wr.pick(["a", "b"], lambda text: "b"), "b") +check("pick passes newline-joined labels to the chooser", + wr.pick(["a", "b"], lambda text: text), "a\nb") +check("cancelled pick returns empty", wr.pick(["a"], lambda text: ""), "") + +sys.exit(fails) diff --git a/scripts/window-restore.py b/scripts/window-restore.py new file mode 100755 index 0000000..ad757f8 --- /dev/null +++ b/scripts/window-restore.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""window-restore — bring a minimized window back from the stash workspace. + +Mod+M moves the focused window to the declared "minimized" workspace; this is +Mod+Shift+M: list what is stashed in a wofi picker, then move the pick to the +workspace you are currently on and focus it. + +Restore targets the *current* workspace by design — the stash workspace is the +entire state, so nothing tracks where a window came from and nothing goes stale. + +The stash must be declared (`workspace "minimized"` in niri/config.kdl). niri +silently ignores moves to an undeclared named workspace, so if the declaration +goes missing this script says so rather than failing quietly. +""" +import json +import subprocess +import sys + +STASH = "minimized" + + +# --- pure logic ------------------------------------------------------------- +def stash_workspace(workspaces): + """The declared stash workspace, or None if it is not declared.""" + return next((w for w in workspaces if w.get("name") == STASH), None) + + +def current_workspace(workspaces): + return next((w for w in workspaces if w.get("is_focused")), None) + + +def stashed_windows(windows, stash_id): + return [w for w in windows if w.get("workspace_id") == stash_id] + + +def label(window): + """Display string for the picker. XWayland windows can lack app_id, so fall + back rather than rendering a bare dash or an empty row.""" + app = (window.get("app_id") or "").strip() + title = (window.get("title") or "").strip() + if app and title: + return f"{app} — {title}" + return app or title or f"window {window['id']}" + + +def resolve(windows, chosen): + """First window whose label matches. Windows with identical labels are + indistinguishable in the menu anyway, and restoring the first still drains + the stash on repeat.""" + return next((w for w in windows if label(w) == chosen), None) + + +def pick(labels, chooser): + """chooser(text) -> selected line. Injected so tests need no wofi.""" + return chooser("\n".join(labels)) + + +# --- io --------------------------------------------------------------------- +def notify(message): + subprocess.run( + ["notify-send", "-a", "window-restore", message], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def niri_json(what): + """`niri msg -j <what>` parsed, or None if niri/IPC is unavailable.""" + try: + out = subprocess.run( + ["niri", "msg", "-j", what], capture_output=True, text=True + ) + except FileNotFoundError: + return None + if out.returncode != 0: + return None + try: + return json.loads(out.stdout) + except json.JSONDecodeError: + return None + + +def niri_action(*args): + subprocess.run( + ["niri", "msg", "action", *args], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def wofi_chooser(text): + try: + out = subprocess.run( + ["wofi", "--dmenu", "--prompt", "Restore:", "--width", "600", + "--height", "400", "--insensitive"], + input=text, capture_output=True, text=True, + ) + except FileNotFoundError: + return "" + return out.stdout.strip() + + +def main(): + workspaces = niri_json("workspaces") + if workspaces is None: + notify("niri IPC unavailable") + return 1 + + stash = stash_workspace(workspaces) + if stash is None: + notify(f'no "{STASH}" workspace — declare it in niri/config.kdl') + return 1 + + current = current_workspace(workspaces) + if current is None: + notify("no focused workspace") + return 1 + + # Restoring here would move a window to the workspace it is already on. + # Reachable by clicking the waybar pill; no keybind goes here. + if current["id"] == stash["id"]: + notify("Already on the minimized workspace") + return 0 + + windows = niri_json("windows") + if windows is None: + notify("niri IPC unavailable") + return 1 + + stashed = stashed_windows(windows, stash["id"]) + if not stashed: + notify("No minimized windows") + return 0 + + chosen = pick([label(w) for w in stashed], wofi_chooser) + if not chosen: + return 0 # cancelled + + window = resolve(stashed, chosen) + if window is None: + return 0 + + # The window is not focused (it is in the stash), so --focus does not apply; + # focus it explicitly afterwards. + niri_action("move-window-to-workspace", "--window-id", str(window["id"]), + str(current["idx"])) + niri_action("focus-window", "--id", str(window["id"])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 41f0ee88ffdf2299390eca0fad2a55cb2d6abd0b Mon Sep 17 00:00:00 2001 From: funman300 <funman300@gmail.com> Date: Thu, 16 Jul 2026 10:03:12 -0700 Subject: [PATCH 4/5] niri: working minimize via declared stash workspace Mod+M referenced an undeclared 'minimized' workspace, so it silently did nothing. Declare it, add focus=false so focus does not follow the window into the stash, and point Mod+Shift+M at the restore picker. niri pins declared named workspaces to index 1 and will not move them, so Mod+1..9 are offset by one. --- install.sh | 1 + niri/config.kdl | 51 ++++++++++++++++++++++++++++++------------------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/install.sh b/install.sh index 1cc8b1f..48d5615 100755 --- a/install.sh +++ b/install.sh @@ -55,6 +55,7 @@ ln -sf "$(pwd)/scripts/screenshot.sh" ~/.local/bin/screenshot ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume ln -sf "$(pwd)/scripts/gamemode-session.sh" ~/.local/bin/gamemode-session ln -sf "$(pwd)/scripts/gamemode-watch.py" ~/.local/bin/gamemode-watch +ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore echo "==> Enabling systemd user services" mkdir -p ~/.config/systemd/user diff --git a/niri/config.kdl b/niri/config.kdl index b65db03..c157971 100644 --- a/niri/config.kdl +++ b/niri/config.kdl @@ -47,6 +47,13 @@ spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1" spawn-at-startup "xwayland-satellite" spawn-at-startup "gamemode-watch" +// Stash for minimized windows (Mod+M). niri pins declared named workspaces to +// index 1 and refuses to move them, so real workspaces start at index 2 — hence +// the +1 offset on the Mod+1..9 binds below. Do not remove this declaration: +// without it the "minimized" binds silently do nothing (niri validate still +// passes, because the name is just a string to the parser). +workspace "minimized" + binds { Mod+Q repeat=false { close-window; } Mod+F { fullscreen-window; } @@ -79,30 +86,34 @@ binds { Mod+Shift+Down { move-window-down; } Mod+Shift+Up { move-window-up; } - Mod+1 { focus-workspace 1; } - Mod+2 { focus-workspace 2; } - Mod+3 { focus-workspace 3; } - Mod+4 { focus-workspace 4; } - Mod+5 { focus-workspace 5; } - Mod+6 { focus-workspace 6; } - Mod+7 { focus-workspace 7; } - Mod+8 { focus-workspace 8; } - Mod+9 { focus-workspace 9; } + // +1 offset: the "minimized" workspace is always index 1 (see the + // declaration above), so the first real workspace is index 2. + Mod+1 { focus-workspace 2; } + Mod+2 { focus-workspace 3; } + Mod+3 { focus-workspace 4; } + Mod+4 { focus-workspace 5; } + Mod+5 { focus-workspace 6; } + Mod+6 { focus-workspace 7; } + Mod+7 { focus-workspace 8; } + Mod+8 { focus-workspace 9; } + Mod+9 { focus-workspace 10; } - Mod+Shift+1 { move-window-to-workspace 1; } - Mod+Shift+2 { move-window-to-workspace 2; } - Mod+Shift+3 { move-window-to-workspace 3; } - Mod+Shift+4 { move-window-to-workspace 4; } - Mod+Shift+5 { move-window-to-workspace 5; } - Mod+Shift+6 { move-window-to-workspace 6; } - Mod+Shift+7 { move-window-to-workspace 7; } - Mod+Shift+8 { move-window-to-workspace 8; } - Mod+Shift+9 { move-window-to-workspace 9; } + Mod+Shift+1 { move-window-to-workspace 2; } + Mod+Shift+2 { move-window-to-workspace 3; } + Mod+Shift+3 { move-window-to-workspace 4; } + Mod+Shift+4 { move-window-to-workspace 5; } + Mod+Shift+5 { move-window-to-workspace 6; } + Mod+Shift+6 { move-window-to-workspace 7; } + Mod+Shift+7 { move-window-to-workspace 8; } + Mod+Shift+8 { move-window-to-workspace 9; } + Mod+Shift+9 { move-window-to-workspace 10; } Mod+Print { spawn "screenshot"; } - Mod+M { move-window-to-workspace "minimized"; } - Mod+Shift+M { focus-workspace "minimized"; } + // focus=false or focus follows the window into the stash — the opposite of + // minimizing. + Mod+M { move-window-to-workspace "minimized" focus=false; } + Mod+Shift+M { spawn "window-restore"; } Mod+Shift+E { spawn "hyprlock"; } Mod+Shift+P { spawn "powermenu"; } From 05cc026d75ddca383a7f9095df38b771ee3bad49 Mon Sep 17 00:00:00 2001 From: funman300 <funman300@gmail.com> Date: Thu, 16 Jul 2026 10:10:40 -0700 Subject: [PATCH 5/5] fix(window-restore): re-read workspace/window state after the wofi pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker blocks on user input for however long they take. In that window niri can destroy a dynamic workspace (its last window closed) and renumber every later one down, so the pre-pick current["idx"] can point at the wrong workspace by the time move-window-to-workspace runs — restoring to the wrong place. The stashed window itself can also have closed in the meantime, in which case the move/focus calls would silently no-op. main() now re-reads workspaces/windows after pick() returns, recomputes the current workspace, and confirms the chosen window still exists before acting; each bails with a notify and the documented exit code otherwise. Adds window_exists() as a pure, tested helper alongside the existing pure functions. Also: clarify the focus=false comment in niri/config.kdl (it read as if focus=false caused the problem it actually prevents), and note in the design doc's Out of scope section that the stash's index-1 pinning is per-output, so a second monitor would need its own analysis of the Mod+1..9 offset. --- .../specs/2026-07-16-niri-minimize-design.md | 4 +++ niri/config.kdl | 4 +-- scripts/tests/window-restore.test.py | 5 +++ scripts/window-restore.py | 31 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-16-niri-minimize-design.md b/docs/superpowers/specs/2026-07-16-niri-minimize-design.md index ed88221..fbcf302 100644 --- a/docs/superpowers/specs/2026-07-16-niri-minimize-design.md +++ b/docs/superpowers/specs/2026-07-16-niri-minimize-design.md @@ -226,3 +226,7 @@ the stash holds index 1). - Restoring to a window's *original* workspace. Rejected during design: it needs per-window origin tracking that can go stale. - A waybar module showing a stash count — the workspace pill already signals it. +- Multi-monitor correctness: the stash is pinned to index 1 only on its own + output, so a second monitor's workspaces would start at 1 with no stash, + throwing off the `Mod+1..9` +1 offset there. Correct on this single-panel + (`eDP-1`) machine; latent if a second output is ever attached. diff --git a/niri/config.kdl b/niri/config.kdl index c157971..3922703 100644 --- a/niri/config.kdl +++ b/niri/config.kdl @@ -110,8 +110,8 @@ binds { Mod+Print { spawn "screenshot"; } - // focus=false or focus follows the window into the stash — the opposite of - // minimizing. + // focus=false is required here — otherwise focus follows the window into + // the stash, the opposite of minimizing. Mod+M { move-window-to-workspace "minimized" focus=false; } Mod+Shift+M { spawn "window-restore"; } diff --git a/scripts/tests/window-restore.test.py b/scripts/tests/window-restore.test.py index 9a61ce7..1accc11 100644 --- a/scripts/tests/window-restore.test.py +++ b/scripts/tests/window-restore.test.py @@ -78,6 +78,11 @@ DUPES = [ ] check("duplicate labels resolve to the first", wr.resolve(DUPES, "Alacritty — same")["id"], 20) +# --- window_exists ----------------------------------------------------------- +check("window_exists is True for a present window", wr.window_exists(WINDOWS, 11), True) +check("window_exists is False once the window has closed", wr.window_exists(WINDOWS, 999), False) +check("window_exists is False for an empty list", wr.window_exists([], 11), False) + # --- pick ------------------------------------------------------------------ check("pick returns the chooser's selection", wr.pick(["a", "b"], lambda text: "b"), "b") diff --git a/scripts/window-restore.py b/scripts/window-restore.py index ad757f8..9efa915 100755 --- a/scripts/window-restore.py +++ b/scripts/window-restore.py @@ -50,6 +50,13 @@ def resolve(windows, chosen): return next((w for w in windows if label(w) == chosen), None) +def window_exists(windows, window_id): + """Whether window_id is still present in a fresh `windows` list. Used after + the picker returns, since wofi can block long enough for the chosen window + to have closed out from under us.""" + return any(w.get("id") == window_id for w in windows) + + def pick(labels, chooser): """chooser(text) -> selected line. Injected so tests need no wofi.""" return chooser("\n".join(labels)) @@ -140,6 +147,30 @@ def main(): if window is None: return 0 + # wofi may have sat open for however long the user took. In that time niri + # can destroy a dynamic workspace (its last window closed) and renumber + # every later one down, so the pre-pick `current["idx"]` may now name the + # wrong workspace; the stashed window may also have closed. Re-read state + # rather than trust anything captured before pick() blocked. + workspaces = niri_json("workspaces") + if workspaces is None: + notify("niri IPC unavailable") + return 1 + + current = current_workspace(workspaces) + if current is None: + notify("no focused workspace") + return 1 + + windows = niri_json("windows") + if windows is None: + notify("niri IPC unavailable") + return 1 + + if not window_exists(windows, window["id"]): + notify("that window closed before it could be restored") + return 0 + # The window is not focused (it is in the stash), so --focus does not apply; # focus it explicitly afterwards. niri_action("move-window-to-workspace", "--window-id", str(window["id"]),