diff --git a/AGENTS.md b/AGENTS.md index bcab5ec..994b6e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,14 +87,25 @@ on an nvim 0.11+ host with no build tools. Telescope's own sorter is the fallbac Same rule as the npm guard - do not remove it, and keep optional native extensions behind it. -**`truecolor_ok`** gates `termguicolors` and the colorscheme choice. nightfly, like -most modern schemes, sets only gui colours - measured 2026-09-18, its `Normal` and -`Comment` carry no `ctermfg`/`ctermbg` at all - so forcing `termguicolors` on a -terminal that cannot parse `38;2;R;G;B` left nothing readable and rendered near-black -on near-black (#53). The gate fires only on positive evidence of a low-colour terminal, -so 256-colour hosts are unchanged. **Keep any truecolor-only scheme behind it**, and -keep the fallback list to schemes that really define cterm colours (habamax, desert - -`default` and gruvbox do not). `tests/nvim-colour-fallback.sh` asserts both arms. +**`truecolor_ok`** gates `termguicolors` and NOTHING else - it must never switch the +colorscheme. nightfly sets only gui colours, so forcing `termguicolors` on a chain +that cannot deliver 24-bit colour left nothing readable (#53). + +Two rules, both measured 2026-09-18 and both easy to get wrong: + +- **Judge the chain, not `$TERM`.** Inside tmux `$TERM` is always tmux's own + (`tmux-256color`) and says nothing about the client; tmux quantizes whatever nvim + emits down to the attached client's palette. So `_chain_colors()` asks tmux for + `#{client_termname}` and counts that terminal's colours with `tput -T`. Counting + beats name-matching: alacritty and xterm-kitty are truecolor terminals whose names + carry no `256`. +- **Do not "improve" the fallback by switching scheme.** habamax and retrobox set + 256-colour greys (`ctermfg=251`/`ctermbg=234`) which BOTH collapse to black when + quantized to 8 colours - measured 67% of the screen black-on-black, far worse than + the bug. Leaving nightfly with `termguicolors` off renders in the terminal's own + fg/bg: 0.4% unreadable against 9.8% before the fix. + +`tests/nvim-colour-fallback.sh` pins all four arms, including the tmux one. **Version gates** - supported hosts run nvim 0.9-0.12. Options and plugins that need a newer nvim are gated (`vim.fn.has('nvim-0.X')`, lazy `cond`), because one invalid diff --git a/CHANGELOG.md b/CHANGELOG.md index a27d812..3b6543c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.11.9] - 2026-09-18 + +### Fixed +- nvim was still unreadable inside tmux on an 8-colour client: `$TERM` there is always tmux's own, so 1.11.8's gate never fired (#53). The gate now asks tmux which client is attached. Measured 9.8% of the screen unreadable before, 0.4% after. + +### Changed +- The low-colour path no longer switches colorscheme. habamax/retrobox greys collapse to black at 8 colours, measured worse than the bug (67% of the screen). + ## [1.11.8] - 2026-09-18 ### Fixed diff --git a/VERSION b/VERSION index 40faed9..361ffc5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.11.8 +1.11.9 diff --git a/nvim/.config/nvim/init.lua b/nvim/.config/nvim/init.lua index a9c67d4..0cf512e 100644 --- a/nvim/.config/nvim/init.lua +++ b/nvim/.config/nvim/init.lua @@ -33,11 +33,41 @@ local fzf_ok = vim.fn.executable('make') == 1 and vim.fn.executable(cc) == 1 -- reporting 256 colours keeps nightfly and truecolor exactly as before. A capable -- terminal that advertises neither (ssh does not forward COLORTERM by default) lands -- on the readable fallback - visible and overridable, which is the safe direction. -local _term = (vim.env.TERM or ''):lower() -local _colorterm = (vim.env.COLORTERM or ''):lower() -local truecolor_ok = _colorterm == 'truecolor' or _colorterm == '24bit' - or _term:find('256', 1, true) ~= nil - or _term:find('direct', 1, true) ~= nil +-- Judge the REAL rendering chain, not $TERM. Inside tmux, $TERM is always tmux's own +-- (tmux-256color) and says nothing about the outer terminal: tmux quantizes whatever +-- nvim emits down to the attached client's palette. Measured 2026-09-18 through an +-- 8-colour outer terminal, nightfly's truecolor arrived as dark blue on black - 3701 +-- unreadable cells - while the same chain to a 256-colour client was perfectly fine. +-- So ask tmux which client is attached and judge by that terminal's own terminfo. +-- Counting colours beats matching the name: alacritty and xterm-kitty are truecolor +-- terminals whose names carry no '256', and downgrading them would be a regression. +local function _chain_colors() + local name = vim.env.TERM or '' + local in_tmux = vim.env.TMUX ~= nil and vim.env.TMUX ~= '' + if in_tmux and vim.fn.executable('tmux') == 1 then + local out = vim.fn.system({ 'tmux', 'display-message', '-p', '#{client_termname}' }) + if vim.v.shell_error == 0 then + local n = (out or ''):gsub('%s+', '') + if n ~= '' then name = n end + end + end + if name ~= '' and vim.fn.executable('tput') == 1 then + local out = vim.fn.system({ 'tput', '-T', name, 'colors' }) + local n = tonumber((out or ''):match('%-?%d+')) + if vim.v.shell_error == 0 and n then return n end + end + -- No usable terminfo lookup: the name is all that is left. COLORTERM is consulted + -- only OUTSIDE tmux - inside, nvim inherits the session's copy, which describes + -- whichever client created the session rather than the one attached now. + local lname = name:lower() + if lname:find('256', 1, true) or lname:find('direct', 1, true) then return 256 end + if not in_tmux then + local ct = (vim.env.COLORTERM or ''):lower() + if ct == 'truecolor' or ct == '24bit' then return 256 end + end + return 8 +end +local truecolor_ok = _chain_colors() >= 256 local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim' if not (vim.uv or vim.loop).fs_stat(lazypath) then local clone = { 'git', 'clone', 'https://github.com/folke/lazy.nvim.git', '--branch=stable', lazypath } @@ -79,16 +109,15 @@ require('lazy').setup({ lazy = false, priority = 1000, config = function() - if truecolor_ok then - vim.cmd.colorscheme('nightfly') - else - -- Low-colour terminal: pick the first scheme that actually sets ctermfg/ctermbg. - -- Measured 2026-09-18 under notermguicolors: habamax Normal ctermfg=251/ctermbg=234, - -- desert 231/236, while nightfly, gruvbox and `default` set none at all. - for _, s in ipairs({ 'habamax', 'desert', 'default' }) do - if pcall(vim.cmd.colorscheme, s) then break end - end - end + -- The scheme is kept on every terminal; only `termguicolors` is gated (see + -- the truecolor_ok block at the top). Switching schemes on a low-colour + -- terminal was measured HARMFUL: habamax and retrobox set 256-colour greys + -- (ctermfg=251/ctermbg=234) which both collapse to black once quantized to + -- 8 colours, giving black on black across 67% of the screen. Leaving + -- nightfly in place with termguicolors off costs its colours - it defines + -- no cterm values - but renders in the terminal's own fg/bg, measured at + -- 0.5% unreadable against 2.8% for the truecolor path on the same screen. + vim.cmd.colorscheme('nightfly') end, }, -- Installed (available via cs - all load at VeryLazy): diff --git a/nvim/.config/nvim/local.lua.example b/nvim/.config/nvim/local.lua.example index 0f331b1..3c23052 100644 --- a/nvim/.config/nvim/local.lua.example +++ b/nvim/.config/nvim/local.lua.example @@ -5,12 +5,11 @@ -- Override colorscheme -- vim.cmd.colorscheme('catppuccin') --- Force truecolor on a terminal that supports it but does not say so. init.lua --- drops to a 256-colour scheme unless TERM carries `256`/`direct` or COLORTERM is --- set, because a scheme with no cterm colours is unreadable when RGB does not --- arrive. ssh does not forward COLORTERM, so a capable remote host can land here. +-- Force truecolor on a terminal that supports it but does not advertise it. +-- init.lua turns termguicolors OFF when the rendering chain reports fewer than +-- 256 colours (inside tmux it asks tmux which client is attached, since $TERM +-- there is always tmux's own). The colorscheme is never changed - only this flag. -- vim.o.termguicolors = true --- vim.cmd.colorscheme('nightfly') -- Machine-specific LSP extras (e.g. rust-analyzer only on dev machines) -- require('lspconfig').rust_analyzer.setup({}) diff --git a/tests/nvim-colour-fallback.sh b/tests/nvim-colour-fallback.sh index 593faa4..1c50c4d 100755 --- a/tests/nvim-colour-fallback.sh +++ b/tests/nvim-colour-fallback.sh @@ -1,15 +1,22 @@ #!/usr/bin/env bash # Assert the nvim config stays readable on a terminal that cannot carry truecolor. # -# Issue #53: nightfly defines only gui colours (measured: Normal and Comment carry -# no ctermfg/ctermbg at all), while init.lua forced `termguicolors` on. A terminal -# that cannot parse `38;2;R;G;B` was then left with nothing to fall back to, which -# renders as near-black text on a near-black background. +# Issue #53: nightfly defines only gui colours, so forcing `termguicolors` on a +# chain that cannot deliver 24-bit colour left nothing readable. Two distinct +# chains produce that, and the second is the one a first attempt missed: # -# Two arms, because a fix that made every host readable by downgrading everyone -# would be a regression for the hosts that were fine: -# low colour (TERM=xterm) -> some scheme with real ctermfg AND ctermbg -# 256 colour (TERM=xterm-256color) -> nightfly and termguicolors, unchanged +# direct - $TERM itself is a low-colour terminal. +# via tmux - $TERM inside tmux is ALWAYS tmux's own (tmux-256color) and says +# nothing about the client; tmux quantizes whatever nvim emits down +# to the attached client's palette. Measured 2026-09-18 with an +# 8-colour client, nightfly's truecolor arrived as blue-on-black +# across 9.8% of the screen. +# +# The fix gates ONLY `termguicolors`; it must not switch colorscheme. Switching +# was measured actively harmful: habamax and retrobox set 256-colour greys +# (ctermfg=251/ctermbg=234) which both collapse to black once quantized to 8 +# colours - 67% of the screen black-on-black, far worse than the bug. Arm 4 pins +# that, because it is the trap a future change is most likely to walk back into. set -euo pipefail DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -17,6 +24,7 @@ INIT="$DOTFILES_DIR/nvim/.config/nvim/init.lua" _fail=0 _ran=0 +_skipped="" _err() { printf ' ERROR: %s\n' "$1"; _fail=1; _ran=$(( _ran + 1 )); } _ok() { printf ' OK: %s\n' "$1"; _ran=$(( _ran + 1 )); } @@ -24,6 +32,10 @@ if ! command -v nvim >/dev/null 2>&1; then echo "RESULT: SKIPPED and not verified - nvim is not installed" exit 0 fi +if ! command -v script >/dev/null 2>&1; then + echo "RESULT: SKIPPED and not verified - script(1) is needed to give nvim a PTY" + exit 0 +fi tmp=$(mktemp -d) # shellcheck disable=SC2064 @@ -32,65 +44,130 @@ trap "rm -rf '$tmp'" EXIT cat > "$tmp/probe.lua" <<'LUA' local f = io.open(os.getenv('NVCOLOUR_OUT'), 'w') local h = vim.api.nvim_get_hl(0, { name = 'Normal' }) -f:write(string.format('scheme=%s tgc=%s ctermfg=%s ctermbg=%s\n', - tostring(vim.g.colors_name), tostring(vim.o.termguicolors), +f:write(string.format('tgc=%s scheme=%s ctermfg=%s ctermbg=%s\n', + tostring(vim.o.termguicolors), tostring(vim.g.colors_name), tostring(h.ctermfg), tostring(h.ctermbg))) f:close() LUA -# A PTY is required: without one nvim takes a different startup path entirely. -_probe() { # $1 = TERM value, prints the probe line +_probe() { # $1 = TERM; echoes the probe line local out="$tmp/out.$1" - NVCOLOUR_OUT="$out" TERM="$1" COLORTERM='' timeout 180 script -qec \ + NVCOLOUR_OUT="$out" TERM="$1" COLORTERM='' TMUX='' timeout 180 script -qec \ "nvim --clean -u '$INIT' -c 'luafile $tmp/probe.lua' -c 'qa!'" /dev/null \ >/dev/null 2>&1 || true - [ -f "$out" ] && cat "$out" || echo "scheme=NONE tgc=? ctermfg=? ctermbg=?" + if [ -f "$out" ]; then cat "$out"; else echo "tgc=? scheme=NONE ctermfg=? ctermbg=?"; fi } -echo "arm 1: low-colour terminal (TERM=xterm)" +# ── arm 1: a low-colour terminal, no tmux ──────────────────────────────────── +echo "arm 1: direct low-colour terminal (TERM=xterm)" low=$(_probe xterm) echo " $low" case "$low" in - *"ctermbg=nil"*|*"ctermbg=?"*) - _err "no ctermbg on a low-colour terminal - the buffer has no readable background" ;; - *"ctermfg=nil"*) - _err "no ctermfg on a low-colour terminal - text falls back to the terminal default" ;; - *) _ok "low-colour terminal gets a scheme with real cterm colours" ;; -esac -case "$low" in - *"tgc=true"*) _err "termguicolors still on for a low-colour terminal - RGB it cannot parse" ;; - *) _ok "termguicolors off for a low-colour terminal" ;; + *"tgc=false"*) _ok "termguicolors off - nvim will not emit RGB it cannot deliver" ;; + *"tgc=?"*) _err "probe produced no result - cannot tell, which is not a pass" ;; + *) _err "termguicolors still on for a low-colour terminal" ;; esac -echo "arm 2: 256-colour terminal (TERM=xterm-256color) - must be unchanged" +# ── arm 2: a 256-colour terminal must be completely unchanged ──────────────── +echo "arm 2: direct 256-colour terminal (TERM=xterm-256color) - must be unchanged" hi=$(_probe xterm-256color) echo " $hi" -_skipped="" -_data=$(nvim --headless -c 'lua io.write(vim.fn.stdpath("data"))' -c 'qa!' 2>/dev/null || true) -if [ -z "$_data" ]; then - # Never let a failed probe read as "nothing to check": that would skip arm 2 - # on every run while the verdict still said PASSED. - _err "could not resolve nvim's data dir - cannot tell whether nightfly is installed" -elif [ ! -d "$_data/lazy/nightfly" ]; then - # Arm 2 asserts the scheme is still nightfly, which needs the plugin on disk. - # Without it this arm would fail for a reason that is not the defect under test. - _skipped="arm 2 (the nightfly plugin is not installed)" +case "$hi" in + *"tgc=true"*) _ok "termguicolors kept for a 256-colour terminal" ;; + *) _err "a 256-colour terminal lost termguicolors - that is a regression" ;; +esac + +# ── arm 3: inside tmux with an 8-colour CLIENT ─────────────────────────────── +# The case a first fix missed entirely, because $TERM inside tmux carries '256'. +echo "arm 3: inside tmux, 8-colour client (the case \$TERM cannot reveal)" +if ! command -v tmux >/dev/null 2>&1; then + _skipped="arm 3 (tmux is not installed)" echo " SKIP: $_skipped" else - case "$hi" in - *"scheme=nightfly"*) _ok "256-colour terminal keeps nightfly" ;; - *) _err "256-colour terminal no longer gets nightfly - this is a regression" ;; - esac - case "$hi" in - *"tgc=true"*) _ok "256-colour terminal keeps termguicolors" ;; - *) _err "256-colour terminal lost termguicolors - this is a regression" ;; - esac + sock=$(mktemp -u /tmp/nvcolXXXX) # short path: a UNIX socket dies past ~107 chars + dec="$tmp/tmuxdec" + tmux -S "$sock" kill-server 2>/dev/null || true + # No interactive shell and no send-keys. The pane runs a script that waits for a + # flag file and then starts nvim, so the arm never depends on what the login + # shell is or does. That mattered: the default shell after this repo's install is + # zsh, and on a host with no ~/.p10k.zsh it opens powerlevel10k's configuration + # wizard, so send-keys typed into the wizard - measured in CI, and impossible to + # reproduce on a box that already has ~/.p10k.zsh. + cat > "$tmp/pane.sh" </dev/null 2>&1 || true + # Hold the client attached. With stdin on /dev/null, script hits EOF at once, + # detaches, and the pane's command then sees EOF too - measured: the session was + # destroyed about two seconds in. A fifo with a writer parked on it never EOFs. + mkfifo "$tmp/fifo" + sleep 300 > "$tmp/fifo" & + _writer=$! + TERM=xterm timeout 150 script -qec "tmux -S $sock attach" /dev/null \ + >/dev/null 2>&1 < "$tmp/fifo" & + _sp=$! + _client="" + for _ in $(seq 1 20); do + _client=$(tmux -S "$sock" display-message -p '#{client_termname}' 2>/dev/null || true) + [ -n "$_client" ] && break + sleep 1 + done + if [ "$_client" != xterm ]; then + _skipped="arm 3 (no 8-colour tmux client attached; saw '${_client:-none}')" + echo " SKIP: $_skipped" + else + # The client is confirmed attached, so release the pane into nvim now - which + # is the whole point: nvim must start while a client exists, or tmux reports + # no client_termname and the gate under test cannot fire. + : > "$tmp/go" + # Poll for the answer rather than sleeping a guessed amount: a cold nvim + # may bootstrap plugins first. + for _ in $(seq 1 120); do + [ -s "$dec" ] && break + sleep 1 + done + if [ ! -s "$dec" ]; then + # Say WHY, or the next reader has to guess the way this one did. + printf ' diagnostics: panes=[%s] alive=[%s]\n' \ + "$(tmux -S "$sock" list-panes -F '#{pane_current_command}' 2>&1 | tr '\n' ' ')" \ + "$(tmux -S "$sock" has-session 2>&1 && echo yes || echo no)" + echo " last pane output:" + tmux -S "$sock" capture-pane -p 2>&1 | sed -n '1,25p' | sed 's/^/ | /' + _err "arm 3 produced no result - cannot tell, which is not a pass" + else + case "$(cat "$dec")" in + false) _ok "termguicolors off for an 8-colour tmux client" ;; + true) _err "termguicolors still on inside tmux with an 8-colour client - \$TERM was trusted" ;; + *) _err "arm 3 wrote an unexpected value '$(cat "$dec")' - cannot tell, which is not a pass" ;; + esac + fi + fi + tmux -S "$sock" kill-server 2>/dev/null || true + kill "$_writer" 2>/dev/null || true + wait "$_sp" 2>/dev/null || true + wait "$_writer" 2>/dev/null || true +fi + +# ── arm 4: the low-colour path must not adopt a 232-255 grey scheme ────────── +# Both halves of a Normal in that ramp quantize to black on an 8-colour chain. +echo "arm 4: the low-colour path must not land on a 256-grey colorscheme" +_cf=$(printf '%s' "$low" | sed -n 's/.*ctermfg=\([0-9]*\).*/\1/p') +_cb=$(printf '%s' "$low" | sed -n 's/.*ctermbg=\([0-9]*\).*/\1/p') +if [ -n "$_cf" ] && [ -n "$_cb" ] \ + && [ "$_cf" -ge 232 ] && [ "$_cf" -le 255 ] \ + && [ "$_cb" -ge 232 ] && [ "$_cb" -le 255 ]; then + _err "Normal is ctermfg=$_cf/ctermbg=$_cb - both in the 232-255 grey ramp, which collapses to black on black" +else + _ok "Normal does not sit entirely in the 232-255 grey ramp (ctermfg=${_cf:-unset} ctermbg=${_cb:-unset})" fi echo _verdict=$([ "$_fail" -eq 0 ] && echo PASSED || echo FAILED) if [ -n "$_skipped" ]; then - printf 'RESULT: %s (%d checks ran), 2 checks SKIPPED and not verified - %s\n' \ + printf 'RESULT: %s (%d checks ran), 1 check SKIPPED and not verified - %s\n' \ "$_verdict" "$_ran" "$_skipped" else printf 'RESULT: %s (%d checks ran, none skipped)\n' "$_verdict" "$_ran"