# 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/.py` is symlinked to `~/.local/bin/` (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 ` 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.