nushell
nushell copied to clipboard
Automatic switching between light and dark theme (CSI 2031)
Related problem
Currently, nushell does not have support for switching between dark and light theme - there is always only one color theme configured in $env.config.color_config.
Several terminals (Ghostty, Contour, Kitty) have implemented support for CSI 2031 https://github.com/contour-terminal/contour/blob/f3c3334aa5c861348c5bbe8ffe572c872eef2e08/docs/vt-extensions/color-palette-update-notifications.md which allows terminal applications to receive updates regarding the current system theme, and then automatically switch between light/dark theme.
It would be nice if nushell had support for this.
Describe the solution you'd like
- Refactor current color theme configuration
$env.config.color_configto support light and dark theme, e.g.:
env.config.color_theme = "light"
env.config.color_theme_light = ...
env.config.color_theme_dark = ...
- Implement support for CSI 2031 https://github.com/contour-terminal/contour/blob/f3c3334aa5c861348c5bbe8ffe572c872eef2e08/docs/vt-extensions/color-palette-update-notifications.md to receive information about current system theme.
Describe alternatives you've considered
No response
Additional context and details
No response
You could probably do this in a hook by calling term query something like this on a supported terminal.
term query "\e[?996n" --prefix "\e[?997;" --terminator "n" | decode
Then depending on whether the response is 1 or 2 load a dark or light theme from the nu_scripts repo.
What are the two strings: "\e[?996n" and "\e[?997;"?
They're from the linked urls above. It explains how it works.
@fdncred thanks, that looks promising, although I hit an issue, here is my nushell hook together with some debug prints:
def switch_theme [] {
let dark_theme = 1
let light_theme = 2
let system_theme = term query "\e[?996n" --prefix "\e[?997;" --terminator "n" | decode | into int
if $system_theme == $dark_theme {
print "Switching to dark"
print $"Current foreground color: ($env.config.color_config.foreground)"
source ($nu.default-config-dir | path join 'rose-pine-moon.nu')
print $"New foreground color: ($env.config.color_config.foreground)"
} else if $system_theme == $light_theme {
print "Switching to light"
print $"Current foreground color: ($env.config.color_config.foreground)"
source ($nu.default-config-dir | path join 'rose-pine-dawn.nu')
print $"New foreground color: ($env.config.color_config.foreground)"
} else {
let error_msg = "Unknown system theme returned from the terminal: " + ($system_theme | into string)
error make {msg: $error_msg }
}
}
$env.config.hooks.pre_prompt = [{ switch_theme }]
When I run nushell with the hook, the hook itself works as expected, correctly detects system theme and changes $env.config.color_config. However, it seems that the change to $env.config.color_config is not propagated outside the hook:
Switching to dark
Current foreground color: #575279
New foreground color: #e0def4
❯ : $env.config.color_config.foreground
#575279 # Here should be new foreground color, not old
Is this correct behavior?
To be clear, I've never done what you're trying to do. So, this is a bit of trial and error. I'm not sure it'll work at all, but it might.
You should probably be activating themes like this https://github.com/nushell/nu_scripts/tree/main/themes#set-terminal-colors or https://github.com/nushell/nu_scripts/tree/main/themes#load-a-color_config
Also, I'd try changing your switch_theme custom command to something more like this.
const theme_name = if $system_theme == $dark_theme { } else { }
Then either source or use the $theme_name following the links above.
Understand, I appreciate your help as my nushell knowledge is very limited. I reworked the code to use the standard theme activation (load the modules with use and then switch the theme with $env.config.color_config = ($theme_func)):
use ($nu.default-config-dir | path join "rose-pine-moon.nu")
use ($nu.default-config-dir | path join "rose-pine-dawn.nu")
def switch_theme [] {
const dark_theme = 1
const light_theme = 2
let system_theme = term query "\e[?996n" --prefix "\e[?997;" --terminator "n" | decode | into int
let theme_func = if $system_theme == $dark_theme {
rose-pine-moon
} else if $system_theme == $light_theme {
rose-pine-dawn
} else {
let error_msg = "Unknown system theme returned from terminal: " + ($system_theme | into string)
error make {msg: $error_msg }
}
$env.config.color_config = ($theme_func)
}
$env.config.hooks.pre_prompt = [{ switch_theme }]
But it suffers from the same problem - modifications made to the $env.config variable are not visible outside of the hook (which I'm not sure if is correct behavior or bug).
I think assigning the theme in $env.config.color_config probably won't work unless your def is def --env. That would probably help.
@NotTheDr01ds do you have any advice here?
Using the switch_theme function @Dom324 wrote, and adding the def --env as @fdncred suggested, I can get it to work in some terminals, but not all. For instance, in the zed editor's terminal, and in the Cosmic terminal, the call to get the system_theme hangs. After pressing ctrl + c to get out of the hang, I see the error message
Error:
× Input did not begin with expected sequence
help: Try running without `--prefix` and inspecting the output.
I think that is expected, CSI 2031/996 is quite new and supported only in couple modern terminals (ghostty, kitty, contour, maybe some others).
The error you are getting is coming from term query "\e[?996n" --prefix "\e[?997;" --terminator "n" | decode, the terminal does not know the control sequence and the query hangs.
I'll have time to test the --env flag later this week.
Yup, that's right. The terminal has to support those ansi escape sequences, otherwise it will wait until you hit ctrl+c.
Edit: I actually got it to work using a def --env function instead of a closure as my hook, as per @fdncred 's suggestion! I've updated my config below.
So I've fallen down the same rabbit hole a few days ago ~and ended up hitting the same roadblock~ (i.e trying to modify $env.config.color_config from within a pre_prompt hook.
My strategy was a bit different since wezterm doesn't support CSI-2031/996 so I ended up basically re-implementing terminal-colorsaurus in nushell 😅
This is what I've got so far ~(but again, I'm blocked by trying to modify the config from within the hook)~:
# Shamelessly stolen/ported from https://github.com/bash/terminal-colorsaurus/tree/main/crates/terminal-colorsaurus and https://github.com/bash/terminal-colorsaurus/tree/main/crates/xterm-color
module color {
def parse_channel_scaled []: string -> float {
let input = $in
let scale = 2 ** (($input | str length) * 4)
($input | into int --radix 16) / $scale
}
def gamma [v: float]: nothing -> float {
if $v <= 0 {
0
} else if $v <= 0.04045 {
$v / 12.92
} else {
(($v + 0.055) / 1.055) ** 2.4
}
}
def luminance []: record<r: float, g: float, b: float> -> float {
let c = $in
0.2126 * (gamma $c.r) + 0.7152 * (gamma $c.g) + 0.0722 * (gamma $c.b)
}
def luminance_to_perceived_lightness []: float -> float {
let luminance = $in
if $luminance <= 216. / 24389. {
$luminance * (24389. / 27.)
} else {
($luminance ** (1 / 3)) * 116. - 16.
}
}
def perceived_lightness []: record<r: float, g: float, b: float> -> float {
($in | luminance | luminance_to_perceived_lightness) / 100
}
def query_color [which: string]: nothing -> record<r: string, g: string, b: string> {
let osc = if $which == "bg" { "11" } else { "10" }
term query $'(ansi osc)($osc);?(ansi st)' --prefix $'(ansi osc)($osc);' --terminator (ansi st)
| decode
| parse 'rgb:{r}/{g}/{b}'
| get 0
}
def parse_xterm_rgb_color []: record<r: string, g: string, b: string> -> record<r: float, g: float, b: float> {
$in
| update r {parse_channel_scaled}
| update g {parse_channel_scaled}
| update b {parse_channel_scaled}
}
def get_bg_color []: nothing -> record<r: float, g: float, b: float> {
query_color "bg" | parse_xterm_rgb_color
}
def get_fg_color []: nothing -> record<r: float, g: float, b: float> {
query_color "fg" | parse_xterm_rgb_color
}
export def get_theme []: nothing -> string {
let fg = get_fg_color | perceived_lightness
let bg = get_bg_color | perceived_lightness
if $bg < $fg {
"dark"
} else if $bg > $fg or $bg > 0.5 {
"light"
} else {
"dark"
}
}
}
# Dark theme
use ~/code/nu_scripts/themes/nu-themes/catppuccin-mocha.nu
# Light theme
use ~/code/nu_scripts/themes/nu-themes/catppuccin-latte.nu
def --env _theme_pre_prompt [] {
use color get_theme
let current_theme = $env | get -i theme | default ""
let theme = get_theme
if $current_theme != $theme {
# Theme has changed
$env.theme = $theme
match $theme {
"dark" => {
catppuccin-mocha set color_config
$env.LS_COLORS = (vivid generate catppuccin-mocha)
}
"light" => {
catppuccin-latte set color_config
$env.LS_COLORS = (vivid generate catppuccin-latte)
}
}
}
}
$env.config = ($env | default {} config).config
$env.config = ($env.config | default {} hooks)
$env.config = (
$env.config | upsert hooks (
$env.config.hooks
| upsert pre_prompt ($env.config.hooks | get -i pre_prompt | default [] | append _theme_pre_prompt)
)
)
Some random thoughts:
- Despite managing to get the above working, it would be nice to have this natively supported by nushell
CSI-2031works like a subscription. When you enable it, you get notify when the system's theme changes. This means that "something" needs to listen to these notifications- nushell is either running a program, which would get the input, or waiting for input at the prompt, in which case it's probably
reedlinewhich would need to get support for this. - Pro: being able to redraw the current prompt when the system's theme changes, instead of having to wait for the next one.
- Con: isn't implemented by all terminal emulators (looking at you, wezterm...)
- Con: probably the most complex implementation?
- nushell is either running a program, which would get the input, or waiting for input at the prompt, in which case it's probably
CSI-996is similar toCSI-2031, but as a direct, one-shot query instead of a subscription model.- Pro: simpler to implement than
CSI-2031. You could just query the current appearance as a built-inpre_prompthook or similar. - Con: same as
CSI-2031. It's not supported everywhere.
- Pro: simpler to implement than
OSC-10/11: query the current foreground and background colors and determines whether this represents a "dark" or "light" theme (i.e. what my script above does). Similar model asCSI-996, i.e. needs to be done in apre_prompthook.- Pro: likely the most widely supported solution
- Con: more complexity needed to determine if the current theme is "dark" or "light", but crates like
terminal-colorsaurusexist. - Con: relies on the terminal emulator itself to properly handle dark/light-mode switching and setting the right foreground and background colors, and the nushell theme to not override those colors.
Of course, hybrid solutions are possible (e.g. try using CSI-996 and if it's not supported, fallback to OSC-11). IIRC, neovim recently added support for CSI-2031, with some logic to detect when it's not supported, but they treat each notification as the appearance changed, i.e. they ignore the actual value (dark or light) and re-detect whether the background is dark or light.
As for the configuration, I'm not sure what it would look like... maybe supporting $env.config.color_config could also be of the form { light: {...}, dark: {...}}, where each block has the same shape as the current color_config? Or maybe it could take a closure that takes a "dark"/"light" value and return a color_config record?
Even if switching the theme is handled natively, I think it would still be useful to have a theme_changed_hook or similar you could use to adjust things like LS_COLORS for instance.
I also managed to get it working with def --env, but there was one more issue, I had to change $env.config.hooks.pre_prompt = [{ switch_theme }] into $env.config.hooks.pre_execution = ([ switch_theme ]).
@abusch I agree with your analysis. I would add that another downside to CSI-996/OSC-10/11 is that they can potentially introduce latency for prompt redraw - if one has several pre_prompt hooks their latency will add up and possibly make the shell feel slow (though i did not experience this).
CSI-2031 is ideal from this point of view (when it is supported by terminal), as it does not add latency to every prompt and is most user friendly to set up.
My code for automatic theme switching with CSI 996:
use ($nu.default-config-dir | path join "rose-pine-moon.nu")
use ($nu.default-config-dir | path join "rose-pine-dawn.nu")
def --env switch_theme [] {
const dark_theme = 1
const light_theme = 2
let system_theme = term query "\e[?996n" --prefix "\e[?997;" --terminator "n" | decode | into int
if $system_theme == $dark_theme {
rose-pine-moon set color_config
} else if $system_theme == $light_theme {
rose-pine-dawn set color_config
} else {
let error_msg = "Unknown system theme returned from terminal: " + ($system_theme | into string)
error make {msg: $error_msg }
}
}
$env.config.hooks.pre_execution = ([ switch_theme ])
The default nushell theme is almost theme-agnostic. I agree that user-defined light & dark variants would be great, but in the meantime it's possible to have a theme that remains legible whatever the system theme & terminal background with:
# Background-agnostic theme:
$env.config.color_config = (
std config dark-theme | transpose key val | update val {|line|
if ($line.val | describe) == string {
$line.val | str replace "white" "default"
} else {
$line.val
}
} | transpose -rd
)
@fdncred If it's possible, I think it would be a good idea to modify the default theme so that it uses out of the box "default" instead of "white" (possibly adding this as "default-theme" to std config, besides dark & light-theme), because right now out-of-the-box nushell is a bit troublesome to use for users with light term backgrounds.
ya, i agree. this is why i added default as a color.