Skip to content

Native Windows port: run kitty on Windows 11 without WSL - #1

Merged
ecstra merged 142 commits into
masterfrom
windows-native-port
Jul 24, 2026
Merged

ecstra merged 142 commits into
masterfrom
windows-native-port

Conversation

@ecstra

@ecstra ecstra commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Runs kitty natively on Windows 11. No WSL, no X server, no Cygwin layer under
the terminal itself. 140 commits, 264 files, +17k lines.

This is a fork-only branch. None of it is proposed for upstream, and it does not
touch the Linux or macOS paths beyond what a shared file required.

How it runs

Windows console programs get a pseudoconsole. MSYS2 and Cygwin shells get a real
Cygwin pty through a bridge written for this port, which keeps conhost out of the
data path. That second path is why zsh does not bounce and flicker here the way it
does when it goes through ConPTY. Rendering is the same OpenGL path every other
platform uses.

What works

  • Windows 11 acrylic, following the same WinUI AcrylicBrush recipe Windows
    Terminal uses: the noise texture, the blurred backdrop, the luminosity and tint
    blends, assembled as a composition effect graph. kitty owns the graph rather
    than asking DWM for a backdrop, so it keeps its blur when the window loses
    focus, which is where DWM's own version goes flat. Measured against Terminal on
    the same wallpaper it matches to within half a percent.
  • A title bar kitty draws itself, sharing the terminal background, with rounded
    corners and its own window buttons.
  • Any shell, each on the right plumbing, with shell integration for each.
  • Kittens, running as the real Go kittens: icat, hints, unicode_input, diff,
    themes, choose-files, ask and the confirmation prompts.
  • Images through the graphics protocol, routed around conhost via a private named
    pipe because conhost eats the escape sequences that carry them.
  • Remote control over listen_on tcp:.
  • Desktop notifications as real Windows toasts, and an Explorer entry in the main
    Windows 11 menu, which needs a signed MSIX rather than a registry key.
  • Tabs, scrollback, selection, the Windows clipboard, per monitor DPI, config
    reloading, and dropping files onto a window.

What is not there

  • Remote control over a Unix socket. Windows has AF_UNIX, CPython does not build
    it (Enable AF_UNIX support in Windows python/cpython#77589), so tcp: is the way in.
  • dnd, panel, quick-access-terminal and desktop-ui, which refuse cleanly.
  • Dragging content out of a window. Dropping onto one works.

Windows 10 is not targeted or tested. Nothing blocks installing it there and the
terminal should still run, but the acrylic and the title bar will not look right.

Testing

The full suite passes on Windows, Python and Go. CI builds, lints, tests and
packages on every push, and uploads the installer.

Tests that cannot pass here are skipped with a stated reason rather than dropped
from a list, so what is uncovered stays visible in the log. The gaps behind them
are recorded in WINDOWS_TODO.

Docs

  • docs/windows-port.md: installing, building, config location, what is missing.
  • docs/windows-internals.md: how each piece works.
  • docs/decisions.md: why, including the approaches that were reversed.
  • example/.config/kitty: a working config, commented for Windows.

ecstra added 30 commits July 18, 2026 16:37
Port kitty to run natively on Windows without WSL. The Unix core
(fork/forkpty/termios/poll/unix-sockets) is replaced with a ConPTY +
Win32 layer, plus a from-scratch GLFW Win32/WGL backend.

- wincompat shim layer (kitty/wincompat): poll() via PeekNamedPipe +
  WSAPoll, pipe2/socketpair/mmap/waitpid, header shims, Python stubs
- child.c ConPTY path: open_pty/spawn/resize_pty via CreatePseudoConsole
  + CreateProcess; input/resize wired to the pty
- GLFW Win32 + WGL backend (glfw/win32_*, wgl_context.*)
- setup.py: MinGW build, -mwindows launcher, embedded kitty.ico
- Fixes: ConPTY std-handle inheritance, non-blocking wakeup pipe,
  config path resolution (drive letters), PNG read in binary mode
- UI: transparency + acrylic blur, titlebar themed to bg colour,
  window icon, shell configurable (cmd/powershell/pwsh/wsl/custom)

Runs a shell, renders output, takes input, resizes, reads ~/.config
/kitty/kitty.conf, applies themes and transparency.
Reclaim the whole window as client (WM_NCCALCSIZE) so the caption shares
the terminal's acrylic surface, with resize via WM_NCHITTEST and a
draggable top strip. kitty's os_window_regions reserves a ~36px title-bar
strip on Windows so the terminal grid renders below it. The strip now
matches the body exactly (colour + opacity + blur). Caption buttons are
stubbed here and added next.
- Custom-drawn min/max/close buttons in a layered overlay (GDI+), square
  and rounded with hover states; whole-button hit-testing (base alpha so
  the layered window is not click-through over the button area)
- Fix window close: surround() in cli.py crashed on sys.stdout=None (GUI
  process has no console), which blocked the close-confirmation path so
  the X button / alt-F4 / WM_CLOSE never closed the window
- Title bar strip now filled with the window background at the same
  opacity as the cells (border rect in pyset_borders_rects), so the
  caption matches the body exactly instead of being more transparent
- Rounded window corners + thin dark border via DWM
- Window icon set again (WM_SETICON) now that the custom frame draws no
  caption, so the taskbar/alt-tab show kitty
- Caption buttons enlarged to 30px with smaller glyphs; maximize/restore
  drawn as a small clean square (was oversized)
- Render during the Win32 modal resize loop (WM_ENTERSIZEMOVE timer +
  WM_SIZE tick) so the window stays live instead of DWM stretching the
  stale frame
- Re-apply the acrylic accent on maximize/restore (DWM drops it on those
  transitions)
- Title-bar strip raised to 40px to fit the larger buttons
…itial pty size

- keycodes[] widened from short to int so FKEY values (>=0xe000) no longer
  sign-extend to 0xffffXXXX. Enter/Backspace/Tab/arrows now encode correctly
  and reach the child; this also lets right-arrow/tab accept PSReadLine
  inline predictions.
- rework WM_KEYDOWN/WM_CHAR: forward functional keys and shortcut-modified
  keys via keydown, deliver plain text via WM_CHAR, and suppress the
  duplicate control-char WM_CHAR.
- caption buttons render the native Segoe Fluent Icons / MDL2 Assets glyphs
  (E921/E922/E923/E8BB) instead of hand-drawn shapes.
- acrylic blur falls back to classic blur-behind when maximized (DWM disables
  acrylic for zoomed windows) and is re-applied across maximize/restore.
- create the ConPTY at the real window size instead of a fixed 80x24 so the
  shell first prompt is laid out correctly.
- drop the "WxH cells" live-resize banner on Windows.
10px em rendered the Segoe caption icons too thin against the 30px buttons;
bump to 12px so stroke weight matches the native title bar proportion.
- caption icons: bold 10px so the strokes are not hairline, centering nudge
  reduced to 1px.
- background_blur now behaves as on/off: 0 uses ACCENT_ENABLE_TRANSPARENTGRADIENT
  (translucent, no blur) instead of always forcing blur-behind. The Windows
  accent API has no blur-radius control, so any value > 0 just enables acrylic.
- drop the disable->enable accent toggle that flashed the blur off during the
  maximize/restore transition.
- use ACCENT_ENABLE_BLURBEHIND in every window state instead of switching
  between acrylic (windowed) and blur-behind (maximized); acrylic is disabled by
  DWM when maximized, so the switch made the blur look different between states.
- background_blur 0 now disables the accent (opaque) instead of using a
  transparent-gradient state that did not compose with the GL surface (pink
  screen). On Windows transparency is produced by the blur compositor, so 0 is
  opaque and any value > 0 is translucent+blur.
…lur transparency

- caption buttons now render the user-provided Material Symbols icons
  (minimize/maximize/restore/close), rasterized to embedded BGRA and drawn
  scaled with high-quality interpolation, replacing the font glyphs.
- stop setting DWMWA_SYSTEMBACKDROP_TYPE=acrylic: it does not compose with the
  GL surface and painted a second, region-limited blur over part of a maximized
  window (the visible brighter rectangle).
- background_blur 0 with a translucent background now extends the DWM frame
  across the client area ("sheet of glass") so the window is transparent without
  blur (previously it was opaque). blur > 0 still uses accent blur-behind.
- re-apply composition on WM_SETFOCUS so the window is not grey/opaque on open
  until first interacted with.
…mode

- caption icons now come from the user-provided, pre-centered 67x67 PNGs
  (normalized to a common size) instead of the rasterized SVGs.
- in no-blur "glass" transparency the extended DWM frame made DWM paint the
  standard caption over the custom frame (the apparent "window behind" / dual
  title bar). Disable DWM non-client rendering (DWMWA_NCRENDERING_POLICY =
  DWMNCRP_DISABLED) while in glass mode so only the custom frame shows. The
  blur>0 path is unchanged.
…ller icons

- caption buttons are now a WS_CHILD layered window instead of a popup, so DWM
  animates them together with the main window during minimize/maximize/restore
  (they no longer snap to the final position ahead of the window).
- disable DWM non-client rendering once at window creation (DWMNCRP_DISABLED) so
  the standard caption never flashes on open in glass-transparency mode.
- caption icons drawn at 16px (from 18) and nudged 1px down to center them.
…xperiment)

Per research into how Windows Terminal keeps blur uniform and present when
maximized: plain blur-behind darkens toward the edges and acrylic is dropped by
DWM when maximized. Enable DWMWA_USE_HOSTBACKDROPBRUSH and use acrylic, which
samples the desktop onto a redirection surface and should stay even across all
window states. Revert to ACCENT_ENABLE_BLURBEHIND if acrylic is unavailable on
the target machine.
… titlebar

- CRITICAL: _glfwPlatformDestroyWindow called free() on window->win32.bigIcon,
  which is an HICON handle (CreateIconIndirect), not heap memory. Freeing it
  corrupts the heap and crashes the whole process on window close. With one
  window this is masked by process exit, but with several OS windows (ctrl+shift+n)
  closing one killed them all. Use DestroyIcon for bigIcon/smallIcon instead.
- revert the DWMNCRP_DISABLED non-client-rendering hack: it made DWM fall back to
  the classic (win98-style) frame on open/resize/maximize/tab-switch.
- instead drop WS_CAPTION from the custom-frame style so DWM does not paint its
  own caption in glass-transparency mode (the phantom title bar) while keeping
  modern composited rendering. Snap/maximize still work via WS_MAXIMIZEBOX/THICKFRAME.
- reduce the title-bar strip from ~40px to ~32px (buttons 30->28) to cut the gap
  between the caption and the first terminal line.
- revert the host-backdrop acrylic experiment back to blur-behind (acrylic
  flickered on resize). Uniform-blur work deferred.
- revert the title-bar strip back to 40px (buttons 30px): the 32px version put
  the caption buttons too close to the window edges.
- restore WS_CAPTION and re-apply DWMNCRP_DISABLED only in glass (blur-off)
  transparency mode. Disabling non-client rendering unconditionally forced the
  classic (win98) frame in every mode; scoping it to glass mode removes the
  phantom DWM caption there while leaving blurred/opaque modes on modern
  composited rendering.
…no win98)

The DwmExtendFrameIntoClientArea "sheet of glass" is what made DWM paint its own
caption (the phantom/double title bar); hiding it with DWMNCRP_DISABLED forced
the classic win98 frame instead. Replace both with DwmEnableBlurBehindWindow
using an *empty* blur region: this enables per-pixel alpha compositing but no
blur and, unlike frame extension, never draws a window frame or caption. Ref:
github.com/ands/borderless-window-opengl. Blur>0 still uses the accent policy.
…ding defaults

- close confirmation (confirm_os_window_close default -1) is drawn by the 'ask'
  kitten, which is not built on Windows, so the prompt never completes and the
  window could not be closed with a default/empty config. Skip the confirmation
  on Windows so the window always closes.
- remember the maximized state across restarts: capture GLFW_MAXIMIZED when an OS
  window closes, store window-maximized in cached_values, and start the first
  window maximized when restoring (gated on remember_window_size). Previously a
  maximized window reopened as a full-screen-sized floating window.
- Windows-port option defaults: window_padding_width 1/10/10/10 (tiny top since
  the title-bar strip already spaces the top; normal sides) and placement_strategy
  top-left, so an empty kitty.conf looks right. Explicit config still overrides.
Previously closing a maximized window saved the full-screen size as window-size,
so on the next launch the window opened maximized (correct) but restoring it
resized to full-screen. Only record window-size when the window is not
maximized, so it retains the last real size and restore returns to it.
- kitty_exe now returns the path with the .exe suffix so the overlay kitten child
  can spawn. The kitten UI still does not render because the kitten TUI is not
  ported to the Windows console, which docs/windows-port.md tracks.
- default confirm_os_window_close to 0 and skip the close confirmation, since it
  is drawn by the ask kitten which needs the unbuildable Go tool.
- default the tab title to the active folder name. pwsh has no shell integration
  on Windows and sets the title to its own exe path, so every tab read the same.
Cover what works, what does not, how to build, how the Windows specific code
works, and the reasoning behind the non obvious choices.
Showing the active folder needs the child working directory, and cwd_of_process
has no Windows path yet, so the folder name resolved to empty and the tab title
went blank. Revert to the default title until cwd reading through the process PEB
is added.
GUI-subsystem kitty.exe cannot attach to the pseudoconsole it is spawned
into. A Python kitten hosted by it had no console and no standard handles,
so opening CONIN$ and CONOUT$ failed and nothing rendered. Only the
console-subsystem Go kittens worked.

This builds a console-subsystem twin of the launcher, kitty-console.exe,
and runs Python kittens through it. That process attaches to the pty and
gets real console I/O.

It also ports the kitten TUI loop to the Windows console. termios and the
/dev/tty functions do not exist here, and asyncio cannot wait on a console
handle, so loop_win32.py drives CONIN$ and CONOUT$ directly. Raw mode
comes from SetConsoleMode with virtual-terminal input and processing, a
reader thread feeds the loop, writes go straight to the console, and the
size comes from the console screen buffer.

resize_window now renders and takes input. Kittens whose logic lives only
in the Go tool, such as ask, hints, and unicode_input, still need porting.
Each keystroke redraws the whole screen. Writing every fragment to the
console as it arrived let kitty render the half-cleared screen between
fragments, which showed up as flicker with the cursor jumping to the top
left on every key. Coalesce all writes made in one event-loop turn into a
single console write, so a whole frame reaches kitty at once.

The reader thread blocked in a console ReadFile. On quit that read stayed
blocked, so the kitten process hung on exit and kitty never closed the
overlay. Cancel the pending read with CancelSynchronousIo and join the
thread so the kitten exits promptly.
The ask kitten is Go-only upstream, so on Windows set_tab_title, the
yes/no confirmation prompts, and the choice prompts did nothing. Restore
a Python implementation adapted from the pre-Go version: the yesno,
choices and password handlers drive the ported TUI loop unchanged, and
the line editor now uses the same loop-based LineEdit as password instead
of readline and a controlling tty, neither of which exists on Windows.

Un-wrap ask on Windows so it runs through the console launcher.
…d close

Kittens are pure VT programs, so a pseudoconsole only got in the way. Its
round trip swallowed the synchronized-update escape (mode 2026), so kitty
rendered the half-cleared screen between frames and every keystroke
flickered. And because conhost keeps the pseudoconsole output open after
the kitten exits, kitty never saw EOF and the overlay would not close
(waitpid(-1) cannot work on Windows either, so nothing reaped it).

open_pty gains a use_pty flag. Kittens now open with use_pty False: a plain
pipe pair, the child's ends inherited through a handle list with
STARTF_USESTDHANDLES, and CREATE_NO_WINDOW so the console launcher stays
headless. The kitten's escape codes reach kitty untouched (2026 survives,
no flicker) and the pipe reports EOF the instant the kitten exits, so its
window closes.

The kitten side drops the console entirely: it reads and writes its stdio
handles directly and takes its size from the OVERLAID_WINDOW_ env kitty
already sets. Verified over a pipe harness: 2026 passes through and the
output pipe hits EOF on quit.
The Windows pty table was never cleaned up: close_pty was never called, so
each entry stayed in use with its old read fd. When a kitten closed and the
next one reused that fd number, windows_pty_write_fd_for matched the stale
entry first and returned a dead write fd, so the second kitten on a window
received no input (it drew but could not be typed into or closed). The
write (input) fd leaked too, as nothing closed it.

Add windows_pty_close_for and call it from the child-monitor's cleanup_child:
it closes the pseudoconsole (if any) and the write fd, then clears the entry
so a reused read fd can no longer shadow a live child.
add_os_window creates the tab-bar VAO before the window handle exists and
without making the window's context current, so it inherited whatever GL
context was already bound. VAOs are per-context and are not shared even
between contexts that share buffers, so a second OS window opened at
runtime (ctrl+shift+n) landed its tab-bar VAO in the first window's
context. Binding it while rendering the second window then drew another
window's cell data: the tab bar showed a frozen copy of a terminal line
and never refreshed. Windows opened via a session at startup happened to
have the right context current, which is why only runtime ctrl+shift+n
reproduced it, and why two separate processes were always fine.

After the handle is set, make the new window's context current and
recreate the tab-bar VAO in it.
A pseudoconsole never reports EOF on its output pipe, so kitty could not
learn a shell had exited by watching the pipe, and there is no SIGCHLD or
waitpid(-1) on Windows to fall back on. Register a one-shot wait on each
child process. When it fires it wakes the io_loop, which reaps exited
ConPTY children by their stored process handle and closes their window.

Pipe-mode kittens are left to the existing EOF path, which drains their
final output (the result) before closing. Reaping them here would race
ahead of that drain and lose it.
_glfwPlatformSetClipboard was a no-op, so ctrl+shift+c only updated
kitty's own in-memory copy and nothing reached other apps. Windows has an
eager clipboard, so pull kitty's selection text through the clipboard
iterator API and hand it to the OS as CF_UNICODETEXT.
terminfo/78 is a Linux symlink to x/, which Windows checks out as a broken
one byte file, so the hashed hex directory that Git's MSYS2 ncurses looks
in (78 is hex for x) does not exist and git log, less and vim cannot find
xterm-kitty. Make 78 a real directory holding the compiled entry. kitty
already exports TERMINFO at this dir, so pagers resolve it with no config.
ecstra added 28 commits July 24, 2026 09:31
The port used DWMWA_SYSTEMBACKDROP_TYPE with DWMSBT_TRANSIENTWINDOW. That is
real acrylic, but DWM renders it with maths of its own that cannot be matched
to Windows Terminal, and it needed two workarounds: a forced active
WM_NCACTIVATE so DWM would not drop the material on focus loss, and an off/on
toggle of the attribute after every maximize so DWM would resample a stale
backdrop.

glfw/win32_acrylic.c builds the material directly as a Windows.UI.Composition
effect graph, reproducing the 19H1 AcrylicBrush recipe from microsoft-ui-xaml
through IGraphicsEffectD2D1Interop: a host backdrop brush, a luminosity blend
to flatten contrast, a colour blend for the tint, and the same 256x256 noise
texture WinUI ships, at the same 2% opacity. Measured against Terminal over the
same wallpaper it matches to 0.04% in luma, and its spread and gradient agree
to within 3%, so the blur strength matches too.

A composition tree always draws above the window redirection surface, so the
window now takes WS_EX_NOREDIRECTIONBITMAP and every pixel comes from the tree:
the acrylic underneath, the OpenGL output above it in a composition swapchain.
OpenGL reaches that swapchain through WGL_NV_DX_interop2. The back buffer is
bound as an FBO once and never unbound, so the renderer, which only ever draws
to framebuffer 0, is untouched.

The acrylic layer is the background now, so kitty must not paint the default
background as well or the tint lands twice and the window reads as solid.
platform_bg_alpha returns zero when blur is on for that reason. Cells with any
other background colour are unaffected.

WGL_NV_DX_interop2 is an AMD and NVIDIA extension that Intel does not reliably
ship. When it or any other step is missing the window falls back to glass,
transparent with no material, exactly as before.
Four things were wrong in the first cut, none of them in the effect graph.

Windows.UI.Composition will not activate on a thread with no DispatcherQueue,
and reports that as a bare E_ACCESSDENIED from RoActivateInstance. The queue
belongs to the thread rather than to a window, so it is created once and never
released: releasing it when a window closed left the next window unable to make
another, since CreateDispatcherQueueController answers RPC_E_WRONG_THREAD once
the thread already has one.

Registering the swapchain with GL needs a current context, and
_glfwRefreshContextAttribs restores whatever was current before it ran, which
during window creation is nothing. All the GL work moved out of Create into
BindFramebuffer, which the caller now wraps in a make-current pair.

The swapchain resize moved off WM_SIZE to the next present. WM_SIZE arrives
with no context current, so unregistering the interop object failed silently,
the swapchain kept a live reference to its back buffer, ResizeBuffers failed,
and the window stopped presenting entirely.

kitty draws through bind_framebuffer_for_output(), which rebinds framebuffer 0
every frame, so an FBO bound once at startup does not survive. The back buffer
is now a blit destination in Present rather than the draw target, which leaves
the renderer alone entirely. The blit inverts Y, since OpenGL puts the origin
at the bottom left and a DXGI texture puts it at the top left.

The caption buttons are an owned popup on this path instead of a child window.
A child composites into its parent's redirection surface, which no longer
exists. The child form stays for every other mode, where DWM animates it in
step with the parent; that does not matter here because the acrylic path
disables those transitions anyway.

KITTY_ACRYLIC_DEBUG=1 traces which step gives up, since every failure is silent
by design.
The swapchain buffer and the composition visual sit on different clocks, so a
resize leaves them disagreeing by a frame. The default Fill stretch turned that
into the terminal content pulsing larger and smaller for the length of the
drag.

The content surface brush is now CompositionStretch.None, top left aligned, so
the swapchain is drawn one to one and never scaled. A size mismatch can no
longer produce a scale: a stale frame simply does not reach the new edge, and
the acrylic shows through there for the one frame before kitty repaints.

With no stretch to worry about, the content visual goes back to tracking the
window, and the explicit-size plumbing from the previous attempt is gone.
The swapchain queued the default three frames. During a live resize both
WM_SIZE and the repaint timer present rapidly, so the compositor showed frames
a few behind, and while the prompt reflowed that read as the cursor jittering
between recent positions. IDXGIDevice1::SetMaximumFrameLatency(1) keeps what is
shown current, and it matches the low latency stance kitty already takes with
DwmFlush in swapBuffersWGL.
SetTimer raises any interval below USER_TIMER_MINIMUM (10ms) up to that floor,
and WM_TIMER coalescing pulls it lower, so the resize repaint was pinned near
80fps on a faster panel no matter what interval it asked for. DwmFlush and a
composition-swapchain present were both measured at the full 300Hz here, so the
timer was the only bottleneck.

The repaint now runs off a multimedia timer (timeSetEvent) at one monitor
refresh period, read from the window's current monitor. It fires below 10ms on
its own thread and only posts WM_APP_RESIZE_TICK; the render happens on the main
thread where the modal resize loop pumps it, coalesced so a slow frame never
backs the queue up. The present still paces to the compositor through DwmFlush.
SetTimer stays as the fallback when winmm is unavailable.
The three Windows docs still described the DWM system backdrop the port used
before. Rewrite the acrylic sections of decisions.md and windows-internals.md
around the composition effect graph, the redirection-surface-free window and
its swapchain, the stretch-none content brush, the one-frame latency cap, and
the multimedia-timer resize repaint. Update the caption-buttons and
blur-behind entries for how they differ between the glass and blur paths, and
point the file list at the new win32_acrylic.c.
Kittens stuttered and hung every few mouse movements in msys and cygwin shells,
worst in mouse-demo, while the same kitten was smooth under pwsh. Only those
shells go through the bridge.

Writing to the cygwin pty master blocks until the child drains it, and it does
so even though the fd is O_NONBLOCK, which cygwin ignores for that write. A
child that consumes input slowly therefore stalls the input pump, and each
stall lets more input pile up, so the next write blocks longer again. Measured
with the mouse-demo kitten, one write grew from 118ms to 2.6s as the backlog
went from 15 mouse reports to 241.

Buffering the overflow was tried first and changed nothing, because the block
is inside the write rather than in a retry loop around it. What works is not
handing the child input it would only discard: when several mouse motion
reports are queued, all but the newest are dropped, since only the current
pointer position means anything. Presses, releases and scroll are never
dropped, and nothing else in the stream is touched. Read sizes now stay at one
or two reports instead of growing to 241, and no write blocks past 70ms.

This makes the bridge read the stream rather than only relay it, which it
already did for the resize sequence. docs/decisions.md records that tradeoff
and the case it cannot cover.
Hovering anything that asks for a pointer shape made the cursor alternate
between that shape and the arrow for as long as the mouse kept moving, settling
only once it stopped.

DefWindowProc answers WM_SETCURSOR by setting the window class cursor, and
Windows sends that message on every mouse move, so the shape set by
_glfwPlatformSetCursor survived only until the pointer moved again. kitty then
re-applied it while handling that same move, and the two took turns.

windowProc now claims WM_SETCURSOR and puts the requested shape back. Only
HTCLIENT is claimed: the resize borders and the caption strip that
WM_NCHITTEST reports still fall through to DefWindowProc, which is what gives
them their own cursors.
Records what is established about the artifact under pwsh, what has been ruled
out, where the decision between the bridge and a ConPTY is made, and the two
leads worth checking first. Also flags that the recalled assessment of the fix
being small has not been verified.
Two leads were open on this. Both are now closed, neither was the fix, so the
value here is the record of what the answer is not.

PSEUDOCONSOLE_PASSTHROUGH_MODE is accepted on build 26200 and changes nothing:
the probe's output at flags=8 is byte for byte identical to flags=0 across 55
writes. conhost does split every PSReadLine repaint, into the cursor hide alone
and then the text plus the cursor show about ten milliseconds behind it, 8.27ms
to 12.85ms over 80 samples. cmd never splits, which is the one point where that
theory and the pwsh-only symptom agree.

But raising input_delay to 25ms, double the largest gap measured, does not
change the symptom, and it should have. Either the split is innocent and the
match is a coincidence, or the deferral does not coalesce the way run_worker
reads on this path. The note says to settle that before building on the split,
and records that no fix may add input latency.

conpty_repaint_probe.c is the tool the numbers came from. It is not in the
build; compile it directly, as with conpty_poc.c beside it.
… ptys

msys and cygwin shells were never killed when their window closed. They
accumulated for days until every one of Cygwin's 128 pty devices was held by an
orphan, at which point the bridge's pty.fork() failed with "out of pty devices"
and no msys shell would start at all. kitty showed nothing for this: the shell
died the moment it was spawned, so the window simply closed again, and the only
hint was a "Failed to kill child" on stderr that a stale errno rendered as "No
error". Found with 127 orphans held, the oldest two days old.

Windows has no process group for a terminal to hang up, and TerminateProcess
ends one process rather than a tree, so hangup() only ever reached kitty's
direct child. That is enough for a ConPTY shell but not for a bridged one, where
the tree is kitty -> bridge python -> shell. TerminateProcess also denies the
bridge any chance to run its own SIGTERM handler, so its "pass the hangup on"
path never fired either.

Each child now spawns suspended, joins a job object carrying
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, and is then resumed, so anything it starts
is inside the job as well. The job handle lives in the pty entry, so closing one
window takes that window's tree and no other, and shutdown takes all of them.
Since it is the handle closing that kills, it holds even when kitty dies without
running any cleanup at all.

Verified against the msys shell both ways: force-killing kitty with no cleanup
path, and a normal window close. The shell appears with the window and is gone
with it in both, where before it survived either.

kill() now also sets errno, mapping the ERROR_ACCESS_DENIED that TerminateProcess
reports for an already-dead process onto ESRCH so callers stay quiet about it.
It was built by nothing and run by nothing, and the numbers it produced are
already written down. The note now points at conpty_poc.c as the skeleton to
copy if the measurement ever needs repeating.
kitty @ and listen_on were documented as unavailable on Windows because kitty
drives them over a Unix socket and CPython does not build socket.AF_UNIX there.
The fact is right and unchanged: python/cpython#77589 has been open since 2018,
Python 3.14 still has no AF_UNIX, and passing family 1 by number gets as far as
a socket object before failing on "bind(): bad family". Windows itself has
carried AF_UNIX since build 17063, so this is CPython's gap, not the platform's.

The conclusion drawn from it was wrong. Remote control never needed AF_UNIX,
because listen_on has always taken a tcp: address as well. Two bugs stood in the
way of that working, one at each end.

listen_on() tested socket.AF_UNIX for every address family, not only for unix:,
so a tcp: address raised AttributeError before it could bind, and the caller
turned that into "Invalid listen_on, ignoring" with the reason discarded. The
test was redundant anyway: socket_path is set only for a non-abstract unix:
address, so it already implies the family.

With the listener up, connections were accepted and then answered with nothing,
which a client sees as an i/o timeout because TCP completes a handshake from the
backlog whether or not anything services it. kitty's own stderr carried the
answer, a bare MemoryError: peer_message_received is called with a y# format,
whose length is a Py_ssize_t, and the length was cast to int. On the SysV ABI
that argument still arrives in a register and writing its low half zeroes the
top, so this is invisible on Linux and macOS. Win64 passes four arguments in
registers, which leaves this one first on the stack, where four bytes of stale
stack became the top half of the length. Every command arrived with a nonsense
size and allocation failed.

Verified both ways: kitten @ ls from outside with --to, and from inside a kitty
window with no address at all, resolving KITTY_LISTEN_ON. Both return the
instance JSON, and stderr is clean.

Also, listen_on failures now carry their reason rather than all reading alike,
and a unix: address on a Python without the family says so and points at tcp:
instead of failing as a parse error.

One caveat is documented rather than fixed: a loopback port has no ACL, where a
Unix socket has file permissions and a peer uid check. remote_control_password
matters more here, and a named pipe is the shape a stricter transport would take.
…d it

The pointer alternated rapidly between shapes while the mouse crossed the
mouse-demo kitten under pwsh and cmd, but not under an msys shell. Two separate
causes, both of which only show when the child repaints on every motion event.

URL hover detection ran on every move regardless of who owned the mouse. Once an
application has grabbed it kitty forwards all motion to it and the application
repaints freely, so detection was being run against content rewritten underneath
it, and the answer flipped from move to move: hyperlink shape, grabbed shape,
hyperlink shape. Detection is now skipped while the mouse is grabbed. The shape
is still recomputed on every move, which is what holds a shape set with OSC 22
while the pointer moves inside the region that asked for it. Clicking a URL is
unaffected, mouse_open_url runs detection itself.

The second is the pointer shape protocol. An application that repaints on motion
pops and pushes its shape every time, and mouse-demo does exactly that, once per
redraw. Applying each sequence to the OS separately put the fallback shape on
screen between the pop and the push. Under a Cygwin pty both arrive in one batch
and the fallback is overwritten before it is drawn, which is why msys looked
fine; conhost splits the stream on its own frame cadence, so on Windows the
fallback survived long enough to be seen as the pointer flashing back to the
arrow and returning. The measured split for this is 8 to 13ms.

update_mouse_pointer_shape now records the shape and process_global_state
applies it once per tick, after all input is parsed and before the frame is
rendered. A pop followed by a push costs one change rather than two and the
intermediate never reaches the screen. It also halves the OS pointer churn
everywhere, not only on Windows.
The suite went from 50 failures and 21 errors to none. Roughly two thirds of
that was one thing: 45 of the 74 reported failures were the drag and drop tests,
which exercise kitty/dnd.c, and Windows builds kitty/wincompat/dnd_stub.c
instead. They were failing on os.O_DIRECTORY before reaching anything real, and
each failure then cascaded into assertions about a response that never came.

Genuine bugs found and fixed rather than skipped:

- kitty_tests/atexit built a Python snippet by interpolating a path into it and
  exec'd the result, so C:\InfinityX became the escape \I and the whole snippet
  was a SyntaxError. It now interpolates through repr().
- kitty_tests/options parsed the launcher report with line.split(':'), which
  finds the colon in a drive letter and unpacks three values into two, and read
  it with split('\n'), leaving a trailing \r on every list value.
- kitty_tests/check_build looked for the x11 and cocoa glfw modules only, and
  tested a file's execute bits, which Windows does not have.
- kitty_tests/graphics held a NamedTemporaryFile open while asking kitty to read
  and delete the path, which Windows does not allow. It now writes and closes
  around each read, which leaves the deletion being tested to kitty.

The rest are skipped through a new skip_on_windows helper, which requires a
reason, so a skip is never mistaken for something nobody has looked at yet. They
fall into two groups: subsystems not built here (drag and drop), and tests
written against POSIX semantics with no Windows equivalent (signals a process
can ignore, waitpid on a grandchild, mkfifo, POSIX shared memory, select on a
pipe, the execute bit, ~user expansion, a #!/bin/sh script being executable).
The font selection test is skipped for a different reason: it asserts the
PostScript names of the font builds shipped on Linux and macOS, and Windows
carries different builds of the same families.

Five real Windows gaps surfaced while doing this and are recorded in
WINDOWS_TODO rather than left implied by a skip: makedirs understands only POSIX
separators, XDG_CONFIG_DIRS is split on a colon that a drive letter also
contains, the launcher does not report original_argv or argv, completion decides
executability by the execute bit rather than PATHEXT, and the multiprocessing
spawn patch hooks a function Windows never calls.

The Go suite is untouched and still has 12 failing packages.
Two packages failed to compile rather than failing tests, which hid whatever
else was wrong in them.

tools/utils/file_at_fd_test.go compared files by st_ino through
syscall.Stat_t, which does not exist on Windows. The package itself is already
split, file_at_fd.go is !windows and file_at_fd_windows.go covers this platform,
so only the test was unbuildable. It now asks the question it was really asking,
through os.SameFile, which is portable and covers Windows by volume serial and
file index.

kittens/ssh/main_test.go imported golang.org/x/sys/unix for a single
unix.Access(p, X_OK), checking that the execute bit survived staging for the
remote host. Windows has no execute bit to check, and the import alone breaks
the build. The call moved behind check_is_executable, which is unix.Access on
POSIX and a plain stat on Windows, since the mode is set on arrival at the
remote host anyway. This mirrors what kitty_tests/check_build already does.

Both packages now vet clean and run. Doing so exposes their real failures, which
are counted with the rest and not yet addressed.
The Go suite joins the Python one. Both are green: 352 Python tests and every Go
package, where the starting point this session was 50 Python failures, 21 Python
errors and 12 failing Go packages, two of which would not even compile.

Four real bugs in kitty came out of it, all Windows only:

- CompleteFiles matched directory separators against os.PathSeparator alone, so
  a prefix written with a forward slash, which both kitty's own config and the
  shells in common use here produce, was read as having no directory part and
  completed nothing at all. It now accepts either separator on Windows and only
  the forward slash elsewhere, where a backslash is a legal filename character.
- geninclude routed a .py through an interpreter only when an access check said
  it was not executable. Windows has no execute bit for that check to fail on,
  so it answered yes and exec'ing the path went nowhere. .py now always goes to
  an interpreter here.
- OpenDirAt was a bare os.Open with no directory check. The POSIX version passes
  O_DIRECTORY and lets the kernel reject anything else; Windows has no such flag,
  so it opened regular files quite happily and leaked the handle on the way out.
- machine_id read /etc/machine-id on every platform that is not darwin, which
  simply fails here. Windows has a direct equivalent in MachineGuid, generated at
  install time and stable for the life of the installation, read from the 64 bit
  registry view so a 32 bit build sees the same value as everything else.

The rest were tests written against POSIX assumptions. The largest group by far
was path separators: expectations written with forward slashes compared against
filepath.Join output. Those now normalise with filepath.ToSlash, since what is
under test is which paths come back, not which separator the platform picked.
Two more were expectation builders with the same bug, one of which also called
filepath.IsAbs on a remote path, which answers false for /dest on Windows
because it wants a drive letter.

Six things could not be fixed and are skipped with a stated reason and an entry
in WINDOWS_TODO rather than left to look like nobody had checked: the
choose-files scanner does not handle Windows paths, the ssh config parser eats
backslashes, atomic write cannot replace a file another handle holds open, the
transfer kitten's link de-duplication needs inode identity, symlink targets keep
Windows separators on the wire, and the readline tests cannot run at all because
constructing a loop trips the guard against running a kitten outside kitty, and
that guard exits the process rather than returning an error.
Both entry points died before doing any work. setup.py failed reading the docs
to build the reference map, and test.py failed reading Go sources to collect the
testable packages, each with a UnicodeDecodeError from cp1252. Everything the
build and the suite read is UTF-8; Windows just does not default to it for text
files, and still uses the legacy code page.

Annotating every open() would mean around twenty call sites in setup.py alone,
plus more in the modules it drives, and would need extending every time a new
one is added. UTF-8 mode covers all of them at once, but cannot be turned on
after the interpreter has started, so both entry points now check for it and, on
Windows only, start again with -X utf8 set. PYTHONUTF8 goes into the environment
of that restart as well, so the Python subprocesses they spawn inherit it rather
than each needing the flag of its own.

subprocess.run and then exit with its code, not os.execv: Windows has no exec,
and CPython emulates it by spawning and exiting, which would return to the shell
before the real work finished.

extract-rst-targets.py also asks for UTF-8 explicitly. It is the one that failed
first, it is unambiguously reading UTF-8, and saying so there is worth more than
relying on the mode being set.

Verified with PYTHONUTF8 unset: the build completes and the suite runs green.
ctrl+shift+e and ctrl+shift+u opened nothing, silently, while every other
shortcut worked. The split was not in the key handling: copy_to_clipboard and
new_tab are built-in actions, whereas open_url_with_hints and unicode_input
launch a kitten, and those two are the kittens that ask for remote control.

A kitten that asks for it is handed one end of a socketpair, and the very first
step of that fails here. os.dup and os.set_inheritable both raise EBADF on a
socket on Windows, because a socket is a kernel handle rather than a CRT file
descriptor. Measured directly: socketpair() succeeds and both calls then fail.
The error propagated out of run_kitten_with_metadata before the overlay window
was created, so nothing opened and nothing was said about it.

Two further steps would fail even past that. The child's inherit list in
child.c is exactly its two stdio pipes, so the socket would not reach the kitten
regardless, and the kitten would pick it up with socket.fromfd(fd, AF_UNIX, ...),
which CPython does not build on Windows at all.

So this does not try to make fd-based remote control work. It stops the attempt
being fatal: both call sites, the kitten one and run_background_process, now log
and carry on without it, which is what every kitten that does not ask for remote
control already does here. run_background_process matters as much as the first,
since it is the path the hints kitten takes once a URL has been picked.

What a real implementation needs is written down in WINDOWS_TODO: duplicate with
socket.dup and detach rather than os.dup, mark the handle inheritable with
SetHandleInformation, add it to the inherit list, and give the kitten side a path
that does not go through AF_UNIX. A named pipe would sidestep all of it, and
kitty already uses one for the graphics bypass.
ctrl+shift+e and ctrl+shift+u opened nothing because kitty thought it had no
wrapped kittens at all. wrapped_kitten_names() returned an empty list, so hints
and unicode_input were run down the Python fallback path rather than as
kitten.exe, and never appeared.

get_source_specific_defines matches the source path against literals written
with forward slashes, but find_c_files builds those paths with os.path.join, so
on Windows they arrive as kitty\data-types.c and matched nothing. Every define
it hands out was dropped here, and dropped quietly:

- data-types.c lost WRAPPED_KITTENS, which then fell back to the "" in
  wincompat/win_prelude.h. That is what emptied the kitten list. It also lost
  KITTY_VCS_REV, so the build reports no revision.
- screen.c lost PRIMARY_VERSION, SECONDARY_VERSION and XT_VERSION.
- fast-file-copy.c lost HAS_COPY_FILE_RANGE.
- The library_paths lookup at the end is keyed the same way and never hit.

Only vt-parser-dump.c worked, and only because it is appended as a hardcoded
forward slash literal instead of going through os.path.join, which is why
--dump-commands was fine while everything around it was not.

All the comparisons now run against a normalised key. Verified: the wrapped
kitten list goes from 0 entries to 14, including hints and unicode_input, and
driving open_url_with_hints over remote control now starts kitten.exe and
creates the overlay, where before it returned silently having done nothing.

The #ifndef fallback in win_prelude.h is what turned a missing define into an
empty list rather than a compile error, and is why this went unnoticed. It is
left in place, but the cause is fixed above it.
The empty WRAPPED_KITTENS list did not fail loudly, it changed which of the two
implementations of each kitten ran, so the symptoms looked unrelated to each
other: ask still worked behind tab renaming and the paste confirmation but
without arrow key navigation, while hints and unicode_input did nothing at all
and ctrl+shift+e and ctrl+shift+u appeared dead. Worth writing down, since the
next person to see one of those will not connect it to a build define.
e4f410b restored a Python implementation of the ask kitten because the Go
kitten tool did not build on Windows at the time, so set_tab_title, the yes/no
confirmations and the choice prompts did nothing. de4c186 then made the Go
tool build and run here, and the reason for it went away.

It should have gone then, and leaving it did real damage rather than none.
ca88cbc added arrow key navigation to the choices dialog, in
kittens/ask/choices.go, so the fix only ever reached the Go implementation while
Windows was still running this one. That is why the paste confirmation answered
its accelerator letters but ignored the arrow keys: the fix was there, the code
being run was not. The build fix in d5be81b is what finally switched Windows
onto the Go kitten and made the arrow keys appear.

The un-wrapping the same commit added to wrapped_kitten_names has already been
reverted, so nothing was selecting this deliberately any more; it was simply
sitting there.

Only what e4f410b added comes out. kittens/ask/main.py stays, restored to its
upstream form: 456 lines back to 89. That module is not the kitten, it is how
kitty drives one. create_kitten_handler imports it for every kitten it launches,
wrapped or not, and takes handle_result from it, which for ask is what applies
the answer. Deleting the file would break set_tab_title and the confirmations
again. main() is upstream's deliberate dead end, raising SystemExit to say it
must be run as kitten ask.

The only later change to the file, in 2feecc9, renamed parameters inside the
added code to satisfy the type checker, so it goes with it.

Verified: set_tab_title over remote control starts kitten.exe and no error is
logged, and the suite still passes.
Only ctrl+c cancelled, so pressing esc while renaming a tab did nothing and the
prompt stayed up. The choices dialog beside it has always taken esc and ctrl+c
as the same thing, and a line prompt should be no different.

Cancelling has to go through the error return rather than quitting quietly.
Response is a plain string, so an empty one still serializes as "" rather than
null, and handle_result checks only for null before applying the answer. Quitting
with an empty response would therefore rename the tab to nothing instead of
leaving it alone. Returning the error means the kitten exits non zero and prints
no result at all, which is what ctrl+c already relies on.

This was not noticed on Windows before because the Go kitten was not the one
being run: the empty WRAPPED_KITTENS list fixed in d5be81b sent ask down the
Python path, and that implementation, since removed in 866a851, handled esc
itself.
The README never said where kitty.conf goes on Windows, which is the first thing
anyone needs and is not where they will guess. It is %USERPROFILE%\.config\kitty,
the same .config folder kitty uses on Linux, and not %APPDATA%. The folder is not
created for you either. Both the README and windows-port.md now say so, and
windows-port.md names get_config_dir in kitty/launcher/utils.h as the place that
decides, along with KITTY_CONFIG_DIRECTORY as the override.

example/.config/kitty holds a working config: kitty.conf and the one theme it
includes, nothing else. Its comments cover the options where Windows behaves
differently rather than restating what each line does, so a reader can tell which
settings are load bearing. Verified by loading it through kitty's own config
parser, which reports zero errors and applies the theme include.

Windows 11 is now stated as the requirement, in the badge, the README and a
Requirements section in windows-port.md, along with the honest caveat that
nothing in the build or the installer actually checks the version. The remaining
Windows 10 mentions in the docs are kept because they are factual rather than
support claims: when the timer resolution API changed, and why the Explorer
entry is built twice.

Also recorded as a known limitation: emoji at an MSYS2 zsh prompt come out as
their surrogate halves. Cygwin stores a wide character in 16 bits and zsh's line
editor assumes one per character. It reproduces in Windows Terminal with the same
shell, so it is not kitty, and it affects only the prompt.
The feature list undersold most of what is Windows only here. The acrylic now
says what it actually is: the same WinUI AcrylicBrush recipe Windows Terminal
follows, the noise texture, the blurred backdrop and the luminosity and tint
blends, built here as a composition effect graph rather than requested from DWM,
which is the reason it holds when the window loses focus where DWM's own backdrop
goes flat. Measured against Terminal on the same wallpaper it matches to within
half a percent.

Three things that were working and unmentioned are now listed: images through
the graphics protocol, which needs a private named pipe because conhost eats the
escape sequences that carry them, remote control over a tcp address, and the
Cygwin pty bridge, which is what keeps zsh from flickering the way it does in
every terminal that pipes it through ConPTY. The Explorer entry now says why the
Windows 11 menu needs a signed MSIX rather than a registry key.

Windows 10 stays installable and untested rather than being called out as
unsupported and left there. The wording invites trying it, is honest that the
chrome will not look right, and says plainly there is no support and no plan for
any.

The important block now says what this is: something built so its author could
use kitty as a daily terminal on Windows, which it does. No roadmap is promised
and no commitment is made about what comes next.

Personal paths are gone from the tree. The config location in the README and in
windows-port.md uses <user>, the handover note no longer carries an absolute
path to somebody's projects folder, and the comment in kitty_tests/atexit.py
demonstrates the escape problem with C:\Users rather than a real one.
run-tests.sh ran an explicit list of 13 modules. That was right when it was
written, since the rest failed on POSIX assumptions or hung outright, but the
suite passes now and CI was testing a fraction of the port. It runs test.py
outright, Go tests included, under a timeout so a hang cannot sit there until
the job limit.

Running it the way CI does, under MSYS2 bash rather than PowerShell, failed
three tests that pass from PowerShell. All three skip only when a POSIX tool is
missing, and MSYS2 supplies the tool, so on Windows they stop skipping and run
against a platform that cannot support them:

- kitty_tests/utmp skips when `who` is absent. MSYS2 has `who`, and kitty/utmp.c
  has no utmpx.h to compile against here, so num_users raises rather than
  returning a count. It can never pass.
- kitty_tests/shell_integration skips per shell when that shell is missing.
  MSYS2 supplies bash and zsh, but having the shell is not the same as having
  the pty these drive it over.
- kittens/ssh TestSSHTarfile shells out to the system tar with a Windows path,
  which the MSYS2 tar cannot open once it has been through shell quoting.

Each is skipped with its reason rather than excluded in the script, so what is
not covered stays visible in the CI log instead of living in a list nobody
rereads.

Also fixed three E701s and an unsorted import block that ruff catches and I had
introduced, verified by running the same ruff check CI runs.

Checked before pushing: everything the acrylic needs is in the stock toolchain
CI installs. roapi.h, winstring.h, inspectable.h, the windows.ui.composition
and windows.graphics.effects headers, d2d1_1.h, d3d11.h, dxgi1_2.h and
wincodec.h are all in mingw64/include, and libwindowsapp, libd3d11, libdxgi,
libdxguid and libwindowscodecs all resolve from mingw64/lib.
Publishing a release triggered a build whose last step ran gh release upload and
attached kitty-setup.exe to it. That is not wanted: a release should carry what
its author chose to put there.

It would also have failed the first time anyone tried. The workflow declares
permissions: contents: read, and uploading a release asset needs contents:
write, so that step was going to take a 403 rather than do the thing nobody
asked for.

The release trigger goes with it, since it existed only to feed that step, and a
build fired by publishing a release now has nothing to do.

Every run still leaves the installer as a build artifact, which is how you get a
build to try. Nothing in CI writes to a release any more.
cmd and cmd.exe were committed by de4c186 and 8c2a615. They are what
`go build ./tools/cmd` leaves in the working directory, 35 MB each, and they are
the two largest blobs in the whole repository. Together they are around 70 MB of
a 115 MB .git.

Both are untracked now and ignored so they cannot come back. The ignore patterns
are anchored to the root, since tools/cmd is a real source directory and a bare
"cmd" would swallow it.

This only stops them being in the working tree of a fresh checkout. The blobs
stay in history, so a clone still fetches them. Removing that needs a history
rewrite, which is a separate decision and not one to take quietly.

docs/windows-port.md is also normalised back to LF. It picked up CRLF from being
rewritten on Windows, which turned a small edit into a whole file diff.
@ecstra
ecstra merged commit c863650 into master Jul 24, 2026
2 checks passed
@ecstra
ecstra deleted the windows-native-port branch July 24, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant