From 0700fa63c6232fada60d82ba313cd23627a2eb15 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:51:22 +0900 Subject: [PATCH] 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 --- pkg/agent/loop.go | 12 +++---- pkg/utils/string.go | 38 +++++++++++++++++++++ pkg/utils/string_test.go | 72 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index a79362976..673f91aa1 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1246,9 +1246,10 @@ func compressRepeats(s string) string { // Display layout constants. const ( - displayPastEntries = 4 // number of compact 1-line past entries - displayErrorLines = 5 // content lines inside the error code block - statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n" + displayPastEntries = 4 // number of compact 1-line past entries + displayErrorLines = 5 // content lines inside the error code block + 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. @@ -1574,10 +1575,7 @@ func (al *AgentLoop) runLLMIteration( return } lastPublish = time.Now() - display := utils.StripThinkBlocks(accumulated) - if strings.TrimSpace(display) == "" { - return - } + display := utils.TailPad(accumulated, streamingDisplayLines, maxEntryLineWidth) al.bus.PublishOutbound(bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 10623d398..df1369dae 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -25,6 +25,44 @@ func StripThinkBlocks(s string) string { 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 // 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 diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index dff69369e..bde040384 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -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 --- func TestTruncate(t *testing.T) {