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>
17 KiB
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 betweenniri/config.kdland theSTASHconstant inscripts/window-restore.py. - No
jqon this system. Parseniri msg -jJSON with Python 3 stdlib only. Do not add dependencies. - Script convention:
scripts/<name>.pyis symlinked to~/.local/bin/<name>(no extension in the bin name) byinstall.sh. - Test convention: plain
python3scripts underscripts/tests/, no pytest. Load the hyphenated module viaimportlib, assert with a localcheck(desc, got, want)helper,sys.exit(fails). Mirrorscripts/tests/gamemode-watch.test.py. - Style: pure logic as module-level functions, subprocess calls confined to
main()and thin wrappers — mirrorscripts/gamemode-watch.py. - Task order matters: Task 1 (script) precedes Task 2 (config). Wiring
Mod+Shift+Mto 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 byMod+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 theminimizedworkspace 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:
#!/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:
#!/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
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-restoreexecutable, spawned byMod+Shift+M. -
Step 1: Declare the stash workspace
In niri/config.kdl, add above the binds { block (after the spawn-at-startup lines):
// 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:
Mod+M { move-window-to-workspace "minimized"; }
Mod+Shift+M { focus-workspace "minimized"; }
with:
// 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):
// +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:
ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore
Then create it for the running session:
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).
Mod+1. Confirm it lands on a real workspace, not the stash — this is the offset working.- On a window there, press
Mod+M. The window vanishes and focus stays put. If the view jumps to an empty workspace,focus=falseis not working — stop and fix. - waybar shows a
minimizedpill on the far left. Mod+2, thenMod+Shift+M. The picker lists the stashed window; pick it. It appears on this workspace and is focused.Mod+Shift+Magain with the stash now empty: a "No minimized windows" toast, no hang.- Click the
minimizedpill in waybar, thenMod+Shift+M: "Already on the minimized workspace". Leave withMod+1.
Confirm the stash is actually at index 1 and holding windows:
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
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:
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.