Native Windows port: run kitty on Windows 11 without WSL - #1
Merged
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.
corners and its own window buttons.
themes, choose-files, ask and the confirmation prompts.
pipe because conhost eats the escape sequences that carry them.
listen_on tcp:.Windows 11 menu, which needs a signed MSIX rather than a registry key.
reloading, and dropping files onto a window.
What is not there
it (Enable AF_UNIX support in Windows python/cpython#77589), so
tcp:is the way in.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.