Skip to content

tea.Println content bypasses color-profile downsampling — NO_COLOR and low-color terminals get full-color scrollback #1709

Description

@dmotylev

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

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)
	}
}
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: <ESC>[31mred<ESC>[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.

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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions