fix: stabilize chat bubble height during streaming preview
TailPad wraps long lines at 42 chars and shows the last 17 visual lines (matching buildRichStatus), padded with Braille blanks so the bubble height stays constant through think/tool-call/content transitions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fc4ff76a9e
commit
d004879579
3 changed files with 115 additions and 7 deletions
|
|
@ -1246,9 +1246,10 @@ func compressRepeats(s string) string {
|
||||||
|
|
||||||
// Display layout constants.
|
// Display layout constants.
|
||||||
const (
|
const (
|
||||||
displayPastEntries = 4 // number of compact 1-line past entries
|
displayPastEntries = 4 // number of compact 1-line past entries
|
||||||
displayErrorLines = 5 // content lines inside the error code block
|
displayErrorLines = 5 // content lines inside the error code block
|
||||||
statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n"
|
statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n"
|
||||||
|
streamingDisplayLines = 17 // line count matching buildRichStatus output
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildRichStatus builds a fixed-height terminal-like status display.
|
// buildRichStatus builds a fixed-height terminal-like status display.
|
||||||
|
|
@ -1574,10 +1575,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastPublish = time.Now()
|
lastPublish = time.Now()
|
||||||
display := utils.StripThinkBlocks(accumulated)
|
display := utils.TailPad(accumulated, streamingDisplayLines, maxEntryLineWidth)
|
||||||
if strings.TrimSpace(display) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,44 @@ func StripThinkBlocks(s string) string {
|
||||||
return strings.TrimSpace(s)
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TailPad returns a fixed-height block of n visual lines built from the
|
||||||
|
// tail of s. Long lines are wrapped at wrapWidth runes so the result
|
||||||
|
// never exceeds the chat bubble width. If fewer than n visual lines
|
||||||
|
// exist, Braille-blank lines (\u2800) are prepended as padding.
|
||||||
|
func TailPad(s string, n, wrapWidth int) string {
|
||||||
|
// Wrap each raw line into visual lines respecting wrapWidth.
|
||||||
|
var visual []string
|
||||||
|
for _, raw := range strings.Split(s, "\n") {
|
||||||
|
visual = append(visual, wrapLine(raw, wrapWidth)...)
|
||||||
|
}
|
||||||
|
if len(visual) > n {
|
||||||
|
visual = visual[len(visual)-n:]
|
||||||
|
}
|
||||||
|
for len(visual) < n {
|
||||||
|
visual = append([]string{"\u2800"}, visual...)
|
||||||
|
}
|
||||||
|
return strings.Join(visual, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapLine splits a single line into segments of at most width runes.
|
||||||
|
// An empty line produces one empty string (preserving blank lines).
|
||||||
|
func wrapLine(line string, width int) []string {
|
||||||
|
runes := []rune(line)
|
||||||
|
if len(runes) <= width {
|
||||||
|
return []string{line}
|
||||||
|
}
|
||||||
|
var segs []string
|
||||||
|
for len(runes) > 0 {
|
||||||
|
end := width
|
||||||
|
if end > len(runes) {
|
||||||
|
end = len(runes)
|
||||||
|
}
|
||||||
|
segs = append(segs, string(runes[:end]))
|
||||||
|
runes = runes[end:]
|
||||||
|
}
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
// DetectRepetitionLoop checks if text contains degenerate repetition
|
// DetectRepetitionLoop checks if text contains degenerate repetition
|
||||||
// by computing the unique N-gram ratio on the last repetitionSampleSize runes.
|
// by computing the unique N-gram ratio on the last repetitionSampleSize runes.
|
||||||
// Returns true if the ratio of unique N-grams to total N-grams
|
// Returns true if the ratio of unique N-grams to total N-grams
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,78 @@ func TestDetectRepetitionLoop_BelowSampleSize(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TailPad ---
|
||||||
|
|
||||||
|
func TestTailPad_FewerThanN(t *testing.T) {
|
||||||
|
got := TailPad("a\nb", 5, 80)
|
||||||
|
lines := strings.Split(got, "\n")
|
||||||
|
if len(lines) != 5 {
|
||||||
|
t.Fatalf("TailPad line count = %d, want 5", len(lines))
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if lines[i] != "\u2800" {
|
||||||
|
t.Errorf("TailPad line %d = %q, want padding", i, lines[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lines[3] != "a" || lines[4] != "b" {
|
||||||
|
t.Errorf("TailPad content = %q %q, want a b", lines[3], lines[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTailPad_ExactlyN(t *testing.T) {
|
||||||
|
in := "a\nb\nc"
|
||||||
|
got := TailPad(in, 3, 80)
|
||||||
|
if got != in {
|
||||||
|
t.Fatalf("TailPad exact = %q, want %q", got, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTailPad_MoreThanN(t *testing.T) {
|
||||||
|
got := TailPad("a\nb\nc\nd\ne", 3, 80)
|
||||||
|
if got != "c\nd\ne" {
|
||||||
|
t.Fatalf("TailPad tail = %q, want %q", got, "c\nd\ne")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTailPad_Empty(t *testing.T) {
|
||||||
|
got := TailPad("", 4, 80)
|
||||||
|
lines := strings.Split(got, "\n")
|
||||||
|
if len(lines) != 4 {
|
||||||
|
t.Fatalf("TailPad empty line count = %d, want 4", len(lines))
|
||||||
|
}
|
||||||
|
for i, l := range lines {
|
||||||
|
if i == len(lines)-1 {
|
||||||
|
if l != "" {
|
||||||
|
t.Errorf("TailPad empty last line = %q, want empty", l)
|
||||||
|
}
|
||||||
|
} else if l != "\u2800" {
|
||||||
|
t.Errorf("TailPad empty line %d = %q, want padding", i, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTailPad_LongLineWraps(t *testing.T) {
|
||||||
|
// One 10-char line wraps into 2 visual lines at width 5.
|
||||||
|
got := TailPad("abcdefghij", 4, 5)
|
||||||
|
lines := strings.Split(got, "\n")
|
||||||
|
if len(lines) != 4 {
|
||||||
|
t.Fatalf("TailPad wrap line count = %d, want 4", len(lines))
|
||||||
|
}
|
||||||
|
// 2 padding + "abcde" + "fghij"
|
||||||
|
if lines[2] != "abcde" || lines[3] != "fghij" {
|
||||||
|
t.Errorf("TailPad wrap content = %v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTailPad_WrapPushesOldLines(t *testing.T) {
|
||||||
|
// "short" (1 visual) + "abcdefghij" (2 visual at width 5) = 3 visual.
|
||||||
|
// With n=2, only tail 2 visual lines remain.
|
||||||
|
got := TailPad("short\nabcdefghij", 2, 5)
|
||||||
|
if got != "abcde\nfghij" {
|
||||||
|
t.Fatalf("TailPad wrap push = %q, want %q", got, "abcde\nfghij")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Truncate ---
|
// --- Truncate ---
|
||||||
|
|
||||||
func TestTruncate(t *testing.T) {
|
func TestTruncate(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue