Question: How to avoid killing an i3 scratchpad window

2026-05-066 minWesley Schwenglei3scratchpadipcdebuggingsource codescratchpad_statepropertyx11reddit

Answer

It’s kinda complicated. i3 exposes a scratchpad_state on every node, but it is not relevant for the node you initially check. The window node of the application you are targetting. Today we are scratching an itch.

When I saw this question posted on Reddit I first thought: let’s look at the data. My Workspace on Demand daemon has an IPC event debugger. So I fired it up and went looking at the data, my debugger told me:

Processing window with payload close

The event looks like this, I removed the non-interesting bits:

perl
$VAR1 = {
  'container' => {
    'window_properties' => {
      'machine' => 'sputnik-odin',
      'title' => '~',
      'class' => 'kitty',
      'instance' => 'kitty',
      'transient_for' => undef
    },

    'floating' => 'auto_off',
    'window' => 209715214,
    'name' => '~',
    'scratchpad_state' => 'none',
  'sticky' => $VAR1->{'container'}{'urgent'},
  },
  'change' => 'close'
};

You can see the scratchpad_state… But this is an event that has happened, so I don’t think you can block it.

Than someone else posted an answer on how to kill windows of a particular type:

i3
bindsym $mod+Shift+x [floating] kill

This lead me into, oh, you can do two kill options:

bindsym $mod+k [tiling] kill
bindsym $mod+Shift+k [floating] kill
bindsym $mod+Shift+Ctrl+k kill

But that lead me to think: You can ask i3 anything and it will answer the question. The concept:

  • get the current active window
  • if the window has property scratchpad other then none then, don’t kill else i3-msg kill

To get the focussed window:

zsh
i3-msg -t get_tree | jq '.. | objects | select(.focused == true)'

Is it a scratchpad?

zsh
i3-msg -t get_tree | jq -r '.. | objects | select(.focused == true) | .scratchpad_state' | grep -qw 'none'

Now you can script it:

zsh
# script it
if [ $(i3-msg -t get_tree | jq -r '.. | objects | select(.focused == true) | .scratchpad_state') = "none" ]
then
  i3-msg kill
fi

# or...
i3-msg -t get_tree | jq -r '.. | objects | select(.focused == true) | .scratchpad_state' | grep -qw none && i3-msg kill

OP tested the solution and said: computer said no. Hmmmm. Ok, so windows in the scratchpad always have none as a state. This is breaking expectations. This must be a bug!

So, when you send a window to the scratchpad (i3-msg move scratchpad) you can look up the scratchpad state:

zsh
i3-msg -t get_tree | jq '.. | objects | select(.name == "__i3_scratch")'

When you ask i3 to show the scratchpad it sends it to your workspace as a floating window:

zsh
i3-msg -t get_tree | jq '[.. | objects | select(.floating == "user_on")]'

Great! You can than compare the “window” property against the window property of what you try to kill:

zsh
i3-msg -t get_tree | jq '
  (.. | objects | select(.focused == true) | .window) as $focused |
  [.. | objects | select(.floating == "user_on") | .window] |
  any(. == $focused)
'

There is a catch. I didn’t notice it at first either:

I don’t do select(.name == "__i3_scratch") anywhere. But.. if you do.. it still doesn’t do what you expect it to do:

zsh
i3-msg -t get_tree | jq '
  [.. | objects | select(.name == "__i3_scratch") | .. | objects | select(.floating == "user_on") | .window]
'

This shows nothing useful, nodes: [] and floating_nodes: []. Empty.

I went looking in the i3 source code, because I thought get_tree was broken, because I’ve tested i3-msg scratchpad show and it consistently sets the focus to the scratchpad. So it knows, it just isn’t telling us. From src/scratchpad.c:

c
/* If this was 'scratchpad show' without criteria, we check if there is a
     * unfocused scratchpad on the current workspace and focus it */
    Con *walk_con;
    Con *focused_ws = con_get_workspace(focused);
    TAILQ_FOREACH (walk_con, &(focused_ws->floating_head), floating_windows) {
        if (!con && (floating = con_inside_floating(walk_con)) &&
            floating->scratchpad_state != SCRATCHPAD_NONE &&
            floating != con_inside_floating(focused)) {
            DLOG("Found an unfocused scratchpad window on this workspace\n");
            DLOG("Focusing it: %p\n", walk_con);
            /* use con_descend_tiling_focused to get the last focused
             * window inside this scratch container in order to
             * keep the focus the same within this container */
            con_activate(con_descend_tiling_focused(walk_con));
            return true;
        }
    }

    /* If this was 'scratchpad show' without criteria, we check if there is a
     * visible scratchpad window on another workspace. In this case we move it
     * to the current workspace. */
    focused_ws = con_get_workspace(focused); TAILQ_FOREACH (walk_con, &all_cons, all_cons) {
        Con *walk_ws = con_get_workspace(walk_con);
        if (!con && walk_ws &&
            !con_is_internal(walk_ws) && focused_ws != walk_ws &&
            (floating = con_inside_floating(walk_con)) &&
            floating->scratchpad_state != SCRATCHPAD_NONE) {
            DLOG("Found a visible scratchpad window on another workspace,\n");
            DLOG("moving it to this workspace: con = %p\n", walk_con);
            con_move_to_workspace(floating, focused_ws, true, false, false);
            con_activate(con_descend_focused(walk_con));
            return true;
        }
    }

This is a bug for sure. We see floating->scratchpad_state != SCRATCHPAD_NONE so the serialization is wrong. Now let’s look at src/ipc.c and we see in dump_node this code:

c
ystr("scratchpad_state");
    switch (con->scratchpad_state) {
        case SCRATCHPAD_NONE:
            ystr("none");
            break;
        case SCRATCHPAD_FRESH:
            ystr("fresh");
            break;
        case SCRATCHPAD_CHANGED:
            ystr("changed");
            break;
    }

This looks to be good. But why isn’t it showing it on the window node itself? That is the tricky part of it. It is part of the node above it, its parent!
The code in src/scratchpad.c loops over the nodes and checks for floating_con nodes and descends into the node of your application and then activates that node. So let’s see how that plays out when we ask i3 this information:

zsh
i3-msg -t get_tree | jq '[.. | objects | select(.type == "floating_con" and .scratchpad_state != "none")]'

This works, it shows the window in the nodes section. Thus making your query:

zsh
i3-msg -t get_tree | jq '
  (.. | objects | select(.focused == true) | .window) as $focused |
  [.. | objects | select(.type == "floating_con" and .scratchpad_state != "none") | .nodes[].window] |
  any(. == $focused)
'

Now this either says true or false. Now you can script something that is able to ignore killing scratchpadded windows or not.

zsh
maybe=$(i3-msg -t get_tree | jq '
  (.. | objects | select(.focused == true) | .window) as $focused |
  [.. | objects | select(.type == "floating_con" and .scratchpad_state != "none") | .nodes[].window] |
  any(. == $focused)
')

[ $maybe = 'true' ] && exit 0
i3-msg kill

# or use case

case $maybe in
  'true') i3-msg scratchpad show;;
  'false') i3-msg kill;;
esac

And your i3 config:

i3
bindsym $mod+k exec $HOME/bin/dont-kill-scratchpads
bindsym $mod+Shift+k kill

You can now safely hit the kill switch on your scratchpads and not kill them and have a escape hatch that kills everything, including scratchpads.