mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
22 files changed
+446
-221
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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.`)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -17,7 +17,7 @@ const (
|
||||
)
|
||||
|
||||
func queryTerminalBackground() (terminalRGB, bool) {
|
||||
if !colorEnabled {
|
||||
if !colorOn() {
|
||||
return terminalRGB{}, false
|
||||
}
|
||||
inFd := int(os.Stdin.Fd())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
Reference in new issue
Block a user