tide: ask the terminal for its background instead of reading config files

Over ssh the light prompt segments came out lighter than the background
instead of darker. __tide_terminal_background_color learned the
background by reading <appearance>-theme.auto.conf, which on a remote
host belongs to *that* machine: devry pins theme-mode to dark, so
apply-theme-mode.sh writes the dark theme into all three slots there,
including light-theme.auto.conf. Asked for a light background it found
#212121, correctly refused it as contradicting the appearance, ran out of
files and left the caller on its plain-white fallback -- and a segment
derived from white sits above a #EBE8E4 background rather than below it.
Dark was unaffected because that path never recomputes the palette.

Ask the terminal instead, with an OSC 11 query: it is the one source that
describes the window this shell is actually displayed in, whichever
machine the shell runs on. The config files stay as the fallback, where
they are still right, and the static white behind them.

The query costs one 0.2s-bounded read per appearance change, restores the
terminal settings before it can fail, and steps aside under tmux and
screen, which need the query wrapped and each want it wrapped
differently.

Covered by a new live pty scenario that reproduces the ssh case exactly --
config files all dark, terminal reporting light -- which fails on the
previous ordering, plus unit tests for the reply parser and the fallback
chain.
This commit is contained in:
Anton Volnuhin 2026-08-06 16:08:40 +03:00
parent e5207ac154
commit af52fcf7a3
9 changed files with 258 additions and 78 deletions

View File

@ -1,80 +1,59 @@
# vim:ft=kitty
## name: Catppuccin-Latte
## author: Pocco81 (https://github.com/Pocco81)
## license: MIT
## upstream: https://github.com/catppuccin/kitty/blob/main/themes/latte.conf
## blurb: Soothing pastel theme for the high-spirited!
## name: Kaolin Breeze
## author: Ogden Webb
## license: GPLv3
## blurb: Light Kaolin theme with soft colors
# The basic colors
foreground #4C4F69
background #EFF1F5
selection_foreground #EFF1F5
selection_background #DC8A78
foreground #383e3f
background #EBE8E4
selection_foreground #EBE8E4
selection_background #383e3f
# Cursor colors
cursor #DC8A78
cursor_text_color #EFF1F5
cursor #383e3f
cursor_text_color #EBE8E4
# URL underline color when hovering with mouse
url_color #DC8A78
# Kitty window border colors
active_border_color #7287FD
inactive_border_color #9CA0B0
bell_border_color #DF8E1D
# OS Window titlebar colors
wayland_titlebar_color system
macos_titlebar_color system
# kitty window border colors
active_border_color #48a9a9
inactive_border_color #7D8468
# Tab bar colors
active_tab_foreground #EFF1F5
active_tab_background #8839EF
inactive_tab_foreground #4C4F69
inactive_tab_background #9CA0B0
tab_bar_background #BCC0CC
# Colors for marks (marked text in the terminal)
mark1_foreground #EFF1F5
mark1_background #7287fD
mark2_foreground #EFF1F5
mark2_background #8839EF
mark3_foreground #EFF1F5
mark3_background #209FB5
# The 16 terminal colors
active_tab_background #383e3f
active_tab_foreground #C9C2BD
inactive_tab_background #7D8468
inactive_tab_foreground #EBE8E4
# black
color0 #5C5F77
color8 #6C6F85
color0 #383e3f
color8 #7D8468
# red
color1 #D20F39
color9 #D20F39
color1 #cd5c60
color9 #ef6787
# green
color2 #40A02B
color10 #40A02B
color2 #39855f
color10 #3e594e
# yellow
color3 #DF8E1D
color11 #DF8E1D
color3 #b87e3c
color11 #d1832e
# blue
color4 #1E66F5
color12 #1E66F5
color4 #2683b5
color12 #4F9CB8
# magenta
color5 #EA76CB
color13 #EA76CB
color5 #845A84
color13 #605DB3
# cyan
color6 #179299
color14 #179299
color6 #48a9a9
color14 #008b8b
# white
color7 #ACB0BE
color15 #BCC0CC
color7 #C9C2BD
color15 #60696b

View File

@ -0,0 +1,35 @@
function __tide_parse_osc_background --argument-names reply \
--description 'Extract RRGGBB from a terminal OSC 11 background report'
# The reply is OSC 11 ; rgb:R/G/B ST, with one to four hex digits per
# channel. Scan for it rather than anchoring: Fish keeps kitty's colour
# scheme notifications subscribed, so an unrelated report can share the
# buffer with this one.
set -l parts (
string match -gr \
']11;rgb:([0-9A-Fa-f]{1,4})/([0-9A-Fa-f]{1,4})/([0-9A-Fa-f]{1,4})' \
-- "$reply"
)
test (count $parts) -eq 3
or return 1
set -l hex
for part in $parts
# Each channel is scaled to the width the terminal chose to report it
# in, so f, ff and ffff all mean full intensity.
set -l max
switch (string length -- $part)
case 1
set max 15
case 2
set max 255
case 3
set max 4095
case '*'
set max 65535
end
set -a hex (printf '%02X' (math -s0 "round(255 * 0x$part / $max)"))
end
string join '' $hex
end

View File

@ -1,18 +1,50 @@
function __tide_background_agrees_with --argument-names hex appearance \
--description 'True when a background is as light or dark as the appearance claims'
set -l luminance (__tide_relative_luminance $hex)
test -n "$luminance"
or return 1
set -l scaled (math -s0 "round($luminance * 1000)")
if test "$appearance" = light
test $scaled -gt 500
else
test $scaled -lt 500
end
end
function __tide_terminal_background_color --argument-names appearance \
--description 'Print the background color kitty is showing for an appearance'
--description 'Print the background color the terminal is showing'
contains -- $appearance dark light
or return 1
# Ask the terminal first. It is the only source that is right over ssh,
# where the config files below belong to the *remote* machine and describe
# the mode pinned there, not the window this shell is displayed in. A remote
# box pinned to dark offers its dark theme as its "light" one, the check
# rejects it, and the caller then falls back to plain white -- lighter than
# the real background rather than darker.
if functions -q __tide_terminal_osc_background
set -l queried (__tide_terminal_osc_background)
if string match -qr '^[0-9A-Fa-f]{6}$' -- "$queried"
set queried (string upper -- $queried)
if __tide_background_agrees_with $queried $appearance
echo $queried
return 0
end
end
end
functions -q __kitty_config_dir
or return 1
set -l config_dir (__kitty_config_dir)
# kitty gives the *-theme.auto.conf files priority over includes and even
# over --override, so whatever is in them is what the window is actually
# showing -- whether it got there from `kitten themes` or from
# apply-theme-mode.sh pinning a mode.
# Locally these describe this very window: kitty gives the
# *-theme.auto.conf files priority over includes and even over --override,
# so whatever is in them is what it is actually showing -- whether that came
# from `kitten themes` or from apply-theme-mode.sh pinning a mode.
for file in $config_dir/$appearance-theme.auto.conf $config_dir/no-preference-theme.auto.conf
test -r $file
or continue
@ -22,21 +54,11 @@ function __tide_terminal_background_color --argument-names appearance \
or continue
set hex (string upper -- $hex)
set -l luminance (__tide_relative_luminance $hex)
test -n "$luminance"
or continue
# A background that disagrees with the appearance Fish reported means
# we read the wrong file. A pill derived from the wrong background is
# worse than the static fallback, so skip it.
set -l scaled (math -s0 "round($luminance * 1000)")
if test "$appearance" = light
test $scaled -gt 500
__tide_background_agrees_with $hex $appearance
or continue
else
test $scaled -lt 500
or continue
end
echo $hex
return 0

View File

@ -0,0 +1,48 @@
function __tide_terminal_osc_background \
--description 'Ask the terminal which background it is showing (OSC 11)'
# Only an interactive shell has anyone to ask. Do not test stdin or stdout
# here: the caller always reads this function through a command
# substitution, so its stdout is a pipe every single time. /dev/tty is the
# terminal however the two are redirected, and the stty below fails
# harmlessly if there is no controlling terminal to open.
status is-interactive
or return 1
# tmux and screen both need the query wrapped, and each wants it wrapped
# differently. Leave those to the caller's config-file path instead of
# guessing wrong and eating the user's keystrokes waiting for a reply.
set -q TMUX
and return 1
string match -q 'screen*' -- "$TERM"
and return 1
set -l saved (stty -g </dev/tty 2>/dev/null)
test -n "$saved"
or return 1
# min 0 / time 2 makes every read give up after two tenths of a second, so a
# terminal that ignores OSC 11 costs that much once per appearance change
# and can never hang the shell. Restore the settings the moment the read
# loop ends, before anything that could fail.
stty raw -echo min 0 time 2 </dev/tty 2>/dev/null
printf '\e]11;?\e\\' >/dev/tty 2>/dev/null
set -l reply
for attempt in 1 2 3 4 5
set -l chunk (dd bs=64 count=1 </dev/tty 2>/dev/null | string collect -N)
test -n "$chunk"
or break
set reply "$reply$chunk"
# Stop as soon as a whole report has arrived; a channel that is still
# being written would parse as a narrower one and give a wrong colour.
string match -q '*]11;rgb:*' -- "$reply"
and string match -qr '\a|\e\\\\' -- "$reply"
and break
end
stty $saved </dev/tty 2>/dev/null
__tide_parse_osc_background "$reply"
end

View File

@ -0,0 +1,5 @@
# vim:ft=kitty
# A machine pinned to dark puts its dark theme in every slot, so even the
# "light" one describes a dark window -- the ssh case this fixture stands in for.
background #212121
foreground #BDB9AE

View File

@ -0,0 +1,5 @@
# vim:ft=kitty
# A machine pinned to dark puts its dark theme in every slot, so even the
# "light" one describes a dark window -- the ssh case this fixture stands in for.
background #212121
foreground #BDB9AE

View File

@ -0,0 +1,5 @@
# vim:ft=kitty
# A machine pinned to dark puts its dark theme in every slot, so even the
# "light" one describes a dark window -- the ssh case this fixture stands in for.
background #212121
foreground #BDB9AE

View File

@ -37,9 +37,10 @@ set -l luminance_helper $project_root/functions/__tide_relative_luminance.fish
set -l scale_helper $project_root/functions/__tide_scale_luminance.fish
set -l ceiling_helper $project_root/functions/__tide_contrast_ceiling.fish
set -l background_probe $project_root/functions/__tide_terminal_background_color.fish
set -l osc_parser $project_root/functions/__tide_parse_osc_background.fish
for file in $light_mapper $appearance_applicator $luminance_helper $scale_helper \
$ceiling_helper $background_probe
$ceiling_helper $background_probe $osc_parser
test -f $file
or __test_fail "missing "(path basename $file)
end
@ -49,6 +50,7 @@ source $luminance_helper
source $scale_helper
source $ceiling_helper
source $background_probe
source $osc_parser
source $appearance_applicator
# The applicator exports its overrides, so a shell that already applied a
@ -112,7 +114,67 @@ set -l empty_config (mktemp -d)
KITTY_CONFIG_DIRECTORY=$empty_config __tide_terminal_background_color light
__test_assert_equal 1 $status "the probe reports failure when no theme is readable"
rm -rf $fake_config $empty_config
# --- OSC 11 replies ------------------------------------------------------
__test_assert_equal EBE8E4 \
(__tide_parse_osc_background \e']11;rgb:ebeb/e8e8/e4e4'\e\\) \
"a four-digit-per-channel report is scaled to eight bits"
__test_assert_equal FFFFFF (__tide_parse_osc_background \e']11;rgb:ff/ff/ff'\a) \
"a two-digit report with a BEL terminator is read"
__test_assert_equal FFFFFF (__tide_parse_osc_background \e']11;rgb:f/f/f'\a) \
"channels are scaled to the width they are reported in"
__test_assert_equal 000000 (__tide_parse_osc_background \e']11;rgb:0000/0000/0000'\e\\) \
"black is not mistaken for a failed parse"
# Fish keeps kitty's colour-scheme notifications on, so another report can be
# sitting in the buffer either side of this one.
__test_assert_equal EBE8E4 \
(__tide_parse_osc_background \e'[?997;1n'\e']11;rgb:ebeb/e8e8/e4e4'\e\\) \
"the report is found next to unrelated terminal output"
__tide_parse_osc_background 'not a reply at all'
__test_assert_equal 1 $status "a reply without a colour is a failure, not a colour"
__tide_parse_osc_background ''
__test_assert_equal 1 $status "an empty reply is a failure"
# --- the terminal outranks the config files ------------------------------
# This is the ssh case: the remote box is pinned to dark, so *its*
# light-theme.auto.conf holds a dark theme, while the window on this end is
# light. Reading files there yields no usable answer and the caller falls back
# to white, which lands lighter than the real background instead of darker.
set -l remote_config (mktemp -d)
printf 'background #212121\n' >$remote_config/light-theme.auto.conf
printf 'background #212121\n' >$remote_config/dark-theme.auto.conf
printf 'background #212121\n' >$remote_config/no-preference-theme.auto.conf
KITTY_CONFIG_DIRECTORY=$remote_config __tide_terminal_background_color light
__test_assert_equal 1 $status \
"without the terminal, a dark-pinned remote gives the probe nothing to use"
function __tide_terminal_osc_background
echo ebe8e4
end
__test_assert_equal EBE8E4 \
(KITTY_CONFIG_DIRECTORY=$remote_config __tide_terminal_background_color light) \
"the terminal's own answer wins over a remote machine's config files"
# A terminal that cannot be asked has to leave the config files in charge.
function __tide_terminal_osc_background
return 1
end
__test_assert_equal EBE8E4 \
(KITTY_CONFIG_DIRECTORY=$fake_config __tide_terminal_background_color light) \
"the config files still answer when the terminal will not"
# And an answer contradicting the appearance is refused just like a file's.
function __tide_terminal_osc_background
echo 212121
end
__test_assert_equal EBE8E4 \
(KITTY_CONFIG_DIRECTORY=$fake_config __tide_terminal_background_color light) \
"a terminal answer that contradicts the appearance is skipped"
functions --erase __tide_terminal_osc_background
rm -rf $fake_config $empty_config $remote_config
set -l empty_palette_result (
fish --no-config -c '

View File

@ -6,16 +6,35 @@ log_user 0
# The segment background is derived from the theme kitty is showing, so the
# emulated terminal responses below and the theme files have to agree. These
# fixtures are the white/black the responses report.
set kitty_config [file join [file dirname [file normalize [info script]]] fixtures kitty]
set fixtures [file join [file dirname [file normalize [info script]]] fixtures]
set kitty_config [file join $fixtures kitty]
# What the emulated terminal answers OSC 11 with, and the segment background
# Tide must derive from it.
set light_reply "ffff/ffff/ffff"
set light_segment "F2F2F2"
set path_label "direct"
set through_tmux 0
if {[llength $argv] > 0 && [lindex $argv 0] eq "tmux"} {
set mode [expr {[llength $argv] > 0 ? [lindex $argv 0] : "direct"}]
if {$mode eq "tmux"} {
set path_label "tmux"
set through_tmux 1
spawn env TERM=xterm-kitty KITTY_CONFIG_DIRECTORY=$kitty_config \
tmux -L tide-appearance-test \
-f "$env(HOME)/.tmux.conf" new-session fish --interactive
} elseif {$mode eq "remote"} {
# The ssh case: the config files belong to a machine pinned to dark, so
# every slot there describes a dark window, while the terminal this shell
# is displayed in is light. Only asking the terminal gets this right --
# reading the files yields nothing usable and Tide falls back to white,
# putting the segment *above* the background instead of below it.
set path_label "remote"
set kitty_config [file join $fixtures kitty-remote-dark]
set light_reply "ebeb/e8e8/e4e4"
set light_segment "DFDCD8"
spawn env TERM=xterm-kitty KITTY_CONFIG_DIRECTORY=$kitty_config fish --interactive
} else {
spawn env TERM=xterm-kitty KITTY_CONFIG_DIRECTORY=$kitty_config fish --interactive
}
@ -31,7 +50,7 @@ if {$through_tmux} {
exp_continue
}
-exact "\033]11;?\033\\" {
send -- "\033]11;rgb:ffff/ffff/ffff\033\\"
send -- "\033]11;rgb:$light_reply\033\\"
exp_continue
}
-exact "\033\[0c" {
@ -49,7 +68,7 @@ if {$through_tmux} {
} else {
expect {
-exact "\033]11;?\033\\" {
send -- "\033]11;rgb:ffff/ffff/ffff\033\\"
send -- "\033]11;rgb:$light_reply\033\\"
exp_continue
}
-exact "\033\[0c" {
@ -66,7 +85,7 @@ if {$through_tmux} {
send -- "printf 'CHECK:%s:%s\\n' \$__tide_terminal_appearance \$tide_pwd_bg_color\r"
expect {
-re {CHECK:light:F2F2F2} {}
-re "CHECK:light:$light_segment" {}
timeout {
puts stderr "The initial light terminal background did not apply the light Tide palette"
exit 1