Files
Willem Kokke 3b5703177d 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>
2026-07-13 04:31:26 +00:00

65 lines
2.2 KiB
Go

// 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")
}