Unwait

Building a macOS overlay that never steals focus

· 4 min read macos tauri nspanel overlay rust

Our overlay appears while you type in a terminal, so stealing a single keystroke kills the product. Here is the NSPanel setup that makes a window float without ever taking focus, in Tauri, with the traps we hit.

Unwait shows a small card in the corner of your screen while Claude Code or Codex is working. The person watching that card is, by definition, someone who was just typing in a terminal and is about to type in it again. If our window grabs keyboard focus for even one keystroke, the product is dead. Nobody keeps an app that eats characters mid-command.

A default window, in any framework, does exactly that. The moment it appears, macOS activates your app and the terminal loses key status. This post is the setup that avoids it, with the specific traps we hit building it in Tauri. The AppKit parts apply to any stack.

Focus stealing is two separate problems

It took us a while to see this clearly: there are two distinct ways a window takes focus, and you have to turn off both.

The window can become key. The key window is the one receiving keyboard input. AppKit decides this per window via canBecomeKeyWindow, and for ordinary windows the answer is yes, so showing one makes it key.

The app can become active. Clicking any window of an app normally activates the whole app, which deactivates the terminal's app, which moves key status even if your window itself refused it.

The fix for the first is an NSPanel subclass that returns false from canBecomeKeyWindow. The fix for the second is the NSWindowStyleMaskNonactivatingPanel style mask, which lets the panel receive clicks without activating the app. Ship only the first and clicking your overlay still yanks the terminal's focus. Ship only the second and merely showing the window steals it. You need both.

The Tauri version

Tauri creates plain NSWindows, so the overlay has to be converted after creation. The tauri-nspanel plugin does the class swap. Our setup, condensed:

use tauri_nspanel::{tauri_panel, CollectionBehavior, PanelLevel, StyleMask, WebviewWindowExt};

tauri_panel! {
    panel!(OverlayPanel {
        config: {
            can_become_key_window: false,
            can_become_main_window: false
        }
    })
}

fn setup_overlay_panel(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
    let window = app.get_webview_window("overlay").ok_or("overlay window not found")?;
    let panel = window.to_panel::<OverlayPanel>()?;

    // Borderless + non-activating: clicks reach the panel without activating the app.
    panel.set_style_mask(StyleMask::empty().nonactivating_panel().value());

    // Float above normal windows.
    panel.set_level(PanelLevel::Status.value());

    // Visible on every Space, does not follow Space switches,
    // and allowed to appear over a full-screen terminal.
    panel.set_collection_behavior(
        CollectionBehavior::new()
            .can_join_all_spaces()
            .stationary()
            .full_screen_auxiliary()
            .value(),
    );

    // Panels hide when the app deactivates, by default. Our app is never active.
    panel.set_hides_on_deactivate(false);

    panel.set_transparent(true);
    panel.set_has_shadow(false);
    Ok(())
}

The window itself is declared hidden in the Tauri config and shown later, so the conversion happens before the user ever sees it.

Three of those lines are the ones people miss.

set_hides_on_deactivate(false). NSPanel's default is to hide itself whenever its app deactivates. A non-activating overlay's app is more or less permanently deactivated, because the whole point is that the terminal stays active. With the default behavior your panel works in testing, when your app happens to be frontmost, and vanishes in real use. This one line is the difference.

full_screen_auxiliary(). Terminal users live in full-screen windows. Without this collection behavior your panel simply does not appear over them, and there is no error to tell you why.

The window level. A panel that never activates never comes forward on its own, so it needs an elevated level to sit above normal windows. Status level is above app windows but below screen savers and the Dock's context menus, which is where an ambient overlay belongs.

Clicks work, keyboards do not, and that is a feature

A window that can never become key can still be clicked. macOS routes mouse events to whatever window is under the cursor regardless of key status, so buttons in the overlay work normally while every keystroke continues into the terminal.

Take the constraint seriously though: nothing in your overlay can want a keyboard. A text field in a never-key window is a text field you cannot type into. Same for keyboard shortcuts scoped to the window, they will never fire. We designed for mouse-only interaction from the start, and we also ruled out global hotkeys, because a global hotkey by definition fights with whatever the terminal wanted that key combo for.

If part of your UI genuinely needs typing, that part has to live in a separate, ordinary window that is allowed to take focus when the user deliberately opens it. Ours is the settings window. The overlay itself never asks.

The resize flicker we shipped and then fixed

Our card resizes to fit its content. The obvious implementation is two calls: set_size, then set_position to re-anchor the corner. That produces a visible one-frame tear: between the two calls the window has its new size at its old position, and since the panel resizes on every card flip, it flickered on every tap.

The fix is to apply both in one operation with NSWindow's setFrame, which takes position and size together:

let cocoa_y = primary_screen_height - y - h;
ns_win.setFrame_display(
    NSRect::new(NSPoint::new(x, cocoa_y), NSSize::new(w, h)),
    true,
);

The coordinate flip matters. Tauri's coordinates put the origin at the top-left with y growing downward. Cocoa's origin is the bottom-left of the primary screen with y growing upward, so a window's Cocoa y is primary_height - y - height. Get it wrong and the panel teleports somewhere surprising, which is at least easier to debug than a flicker.

While you are positioning things: anchor to the screen's visibleFrame, not its frame. The visible frame excludes the menu bar and the Dock, so a bottom-anchored overlay does not sit on top of the Dock.

How you know it actually works

Our acceptance criterion is written down as: with the overlay showing, typing in the terminal drops zero characters. The test is exactly that. Start a long agent turn, wait for the card, and type a long command while it is up. Then click a button on the card and keep typing. Every character should land in the terminal, and the terminal's window should never lose its focused appearance, title bar and cursor included.

It is a boring test, and it catches everything above: the style mask, the panel class, and the hide-on-deactivate default, each of which fails it in a different way.

Further reading

Apple's NSPanel documentation covers panel behavior, and tauri-nspanel is the plugin that makes the conversion possible from Tauri. The overlay described here is the one that ships in Unwait, where it shows learning cards while your AI coding agent works.

Unwait does this for you

A macOS menu bar app that watches your Claude Code and Codex sessions, shows a short card while they work, and puts a strip on screen the moment one finishes. Free for two weeks, no card and no sign up.

Try for free
← All posts