@ 261105.2

261020.24 · a6323f0c0f46222be3db60c1c691fd94b0fd6b97

fix(cli): detect the tty width on Windows/ConPTY (flex preview flip)

The CLI (`nvim -l scripts/cli.lua`, e.g. the pwsh profile's Ctrl+P/K binds) always showed the flex preview in the vertical position on Windows: `_G.fzf_tty_get_width` probed only `uv.new_tty(0)`, whose `get_winsize()` returns nil under ConPTY (psmux, Windows Terminal) - a redirected stdin tty reports no size - and the fallbacks (`$COLUMNS` unset, `vim.o.columns` = 80 in a UI-less process) pinned the layout below `flip_columns` forever, including on resize (SIGWINCH recomputes the same way).

Measured in a 226x64 psmux pane (`nvim -l`):
o.columns=80, COLUMNS=nil
new_tty(0):get_winsize() -> nil
new_tty(1):get_winsize() -> 226 (stdout/stderr tty reports the real size)

- move the detection to `utils.fzf_tty_get_width` (the CLI keeps
assigning the `_G.fzf_tty_get_width` hook) and probe tty handles in
`1, 2, 0` order, keeping the `$COLUMNS`/`vim.o.columns` fallbacks
(stdout is frequently piped in the shell-widget flow: stderr/stdin
cover that)
- posix branch unchanged (`ioctl(TIOCGWINSZ)` on stdin)

Verified end-to-end in a detached psmux pane (226x64): before the fix the CLI picker opened with the preview below; after, it opens on the right and follows resize 226->120 (down) ->226 (right). Unit test stubs `uv.new_tty` for the fd order (stdout preferred, stdin fallback kept).

Verification: full nightly 316/0 fails/19 notes; full stable 316/0/19; focused {utils} 15/0; emmylua_check clean.

reading declarations

lua/fzf-lua/libuv.lua

@@ -85,8 +85,19 @@ local function win_ffi_init()   local ok, res = pcall(function()     local ffi = require("ffi")     ffi.cdef([[+      typedef struct { short x; short y; } FzfCOORD;+      typedef struct { short left; short top; short right; short bottom; } FzfSMALL;+      typedef struct {+        FzfCOORD dwSize;+        FzfCOORD dwCursorPosition;+        unsigned short wAttributes;+        FzfSMALL srWindow;+        FzfCOORD dwMaximumWindowSize;+      } FzfCSBI;       void* CreateFileW(const unsigned short*, unsigned long,         unsigned long, void*, unsigned long, unsigned long, void*);+      int CloseHandle(void* hObject);+      int GetConsoleScreenBufferInfo(void* hConsoleOutput, FzfCSBI* info);       int _open_osfhandle(long long, int);       void* GetStdHandle(unsigned long nStdHandle);       int GetHandleInformation(void* hObject, unsigned long* lpdwFlags);@@ -155,6 +166,42 @@ M.win_stderr_fd = function()   return console and console[3] or 2 end +--- Width of the Windows console screen buffer (`srWindow`), `nil` when not+--- attached to a console or on other platforms. The std handles cannot be+--- trusted for this: the pwsh PSReadLine key handlers pipe both stdout and+--- stderr (`uv.new_tty` fails on the pipes) and a ConPTY stdin tty reports+--- no size, so the console device is opened directly (`CONOUT$`/`CONIN$`,+--- same rationale as `win_console_stdio`).+---@return integer?+M.win_console_width = function()+  if not _is_win then return nil end+  local width+  pcall(function()+    local win = win_ffi_init()+    local ffi = assert(win and win.ffi)+    local function wsz(s)+      local b = ffi.new("unsigned short[?]", #s + 1)+      for i = 1, #s do b[i - 1] = s:byte(i) end+      return b+    end+    local GENERIC_RW, SHARE_RW, OPEN_EXISTING = 0xC0000000, 3, 3+    local info = ffi.new("FzfCSBI")+    for _, dev in ipairs({ "CONOUT$", "CONIN$" }) do+      local h = ffi.C.CreateFileW(wsz(dev), GENERIC_RW, SHARE_RW, nil, OPEN_EXISTING, 0, nil)+      local hv = tonumber(ffi.cast("intptr_t", h))+      if hv ~= -1 then+        if ffi.C.GetConsoleScreenBufferInfo(h, info) ~= 0 then+          local w = info.srWindow.right - info.srWindow.left + 1+          if w > 0 then width = w end+        end+        ffi.C.CloseHandle(h)+        if width then break end+      end+    end+  end)+  return width+end+ --- libuv as bundled with Neovim (<= 1.52) spawns children with --- `CreateProcess(bInheritHandles=TRUE)` and no --- `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`, so *every* inheritable handle of

lua/fzf-lua/profiles/cli.lua

@@ -10,7 +10,10 @@ local _is_win = fn.has("win32") == 1 or fn.has("win64") == 1 -- nil on Windows so all helpers below take their portable fallbacks. local ffi if not _is_win and package.preload.ffi then-  ffi = package.preload.ffi()+  -- `require` caches the module and shares the CType state across modules;+  -- calling the constructor directly would re-initialize it and drop every+  -- declaration made by other modules (see `win_ffi_init` in libuv.lua)+  ffi = require("ffi") end  ---@diagnostic disable-next-line: call-non-callable, need-check-nil@@ -146,30 +149,7 @@ local function win_exec(cmd, ...)   return true end -_G.fzf_tty_get_width = function()-  if _is_win or not ffi then-    -- Windows (or no ffi): `ioctl(TIOCGWINSZ)` does not exist. Try libuv-    -- tty first, then $COLUMNS, then `vim.o.columns` (80 in headless).-    -- Return nil only if nothing yields a usable width so callers can-    -- fall back to their default layout.-    local ok, tty = pcall(uv.new_tty, 0, false)-    if ok and tty then-      local ok2, w = pcall(function() return tty:get_winsize() end)-      pcall(function() tty:close() end)-      if ok2 and type(w) == "number" and w > 0 then return w end-    end-    local cols = tonumber(os.getenv("COLUMNS"))-    if cols and cols > 0 then return cols end-    if vim.o.columns and vim.o.columns > 0 then return vim.o.columns end-    return nil-  end-  local TIOCGWINSZ = 0x5413-  ---@diagnostic disable-next-line: assign-type-mismatch-  local ws = ffi.new("struct winsize[1]") ---@type [{ ws_row: integer, ws_col: integer }]-  local ret = ffi.C.ioctl(0, TIOCGWINSZ, ws)-  ---@diagnostic disable-next-line: undefined-field-  if ret == 0 then return ws[0].ws_col end-end+_G.fzf_tty_get_width = require("fzf-lua.utils").fzf_tty_get_width  -- https://github.com/libuv/libuv/blob/04fc1580d48c1f7aa339b6ccf91f1f815bc08b45/src/unix/core.c#L800 local enable_stdio_inheritance = function()

lua/fzf-lua/utils.lua

@@ -1872,6 +1872,106 @@ function M.termopen(cmd, opts)   end end +--- `TIOCGWINSZ` is `_IOR('t', 104, struct winsize)`: 0x40087468 on+--- macOS/BSD, 0x5413 on Linux. `nil` on other platforms (fallbacks used).+---@param os string? `ffi.os`+---@return integer?+M._tty_ioctl_request = function(os)+  if os == "Linux" then return 0x5413 end+  if os == "OSX" or os == "BSD" then return 0x40087468 end+end++--- Terminal width via `ioctl(TIOCGWINSZ)` on fds 1, 2, 0, `nil` when+--- unknown, on Windows, or when FFI is unavailable (PUC Lua builds).+--- `uv.new_tty` is deliberately not used on POSIX: libuv reopens tty fds+--- (setting `O_NONBLOCK` on the shared description) and accepts+--- pipes/sockets, which `uv_close` would close - mutating the process's+--- std handles. `ioctl` has no such side effects.+---@return integer?+M._posix_tty_width = function()+  if M.__IS_WINDOWS then return nil end+  local width+  pcall(function()+    local ffi = require("ffi")+    if not M._tty_cdef_done then+      M._tty_cdef_done = true+      -- identical declaration to `profiles/cli.lua`, re-`cdef` is a no-op+      pcall(function()+        ---@diagnostic disable-next-line: call-non-callable+        ffi.cdef [[+          struct winsize {+            unsigned short ws_row;+            unsigned short ws_col;+            unsigned short ws_xpixel;+            unsigned short ws_ypixel;+          };+          int ioctl(int fd, unsigned long request, ...);+        ]]+      end)+    end+    local request = M._tty_ioctl_request(ffi.os)+    if not request then return end+    ---@diagnostic disable-next-line: assign-type-mismatch+    local ws = ffi.new("struct winsize[1]") ---@type [{ ws_row: integer, ws_col: integer }]+    for _, fd in ipairs({ 1, 2, 0 }) do+      if ffi.C.ioctl(fd, request, ws) == 0 and ws[0].ws_col > 0 then+        width = ws[0].ws_col+        return+      end+    end+  end)+  return width+end++--- Width of the terminal the (headless) instance is attached to, `nil`+--- when unknown. Used by the CLI (`_G.fzf_tty_get_width`, see+--- `profiles/cli.lua`) so the flex preview layout compares against the+--- real terminal width instead of `vim.o.columns` (80 in a UI-less+--- process).+---+--- The std handles are probed in `1, 2, 0` order: on Windows/ConPTY+--- (psmux, Windows Terminal) stdout/stderr resolve to the tty with the+--- real size while a redirected stdin tty reports none+--- (`uv.new_tty(0):get_winsize()` is nil). When every std handle is piped+--- (pwsh PSReadLine key handlers) the console device is queried directly+--- (`libuv.win_console_width`). POSIX probes the same fd order with+--- `ioctl` (`_posix_tty_width`). `$COLUMNS` then `o.columns` are the final+--- fallbacks on every platform.+---@return integer? width+M.fzf_tty_get_width = function()+  local width+  if M.__IS_WINDOWS then+    for _, fd in ipairs({ 1, 2, 0 }) do+      local ok, tty = pcall(uv.new_tty, fd, false)+      if ok and tty then+        local ok2, w = pcall(function() return tty:get_winsize() end)+        pcall(function() tty:close() end)+        if ok2 and type(w) == "number" and w > 0 then+          width = w+          break+        end+      end+    end+    if not width then+      -- the pwsh PSReadLine key handlers pipe both stdout and stderr+      -- (`uv.new_tty` fails on the pipes) and a ConPTY stdin tty reports+      -- no size, leaving no std handle that can report the terminal size:+      -- open the console device itself (same rationale as+      -- `libuv.win_console_stdio`)+      local w = require("fzf-lua.libuv").win_console_width()+      if w and w > 0 then width = w end+    end+  else+    -- `_posix_tty_width` returns nil when FFI is unavailable (PUC Lua)+    width = M._posix_tty_width()+  end+  if width and width > 0 then return width end+  local cols = tonumber(os.getenv("COLUMNS"))+  if cols and cols > 0 then return math.floor(cols) end+  if vim.o.columns and vim.o.columns > 0 then return vim.o.columns end+  return nil+end+ --- workaround to a potential 'tempname' bug? (#222) --- neovim doesn't guarantee the existence of the --- parent temp dir potentially failing `mkfifo`

tests/utils_spec.lua

@@ -84,6 +84,114 @@ describe("Testing utils module", function()     eq(utils.has({ __FZF_VERSION = { 2, 5, 5 } }, "fzf", "2.5.6"), false)   end) +  it("fzf_tty_get_width probe order (ConPTY stdout before stdin)", function()+    helpers.SKIP_IF_NOT_WIN()+    local libuv = require("fzf-lua.libuv")+    local orig_console_width = libuv.win_console_width+    local orig = {}+    local sizes = {} ---@type table<integer, integer|false>+    ---@return function restore+    local function stub_new_tty()+      ---@diagnostic disable-next-line: deprecated+      local t = vim.uv or vim.loop+      if t and not orig[t] then+        orig[t] = t.new_tty+        t.new_tty = function(fd, _)+          if sizes[fd] == false then error("UV_ENOTTY", 0) end+          return {+            get_winsize = function() return sizes[fd] end,+            close = function() end,+          }+        end+      end+      return function()+        for tt, o in pairs(orig) do tt.new_tty = o end+      end+    end+    local restore = stub_new_tty()+    local ok, err = pcall(function()+      -- stdin tty reports no size under ConPTY (psmux): stdout must win+      sizes = { [1] = 226 }+      eq(utils.fzf_tty_get_width(), 226)+      -- stdout/stderr unusable, stdin is the console (previous behavior)+      sizes = { [0] = 120 }+      eq(utils.fzf_tty_get_width(), 120)+      -- all std handles redirected (pwsh PSReadLine key handlers):+      -- `uv.new_tty` fails on both pipes, the console device is queried+      sizes = { [1] = false, [2] = false, [0] = false }+      libuv.win_console_width = function() return 172 end+      eq(utils.fzf_tty_get_width(), 172)+      -- console unavailable: `$COLUMNS` is the last resort before o.columns+      libuv.win_console_width = function() return nil end+      local prev = vim.env.COLUMNS+      vim.env.COLUMNS = "222"+      eq(utils.fzf_tty_get_width(), 222)+      vim.env.COLUMNS = prev+    end)+    libuv.win_console_width = orig_console_width+    restore()+    if not ok then error(err) end+  end)++  it("fzf_tty_get_width ioctl request per platform", function()+    eq(utils._tty_ioctl_request("Linux"), 0x5413)+    eq(utils._tty_ioctl_request("OSX"), 0x40087468)+    eq(utils._tty_ioctl_request("BSD"), 0x40087468)+    eq(utils._tty_ioctl_request("Windows"), nil)+    eq(utils._tty_ioctl_request("POSIX"), nil)+  end)++  it("fzf_tty_get_width fallbacks", function()+    local libuv = require("fzf-lua.libuv")+    local orig_posix = utils._posix_tty_width+    local orig_console = libuv.win_console_width+    ---@diagnostic disable-next-line: deprecated+    local tty = vim.uv or vim.loop+    local orig_new_tty = tty.new_tty+    if helpers.IS_WIN() then+      -- force the std handle probes to fail so the stubs below are used+      tty.new_tty = function() return nil end+    end+    local prev_columns = vim.env.COLUMNS+    local ok, err = pcall(function()+      -- a successful probe wins+      utils._posix_tty_width = function() return 172 end+      libuv.win_console_width = function() return 172 end+      eq(utils.fzf_tty_get_width(), 172)+      -- failed probes fall through to `$COLUMNS`+      utils._posix_tty_width = function() return nil end+      libuv.win_console_width = function() return nil end+      vim.env.COLUMNS = "222"+      eq(utils.fzf_tty_get_width(), 222)+      -- then to `o.columns`+      vim.env.COLUMNS = nil+      eq(utils.fzf_tty_get_width(), vim.o.columns)+    end)+    utils._posix_tty_width = orig_posix+    libuv.win_console_width = orig_console+    tty.new_tty = orig_new_tty+    vim.env.COLUMNS = prev_columns+    if not ok then error(err) end+  end)++  it("fzf_tty_get_width without ffi (PUC Lua)", function()+    helpers.SKIP_IF_WIN()+    local saved_preload = package.preload.ffi+    local saved_loaded = package.loaded.ffi+    package.preload.ffi = nil+    package.loaded.ffi = nil+    local prev_columns = vim.env.COLUMNS+    local ok, err = pcall(function()+      vim.env.COLUMNS = "222"+      -- must not raise and must fall through to `$COLUMNS`+      eq(utils.fzf_tty_get_width(), 222)+    end)+    package.preload.ffi = saved_preload+    package.loaded.ffi = saved_loaded+    vim.env.COLUMNS = prev_columns+    if not ok then error(err) end+  end)+   it("setmetatable with gc", function()     local gc_called = nil     local _obj = utils.setmetatable({}, { __gc = function() gc_called = true end })