diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index 9ac0a55e..a5c7ed8a 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "regexp" "sort" "strconv" "strings" @@ -159,11 +160,37 @@ func wrapBlock(value string, width int) string { out = append(out, line) continue } - out = append(out, strings.Split(ansi.Wrap(line, width, " -"), "\n")...) + out = append(out, carryStyle(strings.Split(ansi.Wrap(line, width, " -"), "\n"))...) } return strings.Join(out, "\n") } +var sgrPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// carryStyle re-opens the active foreground/attribute style on each continuation +// line of a wrapped logical line. ansi.Wrap emits the opening SGR only on the first +// line and the reset only on the last, so a wrapped colored line (a blocked-safety +// reason, a long error) would otherwise show color on its first row alone. +func carryStyle(lines []string) []string { + active := "" + for i, line := range lines { + if active != "" { + lines[i] = active + line + } + for _, seq := range sgrPattern.FindAllString(line, -1) { + if seq == "\x1b[0m" || seq == "\x1b[m" { + active = "" + } else { + active = seq + } + } + if active != "" && i < len(lines)-1 { + lines[i] += "\x1b[0m" + } + } + return lines +} + // scrollbarThumb brightens the bar being dragged so the grab reads as taking // hold of it. func (m Model) scrollbarThumb(target scrollbarTarget) lipgloss.Color { diff --git a/strix/interface/tui/internal/app/wrap_block_test.go b/strix/interface/tui/internal/app/wrap_block_test.go new file mode 100644 index 00000000..554c2830 --- /dev/null +++ b/strix/interface/tui/internal/app/wrap_block_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" +) + +// A colored line wider than the wrap width must stay colored on every row, not +// only the first: ansi.Wrap emits the opening SGR once and the reset once, so +// wrapBlock re-opens the active style on each continuation line. +func TestWrapBlockCarriesColorAcrossContinuationLines(t *testing.T) { + lipgloss.SetColorProfile(termenv.TrueColor) + amber := "\x1b[38;2;245;158;11m" + line := lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b")). + Render("Blocked: " + strings.Repeat("a reason long enough to wrap ", 4)) + + rows := strings.Split(wrapBlock(line, 30), "\n") + if len(rows) < 3 { + t.Fatalf("expected the reason to wrap to several rows, got %d", len(rows)) + } + for i, row := range rows { + if strings.TrimSpace(ansi.Strip(row)) == "" { + continue + } + if !strings.Contains(row, amber) { + t.Errorf("row %d lost its color after wrapping: %q", i, row) + } + } +} + +func TestWrapBlockLeavesShortColoredLineUnchanged(t *testing.T) { + lipgloss.SetColorProfile(termenv.TrueColor) + line := lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b")).Render("Blocked: short") + if got := wrapBlock(line, 80); got != line { + t.Errorf("a line within width was rewritten:\n got %q\nwant %q", got, line) + } +}