charmbracelet / charmbracelet/bubbletea
tea.Println content bypasses color-profile downsampling — NO_COLOR and low-color terminals get full-color scrollback
- Dominant language
- Go
- Stars
- 44.9k
- Forks
- 1.3k
- PR merge metrics
- No merged PRs in 30d
Description
## Describe the bug
The cell renderer downsamples `View` content to the detected color profile, but content delivered through `tea.Println` / `Program.Println` reaches the terminal verbatim. Under `NO_COLOR=1` (or a 16-color/ascii profile) the live view obeys while every line committed to scrollback keeps its original colors.
## Setup
- bubbletea `v2.0.7` (charm.land/bubbletea/v2), Go 1.26.4, darwin/arm64
- Reproduces on any terminal; captures below are from a PTY via `script`
## To reproduce
```go
package main
import (
"fmt"
"os"
"time"
tea "charm.land/bubbletea/v2"
)
const red = "\x1b[31mred\x1b[m"
type model struct{}
type doneMsg struct{}
func (m model) Init() tea.Cmd {
return tea.Batch(
tea.Println("println: "+red),
tea.Tick(300*time.Millisecond, func(time.Time) tea.Msg { return doneMsg{} }),
)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(doneMsg); ok {
return m, tea.Quit
}
return m, nil
}
func (m model) View() tea.View { return tea.NewView("view: " + red) }
func main() {
if _, err := tea.NewProgram(model{}, tea.WithInput(nil)).Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
```
```sh
go build -o repro .
script -q out.txt sh -c 'stty rows 24 cols 80; NO_COLOR=1 ./repro'
```
## Observed (`NO_COLOR=1`, escapes made visible)
```
println: [31mred[m <- Println path: color survives
view: red <- View path: correctly downsampled
```
Control run without `NO_COLOR` renders both red, as expected.
## Expected
Both paths obey the same profile: `colorprofile.Detect(p.output, p.environ)` already runs at startup (`tea.go`) and `cursedRenderer` holds the result in `s.profile`.
## Where it happens
`cursedRenderer.insertAbove` (`cursed_renderer.go:707`, v2.0.7) splits the queued string into lines and writes them straight to `s.w` — no `ConvertStyle`/profile pass — while regular frames are converted per cell at render time. `ultraviolet.TerminalScreen.InsertAbove` (`terminal_screen.go:733`) has the same shape: it takes content verbatim even though the screen carries a `profile` field and a `SetColorProfile` method.
## Suggested fix
In `insertAbove`, pass each line through the already-available profile before writing, e.g.
```go
var conv strings.Builder
_, _ = (&colorprofile.Writer{Forward: &conv, Profile: s.profile}).WriteString(line)
sb.WriteString(conv.String())
```
(or convert once when the string is enqueued in `printLines`). The same applies to ultraviolet's `TerminalScreen.InsertAbove` — happy to split this into a second issue there if you prefer to track it per-repo.
## Workaround
Callers can downsample before calling `Println`:
```go
profile := colorprofile.Detect(out, os.Environ()) // same detection the program runs
var sb strings.Builder
_, _ = (&colorprofile.Writer{Forward: &sb, Profile: profile}).WriteString(block)
p.Println(sb.String())
```
## Context
Found while building a `docker buildx`-style progress renderer for mage builds, which commits finished subtrees to scrollback via `Program.Println` — under `NO_COLOR=1` the live tree went monochrome while every committed block stayed in full color.
Contributor guide
Research direction
Start in cursed_renderer.go at cursedRenderer.insertAbove and trace how tea.Println content is written to s.w; compare that path with ultraviolet/terminal_screen.go's TerminalScreen.InsertAbove and the existing color-profile handling. Done means committed Println lines are downsampled to the detected profile just like live View content, including under NO_COLOR and low-color terminals.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100