Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatic switching between light and dark theme (CSI 2031) #15227

Open
Dom324 opened this issue Mar 2, 2025 · 13 comments
Open

Automatic switching between light and dark theme (CSI 2031) #15227

Dom324 opened this issue Mar 2, 2025 · 13 comments
Labels
configuration Issue related to nu's configuration enhancement New feature or request needs-triage An issue that hasn't had any proper look

Comments

@Dom324
Copy link

Dom324 commented Mar 2, 2025

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_config to support light and dark theme, e.g.:
env.config.color_theme = "light"
env.config.color_theme_light = ...
env.config.color_theme_dark = ...

Describe alternatives you've considered

No response

Additional context and details

No response

@Dom324 Dom324 added enhancement New feature or request needs-triage An issue that hasn't had any proper look labels Mar 2, 2025
@fdncred
Copy link
Contributor

fdncred commented Mar 2, 2025

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.

@natemcintosh
Copy link

What are the two strings: "\e[?996n" and "\e[?997;"?

@fdncred
Copy link
Contributor

fdncred commented Mar 3, 2025

They're from the linked urls above. It explains how it works.

@Dom324
Copy link
Author

Dom324 commented Mar 3, 2025

@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?

@fdncred
Copy link
Contributor

fdncred commented Mar 3, 2025

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.

@Dom324
Copy link
Author

Dom324 commented Mar 3, 2025

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).

@fdncred
Copy link
Contributor

fdncred commented Mar 3, 2025

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?

@natemcintosh
Copy link

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.

@Dom324
Copy link
Author

Dom324 commented Mar 4, 2025

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.

@sholderbach sholderbach added the configuration Issue related to nu's configuration label Mar 4, 2025
@fdncred
Copy link
Contributor

fdncred commented Mar 4, 2025

Yup, that's right. The terminal has to support those ansi escape sequences, otherwise it will wait until you hit ctrl+c.

@abusch
Copy link
Contributor

abusch commented Mar 5, 2025

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)
  )
)

@abusch
Copy link
Contributor

abusch commented Mar 5, 2025

Some random thoughts:

  • Despite managing to get the above working, it would be nice to have this natively supported by nushell
  • CSI-2031 works 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 reedline which 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?
  • CSI-996 is similar to CSI-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-in pre_prompt hook or similar.
    • Con: same as CSI-2031. It's not supported everywhere.
  • 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 as CSI-996, i.e. needs to be done in a pre_prompt hook.
    • 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-colorsaurus exist.
    • 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.

@Dom324
Copy link
Author

Dom324 commented Mar 6, 2025

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 ])

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
configuration Issue related to nu's configuration enhancement New feature or request needs-triage An issue that hasn't had any proper look
Projects
None yet
Development

No branches or pull requests

5 participants