Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.11.8
1.11.9
59 changes: 44 additions & 15 deletions nvim/.config/nvim/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 <leader>cs - all load at VeryLazy):
Expand Down
9 changes: 4 additions & 5 deletions nvim/.config/nvim/local.lua.example
Original file line number Diff line number Diff line change
Expand Up @@ -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({})
Expand Down
165 changes: 121 additions & 44 deletions tests/nvim-colour-fallback.sh
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
#!/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)"
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 )); }

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
Expand All @@ -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" <<SH
while [ ! -f '$tmp/go' ]; do sleep 0.2; done
nvim --clean -u '$INIT' -c "lua io.open('$dec','w'):write(tostring(vim.o.termguicolors))" -c 'qa!'
sleep 5
SH
TERM=xterm-256color tmux -S "$sock" new-session -d -x 100 -y 30 \
"bash '$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"
Expand Down
Loading