fix(tui): keep color across every wrapped row of a tool result line

A long blocked-safety reason showed its amber only on the first row. wrapBlock
wraps an over-width line with ansi.Wrap, which emits the opening SGR on the
first row and the reset on the last, leaving the continuation rows unstyled — so
a wrapped colored line (a safety block reason, a long error) lost its color
after the first row.

carryStyle re-opens the active style on each continuation row and terminates
each with a reset. Any colored line wider than the chat width was affected;
block reasons are just the first routinely-long one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
oyasumi
2026-08-08 02:05:49 +00:00
co-authored by Claude Opus 5
parent a7336fa194
commit d6c5b410ad
2 changed files with 69 additions and 1 deletions
+28 -1
View File
@@ -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 {
@@ -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)
}
}