Click a toast, land in the right terminal: reconstructing $WINDOWID on WSL
You kick off a long task in a WSL tab, switch virtual desktops, and get on with something else. Ten minutes later a toast slides in: the session needs you. You click it, and either nothing happens or some Windows Terminal window comes up, rarely the one that pinged. Now you're cycling through four terminal windows on three desktops looking for one tab.
This post makes that click land on the window and tab the notification came from. The pieces: capture the session's window handle at the one moment it's provably knowable, ride it on the toast as a protocol link, resolve it with a small PowerShell handler, and pick out the tab with a title you stamp yourself. Along the way: why this is a one-liner on X11, solved by design in kitty, and still unsolved in Windows Terminal.
The trick in one diagram
you type into the tab a toast is raised you click it
│ │ │
▼ ▼ ▼
the foreground window IS the notifier stamps the Windows routes the
this session's window; cached handle onto the term-focus: link to a
cache WT_SESSION → HWND ──► toast as a click target: ──► handler that validates
launch="term-focus:591742 the handle, restores and
?tab=<stamped title>" focuses the window, then
selects the stamped tabNo polling, no window enumeration at notify time, no title matching. The mapping is observed once, when it can't be wrong, and consumed later.
Why your shell can't know its own window
On X11 this problem never existed. Every terminal exports the window that hosts it, and focusing it from a notification action is one command:
$ echo $WINDOWID
79691782
$ wmctrl -ia "$WINDOWID" # raise and focus it, across workspacesA WSL shell in Windows Terminal gets no equivalent. WT_SESSION is an opaque per-session GUID that Windows Terminal exposes nowhere else: there's no CLI to list tabs, no API to resolve a session to a window, and tabs aren't Win32 windows you could enumerate (microsoft/terminal#18692, #19818 are the open requests). Walking process ancestry through the interop bridge dead-ends too: Windows executables launched from WSL parent to the distro's wslhost.exe, not to anything per-tab.
So the notifier, a shell script firing a toast through powershell.exe, knows which session pinged but has no way to ask where it lives on screen.
How terminals that solve it solve it
The terminals that get this right own the notification end to end. kitty's OSC 99 protocol lets clicking the notification focus the window that emitted the escape, and can even report the click back to the program. iTerm2 does the same for its session notifications, and Ghostty has the same behavior on its wishlist. The pattern: the terminal is the one process that knows which pane sent which escape, so activation is bookkeeping, not archaeology.
External notifiers don't have that luxury, and it shows. On macOS the ceiling is app-level activation (terminal-notifier -activate brings the app forward, wrong tab and all). On Windows the common answer is nothing: clicking a toast raised under an unregistered app id is a no-op, and at least one extension's README promises click-to-focus while its code raises a bare toast with no activation wiring at all. Windows Terminal itself turns an OSC 9 escape into a toast, but attributes it to the app generically: clicking activates Windows Terminal, not the tab that wrote the escape.
Catch the window in the act
You can't query the session-to-window mapping, but you can observe it. The moment you type into a session, the foreground window is that session's window. Tautologically. Any hook that fires on user input is a valid capture point; a coding agent's prompt-submit hook (Claude Code's UserPromptSubmit) is ideal, because agent sessions are exactly the ones that ping you later.
The capture is a background PowerShell probe, gated so it writes nothing unless the foreground process really is Windows Terminal:
# P/Invoke declarations for GetForegroundWindow / GetWindowThreadProcessId elided
$h = [W.U]::GetForegroundWindow()
$procId = 0
[void][W.U]::GetWindowThreadProcessId($h, [ref]$procId)
if ((Get-Process -Id $procId).ProcessName -eq 'WindowsTerminal') { $h.ToInt64() }The shell side caches the result per session, throttled so rapid prompts don't stack probes:
~/.cache/wt-window/4f2a…-…-… # one file per $WT_SESSION, contents: 591742That's the whole reconstruction of $WINDOWID: a file that says "the last time you provably looked at this session, it lived in window 591742".
Stamp the toast with a way back
Windows toasts support protocol activation, and it works from an unpackaged PowerShell-raised toast. When the notifier has a cached handle for the pinging session, it adds two attributes to the toast XML:
<toast activationType="protocol" launch="term-focus:591742">
<visual>…</visual>
</toast>When it has no handle (a session you never typed into), it omits them and the click keeps the default behavior. One guard worth copying: only stamp the toast once the protocol is actually registered, so a half-provisioned machine never ships a link nothing handles.
A protocol handler that won't focus just anything
term-focus: is a per-user protocol class, three registry values under HKCU\Software\Classes:
term-focus
(Default) = URL:term-focus
URL Protocol =
shell\open\command = wscript.exe "…\term-focus-launch.vbs" "%1"The wscript shim exists so the click never flashes a console window; it strips quotes from the argument and hands the URI to the real handler. The handler validates before it focuses:
param([string]$Uri = '')
if ($Uri -notmatch '^term-focus:/{0,2}(\d+)') { exit 0 }
$hwnd = [IntPtr][int64]$Matches[1]
# Handles are recycled: require a live window of Windows Terminal's own class.
$live = [W.U]::IsWindow($hwnd) -and (WindowClass $hwnd) -eq 'CASCADIA_HOSTING_WINDOW_CLASS'
if (-not $live) { $hwnd = [W.U]::FindWindow('CASCADIA_HOSTING_WINDOW_CLASS', [NullString]::Value) }
if ([W.U]::IsIconic($hwnd)) { [void][W.U]::ShowWindow($hwnd, 9) } # SW_RESTORE
[void][W.U]::SetForegroundWindow($hwnd)Two of those lines are load-bearing in ways that aren't obvious:
- The class check is a security boundary, not pedantry. A registered protocol is invocable by anything, including a link in a browser, and "focus an arbitrary window by number" is a small phishing primitive. Scoping the handler to Windows Terminal windows caps the blast radius at harmless.
- The
FindWindowfallback (frontmost Windows Terminal window in z-order) is what stale handles degrade to. That's the same behavior a plain toast click gave you before, so the worst case is the old status quo.
The part you don't have to build: focusing a window on another virtual desktop makes Windows switch to that desktop. Activation carries you there.
The tab too: stamp a title, select it by name
Windows Terminal doesn't expose a session-to-tab mapping either, but tabs are UI Automation TabItem elements, their name is the tab title, and SelectionItemPattern.Select() really switches tabs. Matching against titles you don't control is a losing game (a coding agent retitles its tab constantly), so the notifier writes one it does control: at ping time it stamps the pinging tab's title (OSC 0 over /dev/tty reaches the tab whether or not it's focused) and sends the same string along on the link:
term-focus:591742?tab=%F0%9F%94%94%20main%20%28~%2Fdotfiles%29The handler decodes the parameter and selects the matching tab. One constraint took a live probe to discover: an unrendered Windows Terminal window (minimized, sitting on another virtual desktop) exposes an empty UIA tree, so its tabs only become findable after the window comes up. Select after SetForegroundWindow, with a short retry, or you'll find nothing.
Two timing details keep it honest. The stamp is delayed a couple of seconds so the toast's own title read still captures the real tab title for its headline. And the stamp needs no cleanup code, but know when it heals: the agent reasserts its title when it resumes, and a shell prompt hook re-stamps its own on the next prompt render. That's interaction, not focus, so a tab you only glance at keeps the bell in its title until you type into it (the shell can't see tab focus; Windows Terminal sends it no focus events to hook).
What this doesn't do
- The tab match can go stale in its own way. Rename or move the tab between ping and click (or click within the stamp's delay) and you get window-level focus, with the attention ring (OSC 9;4) still marking the pinging tab.
- The mapping can go stale. Move a tab into another window and the cached handle points at its old home until you next type into it; the fallback catches the window-closed case.
- Sessions need a capture moment. A tab you never typed into keeps the default click behavior. In practice the sessions that ping you are the ones you were just driving.
The real fix is upstream
Everything above compensates for missing Windows Terminal primitives, and the honest long-term play is asking for them. The open issues to watch and weigh in on are microsoft/terminal#18692 and #19818, and each landing deletes a layer of this post:
- kitty-style focus-on-click on the toasts Windows Terminal already raises for OSC 9 deletes all of it: the capture probe, the title stamp, the protocol handler. The terminal knows which tab emitted the escape; nothing external can.
- Short of that, a session-to-window/tab query (a
WT_WINDOWenv var, or a tab list the shell can read) deletes the fragile half: no more catching the window at prompt submit, no more stamping titles to make tabs findable. The handler shrinks to "focus this window, select this tab" with real identifiers instead of reconstructed ones.
Until then, the reconstruction above is the whole game.
Provisioned, not hand-built
I run this as part of Dotfiles Pro: the bash role's notify script does the capturing and stamping (wired to Claude Code's hooks by the claude role), and the windows role deploys the handler and registers the protocol, all applied by Ansible and re-applied idempotently. If you'd rather wire it into your own setup, hand the mechanism to your agent:
Copy this prompt to your coding agent
Read https://dotfiles.pro/blog/wsl-toast-click-focus-terminal and build the
three pieces for my setup: (1) a capture hook on my prompt-submit event (I use
Claude Code) that records the foreground Windows Terminal HWND per WT_SESSION,
gated on the foreground process being WindowsTerminal; (2) toast stamping in
my notifier: activationType="protocol" plus a launch URI carrying the cached
handle, only when the protocol is registered; (3) an HKCU protocol handler
that validates the HWND (live, CASCADIA_HOSTING_WINDOW_CLASS), restores if
minimized, falls back to the frontmost Windows Terminal window, and focuses
it. Use a wscript shim so clicks never flash a console. Optionally (4): stamp
the pinging tab's title at ping time, pass it as a ?tab= parameter on the
launch URI, and select the matching TabItem via UI Automation after the
window is focused (UIA trees are empty for unrendered windows).X11 handed every shell its window id decades ago; Windows Terminal still won't. Until it does, catch the window while you're typing into it, and every toast after that knows the way home.