Files
dotfiles/docs/superpowers/plans/2026-07-16-niri-minimize.md
funman300 9ae1d88937 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>
2026-07-16 09:19:49 -07:00

453 lines
17 KiB
Markdown

# 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.