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) {