Skip to content

Commit da02d3a

Browse files
committed
feat(tui): implement ANSI/VT processing for Windows and enhance color profile handling
1 parent ec56b72 commit da02d3a

11 files changed

Lines changed: 510 additions & 16 deletions

File tree

cmd/goanime/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import (
1616
)
1717

1818
func main() {
19+
// Enable ANSI/VT processing on Windows consoles (classic cmd.exe leaves it
20+
// off). Must run before any colored log/TUI output or users see raw escape
21+
// codes like ←[38;2;...m instead of colors. If enable fails, color paths
22+
// fall back to ASCII via tui.ConsoleColorProfile / SupportsANSI.
23+
_ = tui.EnableVirtualTerminal()
24+
1925
// Save terminal state so we can restore it on exit.
2026
// Libraries like promptui (readline) and go-fuzzyfinder (tcell) put the
2127
// terminal into raw mode; if the process is interrupted or exits abnormally

internal/tui/color_profile.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package tui
2+
3+
import (
4+
"io"
5+
"os"
6+
"runtime"
7+
8+
"github.com/charmbracelet/colorprofile"
9+
)
10+
11+
// ResolveColorProfile picks a safe color profile for writer w.
12+
//
13+
// On Windows, colorprofile.Detect returns TrueColor based on the OS build
14+
// alone — even when classic cmd.exe still has VT processing disabled.
15+
// Emitting ANSI then prints raw escape garbage. We only allow color above
16+
// ASCII when VT processing is actually enabled on the target console.
17+
//
18+
// Pure function: pass vtEnabled explicitly so unit tests cover every branch
19+
// without needing a real console.
20+
func ResolveColorProfile(w io.Writer, env []string, vtEnabled bool) colorprofile.Profile {
21+
p := colorprofile.Detect(w, env)
22+
if runtime.GOOS != "windows" {
23+
return p
24+
}
25+
// Detect already chose plain text (pipe, NO_COLOR, dumb TERM).
26+
if p <= colorprofile.ASCII {
27+
return p
28+
}
29+
// Colored Windows console output requires live VT processing.
30+
if !vtEnabled {
31+
return colorprofile.ASCII
32+
}
33+
return p
34+
}
35+
36+
// ConsoleColorProfile enables VT when possible and returns a safe profile for f.
37+
// Call once at startup (or from InitLogger) before any colored write.
38+
func ConsoleColorProfile(f *os.File) colorprofile.Profile {
39+
_ = EnableVirtualTerminal()
40+
return ResolveColorProfile(f, os.Environ(), HasVirtualTerminal(f))
41+
}
42+
43+
// SupportsANSI reports whether it is safe to emit ANSI sequences to f.
44+
// On Windows this means VT processing is active; elsewhere any non-nil file.
45+
func SupportsANSI(f *os.File) bool {
46+
if f == nil {
47+
return false
48+
}
49+
if runtime.GOOS != "windows" {
50+
return true
51+
}
52+
_ = EnableVirtualTerminal()
53+
return HasVirtualTerminal(f)
54+
}

internal/tui/color_profile_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package tui
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"runtime"
7+
"strings"
8+
"testing"
9+
10+
"github.com/charmbracelet/colorprofile"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// TestResolveColorProfile_WindowsWithoutVT_ForcesASCII is the regression guard
16+
// for classic cmd.exe garbage (←[38;2;...m). Detect alone returns TrueColor on
17+
// Win10+ by OS build; without VT we MUST force ASCII so nothing emits ANSI.
18+
func TestResolveColorProfile_WindowsWithoutVT_ForcesASCII(t *testing.T) {
19+
t.Parallel()
20+
if runtime.GOOS != "windows" {
21+
t.Skip("Windows-only regression: VT-disabled console")
22+
}
23+
24+
// Simulate a TTY-ish env that Detect upgrades to TrueColor on Win10+.
25+
env := []string{
26+
"TERM=",
27+
"COLORTERM=truecolor",
28+
}
29+
// Use os.Stderr so Detect sees a real file handle; VT flag forced false.
30+
got := ResolveColorProfile(os.Stderr, env, false)
31+
assert.LessOrEqual(t, got, colorprofile.ASCII,
32+
"Windows console without VT must not emit color ANSI (got %v)", got)
33+
}
34+
35+
// TestResolveColorProfile_WindowsWithVT_AllowsColor ensures we do not
36+
// over-downgrade modern hosts (Windows Terminal, cmd with VT on).
37+
// TTY_FORCE makes Detect treat the writer as a TTY even under go test pipes.
38+
func TestResolveColorProfile_WindowsWithVT_AllowsColor(t *testing.T) {
39+
t.Parallel()
40+
if runtime.GOOS != "windows" {
41+
t.Skip("Windows-only")
42+
}
43+
44+
env := []string{
45+
"TTY_FORCE=1",
46+
"WT_SESSION=test-session",
47+
"COLORTERM=truecolor",
48+
}
49+
got := ResolveColorProfile(os.Stderr, env, true)
50+
assert.Greater(t, got, colorprofile.ASCII,
51+
"VT-enabled Windows console should keep color (got %v)", got)
52+
53+
// Same env without VT must still force ASCII (the cmd.exe regression).
54+
downgraded := ResolveColorProfile(os.Stderr, env, false)
55+
assert.LessOrEqual(t, downgraded, colorprofile.ASCII)
56+
}
57+
58+
// TestResolveColorProfile_NoColorEnv_StaysPlain covers NO_COLOR on any OS.
59+
func TestResolveColorProfile_NoColorEnv_StaysPlain(t *testing.T) {
60+
t.Parallel()
61+
env := []string{"NO_COLOR=1", "TERM=xterm-256color", "COLORTERM=truecolor"}
62+
got := ResolveColorProfile(os.Stderr, env, true)
63+
assert.LessOrEqual(t, got, colorprofile.ASCII)
64+
}
65+
66+
// TestResolveColorProfile_NonWindows_IgnoresVTFlag documents that Unix
67+
// hosts always trust Detect (vtEnabled is irrelevant).
68+
func TestResolveColorProfile_NonWindows_IgnoresVTFlag(t *testing.T) {
69+
t.Parallel()
70+
if runtime.GOOS == "windows" {
71+
t.Skip("Unix-only branch")
72+
}
73+
env := []string{"TERM=xterm-256color", "COLORTERM=truecolor"}
74+
// Even with vtEnabled=false, non-Windows must not force ASCII solely for that.
75+
with := ResolveColorProfile(os.Stderr, env, true)
76+
without := ResolveColorProfile(os.Stderr, env, false)
77+
assert.Equal(t, with, without)
78+
}
79+
80+
// TestResolveColorProfile_PipeWriter_NoTTY ensures redirected writers stay plain.
81+
func TestResolveColorProfile_PipeWriter_NoTTY(t *testing.T) {
82+
t.Parallel()
83+
var buf bytes.Buffer
84+
got := ResolveColorProfile(&buf, []string{"TERM=xterm-256color"}, false)
85+
assert.LessOrEqual(t, got, colorprofile.ASCII,
86+
"non-TTY writer must not use color profiles (got %v)", got)
87+
}
88+
89+
func TestEnableVirtualTerminal_DoesNotPanic(t *testing.T) {
90+
t.Parallel()
91+
_ = EnableVirtualTerminal()
92+
_ = EnableVirtualTerminal()
93+
}
94+
95+
func TestHasVirtualTerminal_NilFile(t *testing.T) {
96+
t.Parallel()
97+
assert.False(t, HasVirtualTerminal(nil))
98+
}
99+
100+
func TestSupportsANSI_NilFile(t *testing.T) {
101+
t.Parallel()
102+
assert.False(t, SupportsANSI(nil))
103+
}
104+
105+
// TestEnableVirtualTerminal_EnablesFlagWhenConsole is the live console check.
106+
// Skips when stdout is not a console (CI pipes, go test capture).
107+
func TestEnableVirtualTerminal_EnablesFlagWhenConsole(t *testing.T) {
108+
if runtime.GOOS != "windows" {
109+
t.Skip("Windows console mode flag only")
110+
}
111+
ok := EnableVirtualTerminal()
112+
if !HasVirtualTerminal(os.Stdout) && !HasVirtualTerminal(os.Stderr) {
113+
t.Skip("no console attached (piped CI) — cannot assert VT flag")
114+
}
115+
require.True(t, ok, "EnableVirtualTerminal should succeed on a real console")
116+
assert.True(t, HasVirtualTerminal(os.Stdout) || HasVirtualTerminal(os.Stderr))
117+
}
118+
119+
// TestConsoleColorProfile_NeverTrueColorWithoutVT hard-guards the user-facing
120+
// bug: profile used by logger/TUI must never be TrueColor/ANSI256 when VT is off.
121+
func TestConsoleColorProfile_NeverTrueColorWithoutVT(t *testing.T) {
122+
t.Parallel()
123+
if runtime.GOOS != "windows" {
124+
t.Skip("Windows-only contract")
125+
}
126+
// Force the pure path with vtEnabled=false regardless of host state.
127+
p := ResolveColorProfile(os.Stderr, os.Environ(), false)
128+
assert.LessOrEqual(t, p, colorprofile.ASCII)
129+
}
130+
131+
// TestRestoreTerminalState_NoANSIWhenUnsupported ensures exit cleanup does not
132+
// dump TerminalResetSequence into classic cmd.exe.
133+
func TestRestoreTerminalState_NoANSIWhenUnsupported(t *testing.T) {
134+
t.Parallel()
135+
// bytes.Buffer is not *os.File → SupportsANSI path not taken; sequence writes.
136+
// Test the *os.File branch with a temp file (not a console → no VT).
137+
f, err := os.CreateTemp(t.TempDir(), "restore-*.txt")
138+
require.NoError(t, err)
139+
t.Cleanup(func() { _ = f.Close() })
140+
141+
if runtime.GOOS == "windows" {
142+
// Temp file is not a console → SupportsANSI false → no write.
143+
RestoreTerminalState(f)
144+
_, _ = f.Seek(0, 0)
145+
data, err := os.ReadFile(f.Name())
146+
require.NoError(t, err)
147+
assert.Empty(t, data, "must not write ANSI reset to non-console Windows handle")
148+
assert.NotContains(t, string(data), "\x1b")
149+
return
150+
}
151+
// Non-Windows: sequence is written (SupportsANSI true for any non-nil file).
152+
RestoreTerminalState(f)
153+
_, _ = f.Seek(0, 0)
154+
data, err := os.ReadFile(f.Name())
155+
require.NoError(t, err)
156+
assert.Contains(t, string(data), "\x1b[?25h")
157+
}
158+
159+
// TestTerminalResetSequence_ContainsNoRIS documents we never hard-reset the
160+
// screen (would wipe scrollback) — keeps restore safe.
161+
func TestTerminalResetSequence_ContainsNoRIS(t *testing.T) {
162+
t.Parallel()
163+
assert.NotContains(t, TerminalResetSequence, "\x1bc")
164+
assert.NotContains(t, TerminalResetSequence, "\x1b[2J")
165+
assert.True(t, strings.Contains(TerminalResetSequence, "\x1b[?25h"))
166+
}

internal/tui/find.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import (
2424

2525
// ResetTerminal sends ANSI sequences to reset terminal state after tcell
2626
// and drains any stale bytes from stdin that tcell may have left behind.
27+
//
28+
// On Windows consoles without VT processing, ANSI is skipped (only a bare
29+
// carriage return) so classic cmd.exe never shows raw codes like ←[?25h.
2730
func ResetTerminal() {
2831
// Reset DECCKM (normal cursor keys) + reset keypad numeric mode + show cursor
2932
// These match the exact sequences tcell's ExitKeypad should send but
@@ -38,7 +41,11 @@ func ResetTerminal() {
3841
// column-0 line instead of glued to leftovers. Deliberately NOT "\r\n":
3942
// ResetTerminal runs after every finder/spinner, and an unconditional
4043
// newline stacks a blank line per call, riddling the session with gaps.
41-
fmt.Fprint(os.Stdout, "\r\033[2K\033[?1l\033>\033[?25h")
44+
if SupportsANSI(os.Stdout) {
45+
fmt.Fprint(os.Stdout, "\r\033[2K\033[?1l\033>\033[?25h")
46+
} else {
47+
fmt.Fprint(os.Stdout, "\r")
48+
}
4249

4350
// Drain any stale bytes from stdin (platform-specific implementation). A
4451
// short raw/no-echo window also catches late terminal capability responses

internal/tui/restore.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,13 @@ const TerminalResetSequence = "" +
4848
// RestoreTerminalState writes TerminalResetSequence to w, returning sane
4949
// interactive terminal state on program exit. It is safe to call multiple times
5050
// and on any exit path.
51+
//
52+
// On Windows without VT, the sequence is skipped — emitting it to classic
53+
// cmd.exe prints raw escape garbage instead of restoring the console.
5154
func RestoreTerminalState(w io.Writer) {
55+
if f, ok := w.(*os.File); ok && !SupportsANSI(f) {
56+
return
57+
}
5258
_, _ = io.WriteString(w, TerminalResetSequence)
5359
}
5460

internal/tui/terminal.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import (
55
"strings"
66

77
tea "charm.land/bubbletea/v2"
8-
"github.com/charmbracelet/colorprofile"
98
)
109

1110
// BubbleTeaProgramOptions returns default Bubble Tea options that avoid
1211
// terminal capability probes known to leak raw responses in some terminals.
12+
// Color profile follows ConsoleColorProfile so classic Windows cmd.exe without
13+
// VT never receives TrueColor sequences it would print as raw garbage.
1314
func BubbleTeaProgramOptions(extra ...tea.ProgramOption) []tea.ProgramOption {
1415
opts := []tea.ProgramOption{
1516
tea.WithEnvironment(safeBubbleTeaEnvironment()),
16-
tea.WithColorProfile(colorprofile.TrueColor),
17+
tea.WithColorProfile(ConsoleColorProfile(os.Stdout)),
1718
}
1819
return append(opts, extra...)
1920
}

internal/tui/terminal_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ func TestSafeBubbleTeaEnvironmentSuppressesCapabilityQueries(t *testing.T) {
1919
assertEnvMissing(t, env, "WT_SESSION")
2020
}
2121

22+
// TestBubbleTeaProgramOptions_UsesResolvedProfile ensures we never hardcode
23+
// TrueColor (the previous source of cmd.exe garbage when VT is off).
24+
func TestBubbleTeaProgramOptions_UsesResolvedProfile(t *testing.T) {
25+
t.Parallel()
26+
opts := BubbleTeaProgramOptions()
27+
if len(opts) < 2 {
28+
t.Fatalf("expected env + color profile options, got %d", len(opts))
29+
}
30+
// Construction must not panic; profile comes from ConsoleColorProfile.
31+
_ = opts
32+
}
33+
2234
func TestRunCleanRestoresEnvironmentAndPropagatesError(t *testing.T) {
2335
t.Setenv("TERM", "xterm-ghostty")
2436
t.Setenv("TERM_PROGRAM", "Ghostty")

internal/tui/vt_other.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//go:build !windows
2+
3+
package tui
4+
5+
import "os"
6+
7+
// EnableVirtualTerminal is a no-op outside Windows. Unix terminals interpret
8+
// ANSI escape sequences by default. Always returns true.
9+
func EnableVirtualTerminal() bool { return true }
10+
11+
// HasVirtualTerminal is always true outside Windows: hosts render ANSI natively.
12+
func HasVirtualTerminal(f *os.File) bool {
13+
return f != nil
14+
}

internal/tui/vt_windows.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//go:build windows
2+
3+
package tui
4+
5+
import (
6+
"os"
7+
8+
"golang.org/x/sys/windows"
9+
)
10+
11+
// EnableVirtualTerminal turns on ANSI/VT processing for stdout and stderr.
12+
//
13+
// Classic cmd.exe on Windows 10 leaves ENABLE_VIRTUAL_TERMINAL_PROCESSING off.
14+
// Without it, TrueColor/ANSI sequences from lipgloss/log/tcell print as raw
15+
// garbage (e.g. ←[38;2;255;255;255m). Returns true when at least one of
16+
// stdout/stderr has VT processing active after the call.
17+
func EnableVirtualTerminal() bool {
18+
outOK := enableVT(os.Stdout)
19+
errOK := enableVT(os.Stderr)
20+
return outOK || errOK
21+
}
22+
23+
// HasVirtualTerminal reports whether f is a console with VT processing on.
24+
func HasVirtualTerminal(f *os.File) bool {
25+
if f == nil {
26+
return false
27+
}
28+
return consoleHasVT(windows.Handle(f.Fd()))
29+
}
30+
31+
func enableVT(f *os.File) bool {
32+
if f == nil {
33+
return false
34+
}
35+
handle := windows.Handle(f.Fd())
36+
var mode uint32
37+
if err := windows.GetConsoleMode(handle, &mode); err != nil {
38+
return false
39+
}
40+
if mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 {
41+
return true
42+
}
43+
if err := windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING); err != nil {
44+
return false
45+
}
46+
return consoleHasVT(handle)
47+
}
48+
49+
func consoleHasVT(handle windows.Handle) bool {
50+
var mode uint32
51+
if err := windows.GetConsoleMode(handle, &mode); err != nil {
52+
return false
53+
}
54+
return mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0
55+
}

0 commit comments

Comments
 (0)