Merge pull request #7053 from esengine/fix/cli-colour-profile

Fix true-colour detection on Windows Terminal, unify colour capability
This commit is contained in:
YHH authored and GitHub committed 2026-07-30 11:49:25 +08:00
commit fc42c619d8
22 files changed
+446 -221

No files matched your search

+1 -1
View File
@@ -14,6 +14,7 @@ require (
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/joho/godotenv v1.5.1
github.com/kevinburke/ssh_config v1.6.0
@@ -44,7 +45,6 @@ require (
)
require (
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
+5 -3
View File
@@ -3,12 +3,14 @@ package cli
import (
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
)
func TestRenderBranchTreeStylesVisualWeight(t *testing.T) {
oldColor := colorEnabled
colorEnabled = true
defer func() { colorEnabled = oldColor }()
oldColor := activeColorProfile
activeColorProfile = colorprofile.ANSI256
defer func() { activeColorProfile = oldColor }()
got := renderBranchTree("branches:\n├─ 0601-030143.318 你是谁 3 turns\n│ └─ 0601-033937.165 JSON response: success 1 turn\n└─ 0601-035153.346 JSON array 1 turn current")
for _, want := range []string{
+34 -21
View File
@@ -51,6 +51,8 @@ type chatTUI struct {
width int
height int
// themeSweep freezes the frame while a /theme switch wipes across it.
themeSweep *themeSweep
// nativeScrollback keeps Termux out of alt-screen mode so taps still focus
// the textarea and raise the soft keyboard.
nativeScrollback bool
@@ -1722,6 +1724,15 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.copyNoticeText = ""
}
case themeSweepTickMsg:
if m.themeSweep != nil {
if m.themeSweep.advance() {
cmds = append(cmds, themeSweepTick())
} else {
m.themeSweep = nil
}
}
case elapsedTickMsg:
if m.state == tuiRunning {
m.elapsed = int(time.Since(m.runStart).Seconds())
@@ -2805,6 +2816,18 @@ func (m chatTUI) runningWorkingLine(cancelRequested, styled bool) string {
}
func (m chatTUI) View() tea.View {
if m.themeSweep != nil {
v := tea.NewView(m.themeSweep.render())
if !m.nativeScrollback {
v.AltScreen = true
if m.mouseCaptureOff {
v.MouseMode = tea.MouseModeNone
} else {
v.MouseMode = tea.MouseModeCellMotion
}
}
return v
}
boxW := m.width
if boxW < 10 {
boxW = 10
@@ -2816,36 +2839,26 @@ func (m chatTUI) View() tea.View {
if !hideComposer {
style := inputBoxStyle.Width(boxW)
if shellMode {
style = style.BorderForeground(lipgloss.Color(statusShellColor.hex))
style = withThemeBorderFG(style, statusShellColor)
}
box = style.Render(m.renderComposerInput())
}
var modeTag string
if shellMode {
modeTag = lipgloss.NewStyle().
Background(lipgloss.Color(statusShellColor.hex)).
Foreground(lipgloss.Color("#ffffff")).
Bold(true).
Padding(0, 1).
Render("Shell")
modeTag = modeTagStyle(statusShellColor, modeTagLight).Render("Shell")
} else {
color := statusAutoColor
foreground := "#111827"
background := statusAutoColor
foreground := modeTagDark
switch {
case m.ctrl.AutoApproveTools():
color = statusYoloColor
foreground = "#ffffff"
background = statusYoloColor
foreground = modeTagLight
case m.planMode:
color = statusPlanColor
foreground = "#ffffff"
background = statusPlanColor
foreground = modeTagLight
}
modeTag = lipgloss.NewStyle().
Background(lipgloss.Color(color.hex)).
Foreground(lipgloss.Color(foreground)).
Bold(true).
Padding(0, 1).
Render(m.modeTagText())
modeTag = modeTagStyle(background, foreground).Render(m.modeTagText())
}
primaryStatus := m.primaryStatusLine(modeTag, shellMode, cancelRequested)
@@ -4059,7 +4072,7 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
}
case "/theme":
m.echoLocalCommand(input)
m.runThemeSubcommand(input)
return m.runThemeSubcommand(input)
case "/language":
m.echoLocalCommand(input)
return m.runLanguageSubcommand(input)
@@ -4629,7 +4642,7 @@ func renderUserBubble(line string, width int, planMode bool) string {
if planMode {
prefix = "› [plan] "
}
if !colorEnabled {
if !colorOn() {
return "│ " + prefix + line
}
return " " + accent(prefix+line)
+5 -3
View File
@@ -10,6 +10,8 @@ import (
"reflect"
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
"time"
tea "charm.land/bubbletea/v2"
@@ -1325,9 +1327,9 @@ func TestUserBubbleEchoedImmediately(t *testing.T) {
}
func TestUserBubbleIsLightweightTranscriptLine(t *testing.T) {
prevColor := colorEnabled
colorEnabled = true
defer func() { colorEnabled = prevColor }()
prevColor := activeColorProfile
activeColorProfile = colorprofile.ANSI256
defer func() { activeColorProfile = prevColor }()
got := renderUserBubble("hello world", 80, false)
plain := ansi.Strip(got)
+16 -20
View File
@@ -107,54 +107,54 @@ func Run(args []string, version string) int {
configureCLIThemeFromConfigForTTYOutput()
return setupConfig(rest)
case "config":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return configCommand(rest)
case "init":
// Project memory (AGENTS.md) is model-generated in-session — `/init` runs
// the codebase analysis. This CLI entry just points there (and to `setup`
// for config), so `reasonix init` isn't a dead end.
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return initHint()
case "acp":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return acpCommand(rest, version)
case "mcp":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return mcpCommand(rest)
case "remote":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return remoteCommand(rest, version)
case "plugin":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return pluginCommand(rest)
case "subagent":
configureCLIThemeFromConfigForTTYOutput()
return subagentCommand(rest)
case "doctor":
if !doctorRepair {
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
}
return doctorCommand(rest, version)
case "report":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return reportCommand(rest)
case "session":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return sessionCommand(rest)
case "hook", "hooks":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return hookCommand(rest)
case "task":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return taskCommand(rest)
case "review":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return reviewCommand(rest)
case "bot":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return botCommand(rest, version)
case "upgrade", "update":
configureCLIThemeFromConfigNoProbe()
configureCLIThemeFromConfig()
return upgradeCommand(rest, version)
case "version", "--version", "-v":
fmt.Println("reasonix", version)
@@ -222,14 +222,10 @@ func configureCLIThemeFromConfig() {
func configureCLIThemeFromConfigForTTYOutput() {
if isTTY(os.Stdout) {
configureCLIThemeFromConfig()
withTerminalProbe(configureCLIThemeFromConfig)
return
}
configureCLIThemeFromConfigNoProbe()
}
func configureCLIThemeFromConfigNoProbe() {
withoutTerminalProbe(configureCLIThemeFromConfig)
configureCLIThemeFromConfig()
}
// setupProfile builds a ready-to-drive Controller from config via boot.Build.
+2 -4
View File
@@ -264,10 +264,8 @@ func hasPluginNamed(cfg *config.Config, name string) bool {
}
func TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {
defer func(prev func() (terminalRGB, bool)) {
queryTerminalBackgroundForTheme = prev
}(queryTerminalBackgroundForTheme)
queryTerminalBackgroundForTheme = func() (terminalRGB, bool) {
defer func(prev func() (terminalRGB, bool)) { terminalProbe = prev }(terminalProbe)
terminalProbe = func() (terminalRGB, bool) {
t.Fatal("metadata command should not query terminal background")
return terminalRGB{}, false
}
+2 -2
View File
@@ -139,7 +139,7 @@ func diffBar(sign byte, code, path string, width int, bg, signFg string, lineNo,
barW = 4
}
code = clampPlain(code, barW-2)
if !colorEnabled {
if !colorOn() {
return " " + gutter + " " + string(sign) + " " + code
}
hl := reapplyBG(highlightCode(path, code), bg)
@@ -196,7 +196,7 @@ func atoi(s string) int {
func highlightClamped(code, path string, w int) string {
c := clampPlain(code, w)
if !colorEnabled {
if !colorOn() {
return c
}
return highlightCode(path, c)
+4 -2
View File
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
"reasonix/internal/event"
)
@@ -67,8 +69,8 @@ func TestDiffPath(t *testing.T) {
}
func TestDiffBarReappliesBackground(t *testing.T) {
defer func(prev bool) { colorEnabled = prev }(colorEnabled)
colorEnabled = true
defer func(prev colorprofile.Profile) { activeColorProfile = prev }(activeColorProfile)
activeColorProfile = colorprofile.ANSI256
line := diffBar('+', "a + b", "x.go", 40, bgDiffAdd, fgDiffAdd, 12, 3)
// Syntax highlighting emits multiple \033[0m resets; each must re-arm the bar
+3 -1
View File
@@ -129,7 +129,9 @@ var (
statusAutoColor = cliColor{"#f59e0b", 214}
statusPlanColor = cliColor{"#2563eb", 27}
statusYoloColor = cliColor{"#e5484d", 167}
statusShellColor = cliColor{"#16a34a", 71} // green — shell mode indicator
statusShellColor = cliColor{"#16a34a", 71}
modeTagLight = cliColor{"#ffffff", 231}
modeTagDark = cliColor{"#111827", 234}
)
func (m chatTUI) statusModeColor() cliColor {
+3 -1
View File
@@ -3,6 +3,8 @@ package cli
import (
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
)
func TestLatexToUnicode(t *testing.T) {
@@ -53,7 +55,7 @@ func TestNormalizeMath(t *testing.T) {
}
func TestRenderInlineMath(t *testing.T) {
colorEnabled = false
activeColorProfile = colorprofile.NoTTY
r := newMarkdownRenderer(80)
out := r.Render(`The mass-energy relation is $E = mc^2$ exactly.`)
+5 -3
View File
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
"reasonix/internal/event"
@@ -15,7 +17,7 @@ import (
// actually streams tokens. It proves a half-written $...$ / $$...$$ is never
// flushed as raw LaTeX and the committed transcript shows finished Unicode.
func TestMathStreamingEndToEnd(t *testing.T) {
colorEnabled = false
activeColorProfile = colorprofile.NoTTY
m := newTestChatTUI()
chunks := []string{
@@ -52,8 +54,8 @@ func TestMathStreamingEndToEnd(t *testing.T) {
// TestMathStreamingStyledPath repeats the commit with colour on so the real
// ANSI-styled output is exercised, then asserts the Unicode survives a strip.
func TestMathStreamingStyledPath(t *testing.T) {
colorEnabled = true
defer func() { colorEnabled = false }()
activeColorProfile = colorprofile.ANSI256
defer func() { activeColorProfile = colorprofile.NoTTY }()
m := newTestChatTUI()
m.ingestEvent(event.Event{Kind: event.Text, Text: `The relation $a^2 + b^2 = c^2$ holds.`})
+1 -1
View File
@@ -44,7 +44,7 @@ func newMarkdownRenderer(width int) *mdRenderer {
}
func italic(s string) string {
if !colorEnabled {
if !colorOn() {
return s
}
return "\033[3m" + s + "\033[0m"
+6 -4
View File
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
"reasonix/internal/event"
@@ -13,8 +15,8 @@ import (
// and a bar that runs exactly to width — a tab has zero StringWidth but the
// terminal expands it, which would otherwise overflow the background bar.
func TestDiffTabExpansion(t *testing.T) {
defer func(prev bool) { colorEnabled = prev }(colorEnabled)
colorEnabled = true
defer func(prev colorprofile.Profile) { activeColorProfile = prev }(activeColorProfile)
activeColorProfile = colorprofile.ANSI256
width := 40
d := event.FileDiff{Diff: "@@ -1 +1 @@\n+\t\tresult := compute()\n", Added: 1}
for _, r := range diffBody(d, "x.go", width, 40) {
@@ -29,8 +31,8 @@ func TestDiffTabExpansion(t *testing.T) {
// TestRenderNarrowNoPanic guards the width math against tiny terminals.
func TestRenderNarrowNoPanic(t *testing.T) {
defer func(prev bool) { colorEnabled = prev }(colorEnabled)
colorEnabled = true
defer func(prev colorprofile.Profile) { activeColorProfile = prev }(activeColorProfile)
activeColorProfile = colorprofile.ANSI256
d := event.FileDiff{Diff: "@@ -1 +1 @@\n-\told 你好\n+\tnew 世界\n", Added: 1, Removed: 1}
for _, w := range []int{1, 2, 3, 5, 8, 20} {
_ = diffBody(d, "x.go", w, 40)
+24 -32
View File
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
"reasonix/internal/control"
@@ -13,9 +15,9 @@ import (
)
func TestTurnReceiptKeepsCompletePerTurnBreakdown(t *testing.T) {
defer restoreThemeForTest(colorEnabled, activeCLITheme)
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
defer i18n.DetectLanguage("en")
colorEnabled = false
activeColorProfile = colorprofile.NoTTY
configureCLITheme("dark")
i18n.DetectLanguage("zh")
@@ -43,9 +45,9 @@ func TestTurnReceiptKeepsCompletePerTurnBreakdown(t *testing.T) {
}
func TestTurnReceiptFallsBackToDerivedFreshTokensAndWrapsCleanly(t *testing.T) {
defer restoreThemeForTest(colorEnabled, activeCLITheme)
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
defer i18n.DetectLanguage("en")
colorEnabled = true
activeColorProfile = colorprofile.ANSI256
configureCLITheme("dark")
i18n.DetectLanguage("en")
@@ -75,8 +77,8 @@ func TestTurnReceiptIgnoresEmptyUsage(t *testing.T) {
}
func TestTurnReceiptBandUsesSingleQuietBoundary(t *testing.T) {
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = false
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.NoTTY
configureCLITheme("dark")
band := renderTurnReceiptBand(" TURN 14.0K tok · in 13.6K", 48)
@@ -96,11 +98,9 @@ func TestTurnReceiptBandUsesSingleQuietBoundary(t *testing.T) {
}
func TestTurnReceiptAdaptsContrastAcrossThemes(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
defer i18n.DetectLanguage("en")
colorEnabled = true
activeColorProfile = colorprofile.ANSI256
i18n.DetectLanguage("en")
for _, tt := range []struct {
@@ -128,12 +128,10 @@ func TestTurnReceiptAdaptsContrastAcrossThemes(t *testing.T) {
}
func TestStatusFooterSemanticPaletteAcrossThemes(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
t.Setenv("REASONIX_THEME", "")
t.Setenv("REASONIX_THEME_STYLE", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
for _, tt := range []struct {
mode, labelSGR, valueSGR, infoSGR, secondarySGR string
@@ -170,9 +168,7 @@ func TestStatusFooterSemanticPaletteAcrossThemes(t *testing.T) {
}
func TestStatusFooterThemesKeepIdenticalGeometry(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
m := newTestChatTUI()
m.ctrl = control.New(control.Options{})
@@ -182,25 +178,23 @@ func TestStatusFooterThemesKeepIdenticalGeometry(t *testing.T) {
m.balance = "¥12.34"
m.gitStatus = gitStatus{Repo: "DeepSeek-Reasonix", Branch: "feature/theme-footer", Added: 3}
render := func(mode string, colors bool) string {
colorEnabled = colors
render := func(mode string, profile colorprofile.Profile) string {
activeColorProfile = profile
configureCLITheme(mode)
primary := m.primaryStatusLine(" Auto ", false, false)
return ansi.Strip(m.renderStatusBlock(primary, 132))
}
dark := render("dark", true)
light := render("light", true)
plain := render("dark", false)
dark := render("dark", colorprofile.ANSI256)
light := render("light", colorprofile.ANSI256)
plain := render("dark", colorprofile.NoTTY)
if dark != light || dark != plain {
t.Fatalf("theme modes changed footer geometry:\ndark:\n%s\nlight:\n%s\nplain:\n%s", dark, light, plain)
}
}
func TestStatusFooterGitAndDividerAdaptToTheme(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
for _, tt := range []struct {
mode, gitSGR, borderSGR string
@@ -225,10 +219,8 @@ func TestStatusFooterGitAndDividerAdaptToTheme(t *testing.T) {
}
func TestContextFooterColorsOnlyValuesByUrgency(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
configureCLITheme("dark")
normal := strings.Join(renderContextStatusGroups(10, 100, .8), " ")
@@ -248,8 +240,8 @@ func TestContextFooterColorsOnlyValuesByUrgency(t *testing.T) {
}
func TestStatusFooterNoColorKeepsSemanticLabels(t *testing.T) {
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = false
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.NoTTY
configureCLITheme("dark")
m := newTestChatTUI()
+10 -4
View File
@@ -6,6 +6,8 @@ import (
"runtime"
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
"time"
tea "charm.land/bubbletea/v2"
@@ -115,6 +117,8 @@ func statuslineCommandHasModel(cmd tea.Cmd, model string) bool {
}
func TestIdleStatuslineIsCompact(t *testing.T) {
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.TrueColor
i18n.DetectLanguage("en")
content := renderStatuslineView(t, false)
@@ -139,6 +143,8 @@ func TestIdleStatuslineIsCompact(t *testing.T) {
}
func TestYoloStatuslineUsesDangerPill(t *testing.T) {
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.TrueColor
i18n.DetectLanguage("en")
content := renderStatuslineView(t, true)
@@ -155,6 +161,8 @@ func TestYoloStatuslineUsesDangerPill(t *testing.T) {
}
func TestPlanStatuslineUsesBluePill(t *testing.T) {
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.TrueColor
i18n.DetectLanguage("en")
content := renderPlanStatuslineView(t)
@@ -257,10 +265,8 @@ func TestStatuslineShowsWorkModeAndBalanceInPersistentFooter(t *testing.T) {
func TestEffortTagExplicitValueUsesThemeInfo(t *testing.T) {
i18n.DetectLanguage("en")
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
for _, tt := range []struct {
mode, infoSGR string
+15 -24
View File
@@ -3,43 +3,34 @@ package cli
import (
"os"
"golang.org/x/term"
"github.com/charmbracelet/colorprofile"
)
// colorEnabled is decided once at startup: only colorize when writing to a real
// terminal and the user hasn't opted out via NO_COLOR (https://no-color.org) or
// a dumb TERM. Piped/redirected output and CI stay plain so scripts aren't broken.
var colorEnabled = detectColor()
// activeColorProfile is the single description of what the terminal can render.
// colorprofile owns the NO_COLOR, CLICOLOR_FORCE, TERM=dumb and isatty rules,
// so piped output stays plain. Colour fidelity is derived from it, never probed
// a second time.
var activeColorProfile = colorprofile.Detect(os.Stdout, os.Environ())
func detectColor() bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
if os.Getenv("TERM") == "dumb" {
return false
}
return term.IsTerminal(int(os.Stdout.Fd()))
func colorOn() bool {
return activeColorProfile > colorprofile.ASCII
}
func trueColorTerminal() bool {
return activeColorProfile == colorprofile.TrueColor
}
const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"
ansiDim = "\033[2m"
ansiGreen = "\033[32m"
ansiRed = "\033[31m"
ansiYellow = "\033[33m"
ansiBlue = "\033[38;5;39m"
ansiCyan = "\033[38;5;44m"
ansiMagenta = "\033[38;5;176m"
ansiReverse = "\033[7m"
// ansiAccent is the dark theme fallback for Reasonix's warm copper brand
// colour. accent() uses the active CLI theme, but tests and legacy callers can
// still refer to this concrete escape sequence.
// ansiAccent is the dark graphite accent as a literal escape, for tests that
// pin the concrete sequence instead of the active theme.
ansiAccent = "\033[38;5;173m"
)
func sgr(code, s string) string {
if !colorEnabled {
if !colorOn() {
return s
}
return code + s + ansiReset
+45 -44
View File
@@ -17,7 +17,10 @@ import (
)
type cliColor struct {
hex string
hex string
// Distance-based downsampling collapses the dark, low-chroma diff backgrounds
// to plain grey and loses the red/green tint that carries their meaning, so
// the 256-colour fallback stays hand-chosen rather than computed.
xterm int
}
@@ -103,10 +106,15 @@ var (
{name: "linen", mode: "light", accent: cliColor{"#bd5d4d", 167}, description: "muted coral light accent"},
{name: "glacier", mode: "light", accent: cliColor{"#357fa8", 74}, description: "cool blue light accent"},
}
activeCLITheme = applyCLIThemeStyle(cliDarkTheme, cliThemeStyles[0])
queryTerminalBackgroundForTheme = queryTerminalBackground
activeCLITheme = applyCLIThemeStyle(cliDarkTheme, cliThemeStyles[0])
// activeBackgroundProbe stays inert unless a caller that owns stdin opts in
// through withTerminalProbe; terminalProbe is what opting in installs.
activeBackgroundProbe = noTerminalBackground
terminalProbe = queryTerminalBackground
)
func noTerminalBackground() (terminalRGB, bool) { return terminalRGB{}, false }
// cliCursorShape is the active cursor shape for the textarea input, configured
// via [ui] cursor_shape. Defaults to the slim bar used by the chat composer.
var cliCursorShape = "bar"
@@ -155,7 +163,7 @@ func resolveCLIThemeMode(mode string) string {
case "dark":
return "dark"
case "auto", "":
if rgb, ok := queryTerminalBackgroundForTheme(); ok {
if rgb, ok := activeBackgroundProbe(); ok {
if rgb.looksLight() {
return "light"
}
@@ -210,23 +218,18 @@ func defaultCLIThemeStyle(mode string) cliThemeStyle {
return cliThemeStyles[0]
}
// withoutTerminalProbe resolves a theme with the OSC background probe disabled —
// for callers running while something else (the live TUI) owns stdin, where a
// raw-mode read would fight the TUI's input reader. "auto" then falls back to the
// COLORFGBG heuristic.
func withoutTerminalProbe(fn func()) {
prev := queryTerminalBackgroundForTheme
queryTerminalBackgroundForTheme = func() (terminalRGB, bool) { return terminalRGB{}, false }
defer func() { queryTerminalBackgroundForTheme = prev }()
// withTerminalProbe resolves "auto" against a live OSC 11 query. Probing reads
// stdin in raw mode, so only a caller that owns stdin may opt in; everyone else
// gets the COLORFGBG fallback and never fights the TUI's input reader.
func withTerminalProbe(fn func()) {
prev := activeBackgroundProbe
activeBackgroundProbe = terminalProbe
defer func() { activeBackgroundProbe = prev }()
fn()
}
func setCLIThemeMode(mode string) cliPalette {
// A runtime /theme switch runs inside the TUI, which owns stdin, so resolving
// "auto" must not live-probe the terminal here.
withoutTerminalProbe(func() {
activeCLITheme = resolveCLIThemeWithStyle(mode, activeCLITheme.style)
})
activeCLITheme = resolveCLIThemeWithStyle(mode, activeCLITheme.style)
refreshCLIStyles()
return activeCLITheme
}
@@ -313,9 +316,8 @@ func colorFGBGLooksLight() bool {
}
func fgSGR(c cliColor) string {
if supportsTrueColor() && c.hex != "" {
r, g, b, ok := parseHexColor(c.hex)
if ok {
if trueColorTerminal() {
if r, g, b, ok := parseHexColor(c.hex); ok {
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
}
}
@@ -323,9 +325,8 @@ func fgSGR(c cliColor) string {
}
func bgSGR(c cliColor) string {
if supportsTrueColor() && c.hex != "" {
r, g, b, ok := parseHexColor(c.hex)
if ok {
if trueColorTerminal() {
if r, g, b, ok := parseHexColor(c.hex); ok {
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
}
}
@@ -343,44 +344,42 @@ func parseHexColor(hex string) (int, int, int, bool) {
return int(r), int(g), int(b), errR == nil && errG == nil && errB == nil
}
func supportsTrueColor() bool {
ct := strings.ToLower(os.Getenv("COLORTERM"))
if strings.Contains(ct, "truecolor") || strings.Contains(ct, "24bit") {
return true
}
switch os.Getenv("TERM_PROGRAM") {
case "iTerm.app", "WezTerm", "vscode":
return true
default:
return false
}
}
func themeFg(c cliColor, s string) string {
return sgr(fgSGR(c), s)
}
// themeLipColor pre-resolves the fallback rather than handing lipgloss a 24-bit
// value: the bubbletea renderer would otherwise downsample it with the same
// distance metric the hand-chosen xterm indices exist to avoid.
func themeLipColor(c cliColor) color.Color {
if supportsTrueColor() && c.hex != "" {
if trueColorTerminal() {
return lipgloss.Color(c.hex)
}
return lipgloss.Color(strconv.Itoa(c.xterm))
}
func themeStyle(c cliColor) lipgloss.Style {
if !colorEnabled {
if !colorOn() {
return lipgloss.NewStyle()
}
return lipgloss.NewStyle().Foreground(themeLipColor(c))
}
func withThemeBorderFG(st lipgloss.Style, c cliColor) lipgloss.Style {
if !colorEnabled {
if !colorOn() {
return st
}
return st.BorderForeground(themeLipColor(c))
}
func modeTagStyle(background, foreground cliColor) lipgloss.Style {
st := lipgloss.NewStyle().Bold(true).Padding(0, 1)
if !colorOn() {
return st
}
return st.Background(themeLipColor(background)).Foreground(themeLipColor(foreground))
}
func init() {
refreshCLIStyles()
}
@@ -405,7 +404,7 @@ func refreshCLIStyles() {
func applyTextareaTheme(ti *textarea.Model) {
plain := lipgloss.NewStyle()
weak := themeStyle(activeCLITheme.faint)
if !colorEnabled {
if !colorOn() {
weak = plain
}
@@ -430,7 +429,7 @@ func applyTextareaTheme(ti *textarea.Model) {
Placeholder: weak,
Prompt: weak,
}
if colorEnabled {
if colorOn() {
styles.Cursor.Color = themeLipColor(activeCLITheme.accent)
} else {
styles.Cursor.Color = nil
@@ -446,13 +445,14 @@ func applyTextareaTheme(ti *textarea.Model) {
ti.SetStyles(styles)
}
func (m *chatTUI) runThemeSubcommand(input string) {
func (m *chatTUI) runThemeSubcommand(input string) tea.Cmd {
args := tokenizeArgs(input)
if len(args) < 2 {
m.notice(i18n.M.ThemeHeader + "\n" + describeCLIThemes() + "\n" + i18n.M.ThemeHint)
return
return nil
}
name := strings.ToLower(args[1])
previous := activeCLITheme
var theme cliPalette
switch name {
case "auto", "light", "dark":
@@ -461,7 +461,7 @@ func (m *chatTUI) runThemeSubcommand(input string) {
next, ok := setCLIThemeStyle(name)
if !ok {
m.notice(fmt.Sprintf(i18n.M.ThemeUnknownFmt, name) + "\n" + describeCLIThemes())
return
return nil
}
theme = next
}
@@ -470,6 +470,7 @@ func (m *chatTUI) runThemeSubcommand(input string) {
// Persist to user config so the choice survives restart.
m.persistTheme(name)
return m.startThemeSweep(previous, theme)
}
func (m *chatTUI) persistTheme(inputName string) {
+1 -1
View File
@@ -17,7 +17,7 @@ const (
)
func queryTerminalBackground() (terminalRGB, bool) {
if !colorEnabled {
if !colorOn() {
return terminalRGB{}, false
}
inFd := int(os.Stdin.Fd())
+107
View File
@@ -0,0 +1,107 @@
package cli
import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
const (
themeSweepInterval = 16 * time.Millisecond
themeSweepFrames = 18
themeSweepMinWidth = 24
)
// themeSweep wipes the incoming palette across the frame from left to right.
// Both frames are rendered once up front: the transcript is frozen for the
// duration, so each step is column slicing only.
type themeSweep struct {
before []string
after []string
col int
step int
width int
}
type themeSweepTickMsg struct{}
func themeSweepTick() tea.Cmd {
return tea.Tick(themeSweepInterval, func(time.Time) tea.Msg { return themeSweepTickMsg{} })
}
// startThemeSweep returns nil when the terminal or the moment cannot carry the
// animation, and the caller keeps the plain instant switch.
func (m *chatTUI) startThemeSweep(from, to cliPalette) tea.Cmd {
if !colorOn() || m.width < themeSweepMinWidth || m.state == tuiRunning {
return nil
}
if from.name == to.name && from.style == to.style {
return nil
}
before := strings.Split(m.frameWithTheme(from), "\n")
after := strings.Split(m.frameWithTheme(to), "\n")
if len(before) != len(after) {
return nil
}
step := max(m.width/themeSweepFrames, 1)
m.themeSweep = &themeSweep{
before: before,
after: after,
col: step,
step: step,
width: m.width,
}
return themeSweepTick()
}
// frameWithTheme renders the whole view under a palette other than the active
// one. The value receiver keeps the swapped-in sweep state local to this call.
func (m chatTUI) frameWithTheme(p cliPalette) string {
m.themeSweep = nil
prev := activeCLITheme
activeCLITheme = p
refreshCLIStyles()
defer func() {
activeCLITheme = prev
refreshCLIStyles()
}()
return m.View().Content
}
func (s *themeSweep) advance() bool {
s.col += s.step
return s.col < s.width
}
func (s *themeSweep) render() string {
rows := make([]string, len(s.after))
for i := range s.after {
rows[i] = s.composeRow(s.after[i], s.before[i])
}
return strings.Join(rows, "\n")
}
// composeRow builds the row to an exact cell count. Slicing on an odd column
// cannot split a double-width rune, so both sides are re-clamped and padded;
// otherwise a CJK transcript pushes the boundary out of vertical alignment.
func (s *themeSweep) composeRow(after, before string) string {
lead := min(max(s.col, 0), s.width)
row := padCells(ansi.Truncate(after, lead, ""), lead)
if lead == s.width {
return row
}
tailWidth := s.width - lead
tail := ansi.Truncate(ansi.TruncateLeft(before, lead, ""), tailWidth, "")
return row + padCells(tail, tailWidth)
}
// padCells restores the exact column count after a truncation that landed on a
// double-width cell.
func padCells(s string, w int) string {
if d := w - visibleWidth(s); d > 0 {
return s + strings.Repeat(" ", d)
}
return s
}
+86
View File
@@ -0,0 +1,86 @@
package cli
import (
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
)
func testSweepRows(width int) (before, after []string) {
// mix wide runes, pre-styled text and plain text
for range 4 {
before = append(before, strings.Repeat("宽", width/2), strings.Repeat("a", width),
themeFg(activeCLITheme.warn, strings.Repeat("s", width)))
after = append(after, strings.Repeat("窄", width/2), strings.Repeat("b", width),
themeFg(activeCLITheme.info, strings.Repeat("t", width)))
}
return before, after
}
func TestThemeSweepHoldsExactRowWidth(t *testing.T) {
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
configureCLIThemeWithStyle("dark", "graphite")
for _, profile := range []colorprofile.Profile{colorprofile.ANSI256, colorprofile.TrueColor} {
activeColorProfile = profile
const width = 40
before, after := testSweepRows(width)
s := &themeSweep{before: before, after: after, step: 3, width: width}
for s.col = 0; s.col <= width; s.col++ {
for i, row := range strings.Split(s.render(), "\n") {
if got := visibleWidth(row); got != width {
t.Fatalf("%v col=%d row=%d width=%d, want %d: %q", profile, s.col, i, got, width, row)
}
}
}
}
}
func TestThemeSweepAdvanceTerminates(t *testing.T) {
s := &themeSweep{before: []string{"a"}, after: []string{"b"}, step: 3, width: 80}
steps := 0
for s.advance() {
steps++
if steps > themeSweepFrames*4 {
t.Fatal("sweep never reached the right edge")
}
}
if s.col < s.width {
t.Fatalf("sweep stopped at col %d, want >= %d", s.col, s.width)
}
}
func TestThemeSweepSkippedWhenTerminalCannotCarryIt(t *testing.T) {
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
configureCLIThemeWithStyle("dark", "graphite")
dark := activeCLITheme
light := resolveCLIThemeWithStyle("light", "sandstone")
for _, tt := range []struct {
name string
profile colorprofile.Profile
width int
state tuiState
from cliPalette
to cliPalette
}{
{name: "no colour", profile: colorprofile.NoTTY, width: 80, from: dark, to: light},
{name: "too narrow", profile: colorprofile.ANSI256, width: 8, from: dark, to: light},
{name: "turn running", profile: colorprofile.ANSI256, width: 80, state: tuiRunning, from: dark, to: light},
{name: "same theme", profile: colorprofile.ANSI256, width: 80, from: dark, to: dark},
} {
t.Run(tt.name, func(t *testing.T) {
activeColorProfile = tt.profile
m := newTestChatTUI()
m.width = tt.width
m.state = tt.state
if cmd := m.startThemeSweep(tt.from, tt.to); cmd != nil {
t.Fatal("sweep should be skipped, switch must stay instant")
}
if m.themeSweep != nil {
t.Fatal("skipped sweep must not freeze the frame")
}
})
}
}
+63 -44
View File
@@ -5,18 +5,20 @@ import (
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
"reasonix/internal/control"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
func TestConfigureCLIThemeSwitchesModeAndDefaultStyle(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
t.Setenv("REASONIX_THEME", "")
t.Setenv("REASONIX_THEME_STYLE", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
configureCLITheme("light")
if activeCLITheme.name != "light" || activeCLITheme.style != "sandstone" {
@@ -36,12 +38,10 @@ func TestConfigureCLIThemeSwitchesModeAndDefaultStyle(t *testing.T) {
}
func TestConfigureCLIThemeStyleOverride(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
t.Setenv("REASONIX_THEME", "")
t.Setenv("REASONIX_THEME_STYLE", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
configureCLIThemeWithStyle("dark", "aurora")
if activeCLITheme.name != "dark" || activeCLITheme.style != "aurora" {
@@ -58,12 +58,10 @@ func TestConfigureCLIThemeStyleOverride(t *testing.T) {
}
func TestConfigureCLIThemeHonorsEnvOverride(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
t.Setenv("REASONIX_THEME", "ember")
t.Setenv("REASONIX_THEME_STYLE", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
configureCLIThemeWithStyle("light", "glacier")
if activeCLITheme.name != "dark" || activeCLITheme.style != "ember" {
@@ -71,9 +69,29 @@ func TestConfigureCLIThemeHonorsEnvOverride(t *testing.T) {
}
}
func TestThemeRendersAtProfileFidelity(t *testing.T) {
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
configureCLIThemeWithStyle("dark", "graphite")
activeColorProfile = colorprofile.TrueColor
if got := accent("x"); !strings.HasPrefix(got, "\033[38;2;217;119;87m") {
t.Fatalf("truecolor accent = %q, want 24-bit #d97757", got)
}
activeColorProfile = colorprofile.ANSI256
if got := accent("x"); !strings.HasPrefix(got, ansiAccent) {
t.Fatalf("256-colour accent = %q, want %q", got, ansiAccent)
}
activeColorProfile = colorprofile.NoTTY
if got := accent("x"); got != "x" {
t.Fatalf("no-tty accent = %q, want unstyled text", got)
}
}
func TestThemeArgCompletion(t *testing.T) {
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
configureCLIThemeWithStyle("dark", "graphite")
m := newTestChatTUI()
@@ -87,16 +105,17 @@ func TestThemeArgCompletion(t *testing.T) {
}
func TestRunThemeSubcommandSwitchesAccentAndTextarea(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
t.Setenv("REASONIX_THEME", "")
t.Setenv("REASONIX_THEME_STYLE", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
configureCLIThemeWithStyle("dark", "graphite")
m := newTestChatTUI()
m.runThemeSubcommand("/theme aurora")
m.ctrl = control.New(control.Options{})
if cmd := m.runThemeSubcommand("/theme aurora"); cmd == nil {
t.Fatal("a real theme change should start the sweep")
}
if activeCLITheme.name != "dark" || activeCLITheme.style != "aurora" {
t.Fatalf("current theme = %s/%s, want dark/aurora", activeCLITheme.name, activeCLITheme.style)
}
@@ -150,8 +169,8 @@ func TestParseOSC11Response(t *testing.T) {
}
func TestAutoThemeFallsBackToColorFGBG(t *testing.T) {
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = false
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.NoTTY
t.Setenv("COLORFGBG", "0;15")
if got := resolveCLITheme("auto").name; got != "light" {
@@ -165,12 +184,10 @@ func TestAutoThemeFallsBackToColorFGBG(t *testing.T) {
}
func TestApplyTextareaThemeClearsCursorLineBackground(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
t.Setenv("REASONIX_THEME", "")
t.Setenv("REASONIX_THEME_STYLE", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
for _, mode := range []string{"dark", "light", "auto"} {
t.Run(mode, func(t *testing.T) {
@@ -203,14 +220,12 @@ func TestApplyTextareaThemeClearsCursorLineBackground(t *testing.T) {
}
func TestApplyTextareaThemeHonorsCursorShape(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
t.Setenv("REASONIX_THEME", "")
t.Setenv("REASONIX_THEME_STYLE", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
prevShape := cliCursorShape
defer func() { cliCursorShape = prevShape }()
colorEnabled = true
activeColorProfile = colorprofile.ANSI256
configureCLITheme("dark")
for _, tt := range []struct {
@@ -236,12 +251,10 @@ func TestApplyTextareaThemeHonorsCursorShape(t *testing.T) {
}
func TestComposerBorderAndCursorTrackThemeAccent(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
t.Setenv("REASONIX_THEME", "")
t.Setenv("REASONIX_THEME_STYLE", "")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
for _, theme := range cliThemeStyles {
t.Run(theme.name, func(t *testing.T) {
@@ -262,7 +275,7 @@ func TestComposerBorderAndCursorTrackThemeAccent(t *testing.T) {
})
}
colorEnabled = false
activeColorProfile = colorprofile.NoTTY
configureCLITheme("dark")
empty := lipgloss.NewStyle().GetBorderTopForeground()
if got := inputBoxStyle.GetBorderTopForeground(); !reflect.DeepEqual(got, empty) {
@@ -278,20 +291,17 @@ func TestComposerBorderAndCursorTrackThemeAccent(t *testing.T) {
// racing bubbletea's input reader. The switch must resolve via the COLORFGBG
// fallback instead, never invoking the probe.
func TestRuntimeAutoThemeDoesNotProbeStdin(t *testing.T) {
t.Setenv("COLORTERM", "")
t.Setenv("TERM_PROGRAM", "")
t.Setenv("REASONIX_THEME", "")
t.Setenv("REASONIX_THEME_STYLE", "")
t.Setenv("COLORFGBG", "15;0")
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = true
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.ANSI256
prev := queryTerminalBackgroundForTheme
defer func() { queryTerminalBackgroundForTheme = prev }()
probed := false
queryTerminalBackgroundForTheme = func() (terminalRGB, bool) {
defer func(prev func() (terminalRGB, bool)) { terminalProbe = prev }(terminalProbe)
terminalProbe = func() (terminalRGB, bool) {
probed = true
return terminalRGB{}, false
return terminalRGB{255, 255, 255}, true
}
if got := setCLIThemeMode("auto").name; got != "dark" {
@@ -300,10 +310,19 @@ func TestRuntimeAutoThemeDoesNotProbeStdin(t *testing.T) {
if probed {
t.Fatal("runtime /theme auto probed the terminal while the TUI owns stdin")
}
withTerminalProbe(func() {
if got := resolveCLITheme("auto").name; got != "light" {
t.Fatalf("opted-in probe resolved %q, want light", got)
}
})
if !probed {
t.Fatal("withTerminalProbe should be the one path that reaches the terminal")
}
}
func restoreThemeForTest(prevColor bool, prevTheme cliPalette) {
colorEnabled = prevColor
func restoreThemeForTest(prevColor colorprofile.Profile, prevTheme cliPalette) {
activeColorProfile = prevColor
activeCLITheme = prevTheme
refreshCLIStyles()
}
+8 -6
View File
@@ -5,14 +5,16 @@ import (
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
"reasonix/internal/provider"
)
func TestAssistantMarkdownHasIdentityAndIndentedBody(t *testing.T) {
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = false
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.NoTTY
configureCLITheme("dark")
rendered := renderAssistantMarkdown("A concise answer that wraps across the available width.", 32)
@@ -37,8 +39,8 @@ func TestAssistantMarkdownHasIdentityAndIndentedBody(t *testing.T) {
}
func TestReplaySectionsKeepAssistantIdentity(t *testing.T) {
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = false
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.NoTTY
configureCLITheme("dark")
sections := replaySectionsFor([]provider.Message{
@@ -54,8 +56,8 @@ func TestReplaySectionsKeepAssistantIdentity(t *testing.T) {
}
func TestReplaySectionsRestoreInterruptedLocalOutput(t *testing.T) {
defer restoreThemeForTest(colorEnabled, activeCLITheme)
colorEnabled = false
defer restoreThemeForTest(activeColorProfile, activeCLITheme)
activeColorProfile = colorprofile.NoTTY
configureCLITheme("dark")
sections := replaySectionsFor([]provider.Message{