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
@@ -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
@@ -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()
@@ -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`
@@ -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 })