Files
dotfiles/docs/superpowers/specs/2026-07-16-niri-minimize-design.md
T
funman300 05cc026d75 fix(window-restore): re-read workspace/window state after the wofi pick
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.
2026-07-16 10:10:40 -07:00

10 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 at niri/config.kdl.

  • The existing binds are no-ops:

    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/<name>.{sh,py} symlinked to ~/.local/bin/<name> (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 <id> <reference> and focus-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 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:

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

Unitscripts/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.
  • 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.