Skip to content

feat(term): let the cell size be configured when the pty reports none - #376

Open
szsolt wants to merge 1 commit into
3rd:masterfrom
szsolt:cell-size-option
Open

feat(term): let the cell size be configured when the pty reports none#376
szsolt wants to merge 1 commit into
3rd:masterfrom
szsolt:cell-size-option

Conversation

@szsolt

@szsolt szsolt commented Jul 31, 2026

Copy link
Copy Markdown

Problem

utils/term gets the cell size from TIOCGWINSZ, but some ptys report cell counts while
leaving the pixel fields zero. WSL2 does this always; SSH often does. update_size() then
falls back to assuming 8x16.

Cell size scales every crop rectangle in backends/kitty/init.lua, so when the assumption
is wrong the crop samples the wrong region of the source image — images render visibly
mis-cropped or mis-scaled rather than failing outright. On this machine the real cells are
14x32, so every dimension was off by roughly a factor of two.

Neovim can't discover the value on its own. CSI 16 t is answered by the terminal, but
TermResponse never fires for the reply, and a child process spawned to read it gets no
/dev/tty. So the size has to be supplied from outside.

Independently reported at wezterm#6781,
where the same WSL setup reports {size}: 0 x 0 pixels / {cell}: 0 x 0 pixels — the
zero there is severe enough to make wezterm imgcat itself bail with "no size information
available", and an earlier build divide by zero.

Change

Add a cell_size option:

require("image").setup({
  cell_size = "14x32",                          -- or { width = 14, height = 32 }
})

It's consulted only when TIOCGWINSZ reports no pixel geometry, so terminals that do
report it are completely unaffected.

One wrinkle worth flagging for review: utils/term is required (via utils) before
setup() runs, and it computes its size at load time — so an option read there would
silently never apply. This exports set_cell_size() and calls it from setup() to
re-resolve once the option is known.

IMAGE_NVIM_CELL_SIZE is also honoured when no option is passed, which covers the case
where the value has to come from the shell — the shell does own the tty, so it can ask
the terminal directly, and it doesn't have to be hardcoded per machine:

printf '\033[16t'   # reply: CSI 6 ; height ; width t

Precedence is cell_size > IMAGE_NVIM_CELL_SIZE > 8x16. Zero sizes are rejected instead
of being passed through to divide cell_width downstream.

zsh snippet to detect and export it automatically

Sourced from .zshrc. The point of querying rather than hardcoding is that it follows
font-size changes and works unchanged across machines.

() {
  [[ -n ${IMAGE_NVIM_CELL_SIZE:-} ]] && return 0
  [[ -o interactive ]] || return 0
  [[ -t 0 && -t 1 ]] || return 0

  # Skip terminals that won't answer; they'd just eat the timeout below.
  case ${TERM:-}${TERM_PROGRAM:-} in
    *kitty*|*ghostty*|*WezTerm*|*wezterm*) ;;
    *) [[ -n ${KITTY_WINDOW_ID:-} || -n ${WEZTERM_PANE:-} ]] || return 0 ;;
  esac

  local saved reply
  saved=$(stty -g 2>/dev/null) || return 0

  # min 0 time 3: give up after 0.3s rather than hanging shell startup.
  stty raw -echo min 0 time 3 2>/dev/null || return 0
  printf '\033[16t' > /dev/tty
  IFS= read -r -d 't' reply
  stty "$saved" 2>/dev/null

  # Reply: CSI 6 ; height ; width t
  if [[ $reply =~ '\[6;([0-9]+);([0-9]+)$' ]]; then
    local h=$match[1] w=$match[2]
    (( h > 0 && w > 0 )) && export IMAGE_NVIM_CELL_SIZE="${w}x${h}"
  fi
  return 0
}

Note it must run in the shell, not from inside Neovim — a process Neovim spawns has no
/dev/tty to read the reply from, which is the whole reason this can't be autodetected
in-process. Restores tty state on every exit path, and bounded to 0.3s so a
non-responding terminal can't hang startup.

Verified

Under a pty with known dimensions, exercising each rung:

input resulting cell size
neither set 8x16 (unchanged fallback)
IMAGE_NVIM_CELL_SIZE=14x32 14x32
cell_size = "20x40" 20x40
cell_size = { width = 7, height = 15 } 7x15
cell_size = "garbage" 8x16, with a warning
cell_size = nil, env set 14x32

Happy to drop the env-var half if you'd rather keep the surface to the option alone,
though it is the only channel that works when the value must come from the shell.

Some ptys report cell counts but no pixel geometry -- WSL2 always, and often
SSH. utils/term then assumed 8x16, and since cell size scales every crop
rectangle, a wrong assumption samples the wrong region of the source image.

Add a `cell_size` option, accepting "14x32" or { width = 14, height = 32 }.
It is consulted only when TIOCGWINSZ reports no pixels, so terminals that do
report geometry are unaffected.

term.lua is required (via utils) before setup() runs, so its size is computed
at load time and an option read there would be ignored. Export set_cell_size()
and call it from setup() to re-resolve once the option is known.

Also honour IMAGE_NVIM_CELL_SIZE as a fallback when no option is given: Neovim
cannot query the terminal itself -- TermResponse never fires for the CSI 16 t
reply, and a child process gets no /dev/tty -- so the value sometimes has to
come from the shell, which does own the tty:

  printf '\033[16t'   # reply: CSI 6 ; height ; width t

Precedence is option > IMAGE_NVIM_CELL_SIZE > 8x16. Sizes of zero are rejected
rather than passed through to divide cell_width downstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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