mirror of
https://gitea.com/gitea/tea.git
synced 2026-07-16 02:57:40 +02:00
fix(theme): don't query the terminal at start-up (#1054)
Fixes https://gitea.com/gitea/tea/issues/1053 ## What tea hangs forever on Windows when it is started by something that owns a console but redirects tea's stdio: a Windows service, a CI runner, an automation harness. Every command is affected, `tea --version` included. ## Root cause `modules/theme` imported `charm.land/lipgloss/v2/compat` for one struct type, `compat.AdaptiveColor`. But compat detects the terminal background from package-level vars: ```go var ( HasDarkBackground = lipgloss.HasDarkBackground(os.Stdin, os.Stdout) Profile = colorprofile.Detect(os.Stdout, os.Environ()) ) ``` Go initialises every package in the import graph before `main()` runs, and every file in package `cmd` imports `modules/context`, which imports `modules/theme`. So tea queried the terminal on every invocation, before urfave/cli had even looked at the arguments — which is why `--version` hung. On Windows that query opens `CONIN$`/`CONOUT$` and asks the console directly instead of giving up when stdio is redirected, then waits for a reply that never comes. The read has a 2 second timeout, but it does not fire, because the cancel it relies on is a no-op for that handle. Full trace in the issue. ## Changes - `modules/theme/theme.go` — drop the `compat` import and use `lipgloss.LightDark`, a plain function that touches no terminal. `TeaTheme.Theme` is already handed the `isDark` it needs. This also fixes a bug hiding in plain sight: `compat.AdaptiveColor` resolves against a process-wide value detected at init, so the title color ignored the `isDark` argument huh passed in. `Theme(true)` and `Theme(false)` returned the same color. - `modules/theme/background.go` — new `HasDarkBackground()` helper that only asks the terminal when stdin and stdout are both terminals, and otherwise assumes dark, which is the default lipgloss itself falls back to. This is the same rule lipgloss already applies on Unix. - `modules/interact/print.go` — `printTitleAndContent` called `lipgloss.HasDarkBackground(os.Stdin, os.Stdout)` directly, so it hit the same wait on the interactive paths, `tea login add` among them. It now goes through the helper. ## Why fix this in tea The underlying bug is upstream and I have opened PRs for both halves of it: https://github.com/charmbracelet/ultraviolet/pull/138 and https://github.com/charmbracelet/lipgloss/pull/713. But it is not fixed in any released version — lipgloss v2.0.5 is byte-identical to v2.0.4 in the relevant files — so upgrading dependencies does not help, and tea would stay broken on Windows until Charm cuts a release and tea bumps `go.mod`. Separately, these changes stand on their own. tea should not query the terminal in order to print a version string, whatever upstream does. Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: Willem Kokke <mail@willem.net>
This commit is contained in:
@@ -5,7 +5,6 @@ package interact
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
|
||||||
"gitea.dev/tea/modules/theme"
|
"gitea.dev/tea/modules/theme"
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// printTitleAndContent prints a title and content with the gitea theme
|
// printTitleAndContent prints a title and content with the gitea theme
|
||||||
func printTitleAndContent(title, content string) {
|
func printTitleAndContent(title, content string) {
|
||||||
hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
hasDarkBG := theme.HasDarkBackground()
|
||||||
style := lipgloss.NewStyle().
|
style := lipgloss.NewStyle().
|
||||||
Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true).
|
Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultDarkBackground is the background to assume when we cannot detect one. It
|
||||||
|
// matches the default lipgloss falls back to.
|
||||||
|
const defaultDarkBackground = true
|
||||||
|
|
||||||
|
// HasDarkBackground reports whether the terminal has a dark background.
|
||||||
|
//
|
||||||
|
// It only asks the terminal when stdin and stdout are both terminals. Detection
|
||||||
|
// works by writing an escape sequence to the output and waiting for the terminal
|
||||||
|
// to answer on the input, and nothing answers when stdio is redirected, so asking
|
||||||
|
// means waiting on a reply that never comes. On Windows that wait is unbounded:
|
||||||
|
// lipgloss opens the console directly rather than giving up, which is why tea used
|
||||||
|
// to hang at start-up under a service or a CI runner.
|
||||||
|
func HasDarkBackground() bool {
|
||||||
|
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||||
|
return defaultDarkBackground
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ package theme
|
|||||||
import (
|
import (
|
||||||
"charm.land/huh/v2"
|
"charm.land/huh/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"charm.land/lipgloss/v2/compat"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TeaTheme implements the huh.Theme interface with tea-cli styling.
|
// TeaTheme implements the huh.Theme interface with tea-cli styling.
|
||||||
@@ -16,7 +15,8 @@ type TeaTheme struct{}
|
|||||||
func (t TeaTheme) Theme(isDark bool) *huh.Styles {
|
func (t TeaTheme) Theme(isDark bool) *huh.Styles {
|
||||||
theme := huh.ThemeCharm(isDark)
|
theme := huh.ThemeCharm(isDark)
|
||||||
|
|
||||||
title := compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")}
|
lightDark := lipgloss.LightDark(isDark)
|
||||||
|
title := lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87"))
|
||||||
theme.Focused.Title = theme.Focused.Title.Foreground(title).Bold(true)
|
theme.Focused.Title = theme.Focused.Title.Foreground(title).Bold(true)
|
||||||
theme.Blurred = theme.Focused
|
theme.Blurred = theme.Focused
|
||||||
return theme
|
return theme
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compatPkg detects the terminal background from package-level vars, so importing
|
||||||
|
// it anywhere makes tea query the terminal before main() runs. On Windows that
|
||||||
|
// query can block forever when stdio is redirected, which hung every tea command,
|
||||||
|
// including tea --version.
|
||||||
|
//
|
||||||
|
// lipgloss.LightDark covers what we need without the package-level detection, so
|
||||||
|
// nothing in tea should depend on compat again.
|
||||||
|
const compatPkg = "charm.land/lipgloss/v2/compat"
|
||||||
|
|
||||||
|
func TestBinaryDoesNotImportLipglossCompat(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("go"); err != nil {
|
||||||
|
t.Skip("go is not on PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command("go", "list", "-deps", "gitea.dev/tea").Output()
|
||||||
|
require.NoError(t, err, "go list -deps")
|
||||||
|
|
||||||
|
imported := slices.Contains(strings.Fields(string(out)), compatPkg)
|
||||||
|
assert.False(t, imported,
|
||||||
|
"%s is back in tea's import graph. It detects the terminal background from "+
|
||||||
|
"package-level vars, so tea queries the terminal before main() runs, and on "+
|
||||||
|
"Windows that hangs at start-up when stdio is redirected.", compatPkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under go test neither stdin nor stdout is a terminal, so HasDarkBackground must
|
||||||
|
// take the default and return, rather than querying and waiting for an answer.
|
||||||
|
func TestHasDarkBackgroundDoesNotBlockWithoutTTY(t *testing.T) {
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
done <- HasDarkBackground()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
assert.Equal(t, defaultDarkBackground, got)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("HasDarkBackground blocked when stdio is not a terminal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The title color has to come from the isDark we are handed. It used to come from
|
||||||
|
// a process-wide value that compat detected at init, which ignored this argument.
|
||||||
|
func TestThemeHonorsIsDark(t *testing.T) {
|
||||||
|
dark := GetTheme().Theme(true).Focused.Title.GetForeground()
|
||||||
|
light := GetTheme().Theme(false).Focused.Title.GetForeground()
|
||||||
|
|
||||||
|
assert.NotEqual(t, dark, light, "Theme ignored isDark when picking the title color")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user