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) <noreply@anthropic.com>
9.9 KiB
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 atniri/config.kdl. -
The existing binds are no-ops:
Mod+M { move-window-to-workspace "minimized"; } Mod+Shift+M { focus-workspace "minimized"; }minimizedis 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 validatereports 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..9focus them by index (focus-workspace 1..9),Mod+Shift+1..9move windows to them by index. -
waybar runs
niri/workspacesinmodules-leftwith no module-specific config, so it renders every workspace niri reports. -
No
jqinstalled.python3is already a dependency viascripts/gamemode-watch.py, which also establishes the test convention (scripts/tests/gamemode-watch.test.py). -
Script convention:
scripts/<name>.{sh,py}symlinked to~/.local/bin/<name>(no extension in the bin name) byinstall.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:
- An undeclared named workspace is a silent no-op.
move-window-to-workspace minimizedleft the window where it was;focus-workspace minimizeddid not change focus and created nothing. - Declaring it makes both work. With
workspace "minimized"at the top level, a window moved from a dynamic workspace into the stash by name. - 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 minimizedrefused at every target index, andopen-on-outputpointing at a non-existent output did not dislodge it. This is why theMod+1..9binds must be offset by one. focus=falseis required on minimize. The default (focus=true) makes focus follow the window into the stash — the opposite of minimizing. With--focus falsethe window moved to the stash and focus stayed put. Thefocus=falseproperty parses in a KDL bind.- The IPC needed for restore exists:
move-window-to-workspace --window-id <id> <reference>andfocus-window --id <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:
// 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:
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):
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 "<app_id> — <title>" 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 orNonecurrent_workspace(workspaces)→ the focused dictstashed_windows(windows, stash_id)→ listlabel(window)→ display string."<app_id> — <title>"; if either is missing or empty (XWayland windows can lackapp_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:
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 msgfails → 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
minimizedworkspace, 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. Detectcurrent.id == stash.idandnotify-send "Already on the minimized workspace", exit 0. Windows there can be moved out with the normalMod+Shift+1..9binds. - Duplicate labels: two windows with the same
app_idand 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).
niri validate -c niri/config.kdlpasses.- On the first workspace,
Mod+Mon a window: it vanishes and focus stays on the first workspace (regression test for constraint 4 — if focus jumps to the stash,focus=falseis not working). - waybar shows the
minimizedpill. Mod+2to the second workspace,Mod+Shift+M, pick the window: it appears there and is focused.Mod+Shift+Mwith an empty stash: "No minimized windows", no hang.Mod+1lands on the first real workspace, not the stash — confirming the offset. Spot-checkMod+3andMod+Shift+2likewise.- Click the
minimizedpill in waybar to land on the stash, thenMod+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.