fix(tui): wrap ANSI-rendered assistant body on narrow terminals
- commit
- 569d28dbdb02d900ee203db94f0110ffa32ca427
- author
- patriceckhart · · 2026-04-22 07:40 UTC
- parent
- fdcdeb5
- signature
- unsigned
- refs
- tag: v0.0.91
2 files · +114 -1
M internal/tui/editor.go +111 -0
| @@ -829,6 +829,117 @@ func stripANSI(s string) string { | |||
| 829 | 829 | return string(out) | |
| 830 | 830 | } | |
| 831 | 831 | ||
| 832 | + | // wrapANSILine folds a string that may contain ANSI CSI escapes so that | |
| 833 | + | // the visible width of each line stays within limit. Breaks happen on | |
| 834 | + | // spaces when possible, falling back to mid-token splits for very long | |
| 835 | + | // unbroken runs. Escape sequences are preserved verbatim and never | |
| 836 | + | // counted toward the visible column. | |
| 837 | + | func wrapANSILine(s string, limit int) []string { | |
| 838 | + | if limit <= 0 { | |
| 839 | + | return []string{s} | |
| 840 | + | } | |
| 841 | + | if visibleWidth(s) <= limit { | |
| 842 | + | return []string{s} | |
| 843 | + | } | |
| 844 | + | runes := []rune(s) | |
| 845 | + | var out []string | |
| 846 | + | var line strings.Builder | |
| 847 | + | var word strings.Builder | |
| 848 | + | lineW := 0 | |
| 849 | + | wordW := 0 | |
| 850 | + | ||
| 851 | + | flushLine := func() { | |
| 852 | + | out = append(out, line.String()) | |
| 853 | + | line.Reset() | |
| 854 | + | lineW = 0 | |
| 855 | + | } | |
| 856 | + | flushWord := func() { | |
| 857 | + | if word.Len() == 0 { | |
| 858 | + | return | |
| 859 | + | } | |
| 860 | + | if lineW+wordW > limit && lineW > 0 { | |
| 861 | + | flushLine() | |
| 862 | + | } | |
| 863 | + | // If the word alone is bigger than the limit, split it by runes. | |
| 864 | + | if wordW > limit { | |
| 865 | + | wr := []rune(word.String()) | |
| 866 | + | for i := 0; i < len(wr); { | |
| 867 | + | r := wr[i] | |
| 868 | + | if r == 0x1b && i+1 < len(wr) && wr[i+1] == '[' { | |
| 869 | + | line.WriteRune(r) | |
| 870 | + | line.WriteRune(wr[i+1]) | |
| 871 | + | i += 2 | |
| 872 | + | for i < len(wr) { | |
| 873 | + | c := wr[i] | |
| 874 | + | line.WriteRune(c) | |
| 875 | + | i++ | |
| 876 | + | if c >= 0x40 && c <= 0x7e { | |
| 877 | + | break | |
| 878 | + | } | |
| 879 | + | } | |
| 880 | + | continue | |
| 881 | + | } | |
| 882 | + | rw := runewidth.RuneWidth(r) | |
| 883 | + | if lineW+rw > limit && lineW > 0 { | |
| 884 | + | flushLine() | |
| 885 | + | } | |
| 886 | + | line.WriteRune(r) | |
| 887 | + | lineW += rw | |
| 888 | + | i++ | |
| 889 | + | } | |
| 890 | + | word.Reset() | |
| 891 | + | wordW = 0 | |
| 892 | + | return | |
| 893 | + | } | |
| 894 | + | line.WriteString(word.String()) | |
| 895 | + | lineW += wordW | |
| 896 | + | word.Reset() | |
| 897 | + | wordW = 0 | |
| 898 | + | } | |
| 899 | + | ||
| 900 | + | for i := 0; i < len(runes); { | |
| 901 | + | r := runes[i] | |
| 902 | + | if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' { | |
| 903 | + | word.WriteRune(r) | |
| 904 | + | word.WriteRune(runes[i+1]) | |
| 905 | + | i += 2 | |
| 906 | + | for i < len(runes) { | |
| 907 | + | c := runes[i] | |
| 908 | + | word.WriteRune(c) | |
| 909 | + | i++ | |
| 910 | + | if c >= 0x40 && c <= 0x7e { | |
| 911 | + | break | |
| 912 | + | } | |
| 913 | + | } | |
| 914 | + | continue | |
| 915 | + | } | |
| 916 | + | if r == ' ' || r == '\t' { | |
| 917 | + | flushWord() | |
| 918 | + | rw := runewidth.RuneWidth(r) | |
| 919 | + | if lineW+rw > limit && lineW > 0 { | |
| 920 | + | flushLine() | |
| 921 | + | i++ | |
| 922 | + | continue | |
| 923 | + | } | |
| 924 | + | line.WriteRune(r) | |
| 925 | + | lineW += rw | |
| 926 | + | i++ | |
| 927 | + | continue | |
| 928 | + | } | |
| 929 | + | word.WriteRune(r) | |
| 930 | + | wordW += runewidth.RuneWidth(r) | |
| 931 | + | i++ | |
| 932 | + | } | |
| 933 | + | flushWord() | |
| 934 | + | if line.Len() > 0 { | |
| 935 | + | flushLine() | |
| 936 | + | } | |
| 937 | + | if len(out) == 0 { | |
| 938 | + | out = []string{""} | |
| 939 | + | } | |
| 940 | + | return out | |
| 941 | + | } | |
| 942 | + | ||
| 832 | 943 | func wrapLine(s string, width int, cont string) []string { | |
| 833 | 944 | if width <= 0 { | |
| 834 | 945 | return []string{s} | |
| @@ -829,6 +829,117 @@ func stripANSI(s string) string { | |||||
| 829 | return string(out) | 829 | return string(out) | ||
| 830 | } | 830 | } | ||
| 831 | 831 | ||||
| 832 | + | // wrapANSILine folds a string that may contain ANSI CSI escapes so that | |||
| 833 | + | // the visible width of each line stays within limit. Breaks happen on | |||
| 834 | + | // spaces when possible, falling back to mid-token splits for very long | |||
| 835 | + | // unbroken runs. Escape sequences are preserved verbatim and never | |||
| 836 | + | // counted toward the visible column. | |||
| 837 | + | func wrapANSILine(s string, limit int) []string { | |||
| 838 | + | if limit <= 0 { | |||
| 839 | + | return []string{s} | |||
| 840 | + | } | |||
| 841 | + | if visibleWidth(s) <= limit { | |||
| 842 | + | return []string{s} | |||
| 843 | + | } | |||
| 844 | + | runes := []rune(s) | |||
| 845 | + | var out []string | |||
| 846 | + | var line strings.Builder | |||
| 847 | + | var word strings.Builder | |||
| 848 | + | lineW := 0 | |||
| 849 | + | wordW := 0 | |||
| 850 | + | ||||
| 851 | + | flushLine := func() { | |||
| 852 | + | out = append(out, line.String()) | |||
| 853 | + | line.Reset() | |||
| 854 | + | lineW = 0 | |||
| 855 | + | } | |||
| 856 | + | flushWord := func() { | |||
| 857 | + | if word.Len() == 0 { | |||
| 858 | + | return | |||
| 859 | + | } | |||
| 860 | + | if lineW+wordW > limit && lineW > 0 { | |||
| 861 | + | flushLine() | |||
| 862 | + | } | |||
| 863 | + | // If the word alone is bigger than the limit, split it by runes. | |||
| 864 | + | if wordW > limit { | |||
| 865 | + | wr := []rune(word.String()) | |||
| 866 | + | for i := 0; i < len(wr); { | |||
| 867 | + | r := wr[i] | |||
| 868 | + | if r == 0x1b && i+1 < len(wr) && wr[i+1] == '[' { | |||
| 869 | + | line.WriteRune(r) | |||
| 870 | + | line.WriteRune(wr[i+1]) | |||
| 871 | + | i += 2 | |||
| 872 | + | for i < len(wr) { | |||
| 873 | + | c := wr[i] | |||
| 874 | + | line.WriteRune(c) | |||
| 875 | + | i++ | |||
| 876 | + | if c >= 0x40 && c <= 0x7e { | |||
| 877 | + | break | |||
| 878 | + | } | |||
| 879 | + | } | |||
| 880 | + | continue | |||
| 881 | + | } | |||
| 882 | + | rw := runewidth.RuneWidth(r) | |||
| 883 | + | if lineW+rw > limit && lineW > 0 { | |||
| 884 | + | flushLine() | |||
| 885 | + | } | |||
| 886 | + | line.WriteRune(r) | |||
| 887 | + | lineW += rw | |||
| 888 | + | i++ | |||
| 889 | + | } | |||
| 890 | + | word.Reset() | |||
| 891 | + | wordW = 0 | |||
| 892 | + | return | |||
| 893 | + | } | |||
| 894 | + | line.WriteString(word.String()) | |||
| 895 | + | lineW += wordW | |||
| 896 | + | word.Reset() | |||
| 897 | + | wordW = 0 | |||
| 898 | + | } | |||
| 899 | + | ||||
| 900 | + | for i := 0; i < len(runes); { | |||
| 901 | + | r := runes[i] | |||
| 902 | + | if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' { | |||
| 903 | + | word.WriteRune(r) | |||
| 904 | + | word.WriteRune(runes[i+1]) | |||
| 905 | + | i += 2 | |||
| 906 | + | for i < len(runes) { | |||
| 907 | + | c := runes[i] | |||
| 908 | + | word.WriteRune(c) | |||
| 909 | + | i++ | |||
| 910 | + | if c >= 0x40 && c <= 0x7e { | |||
| 911 | + | break | |||
| 912 | + | } | |||
| 913 | + | } | |||
| 914 | + | continue | |||
| 915 | + | } | |||
| 916 | + | if r == ' ' || r == '\t' { | |||
| 917 | + | flushWord() | |||
| 918 | + | rw := runewidth.RuneWidth(r) | |||
| 919 | + | if lineW+rw > limit && lineW > 0 { | |||
| 920 | + | flushLine() | |||
| 921 | + | i++ | |||
| 922 | + | continue | |||
| 923 | + | } | |||
| 924 | + | line.WriteRune(r) | |||
| 925 | + | lineW += rw | |||
| 926 | + | i++ | |||
| 927 | + | continue | |||
| 928 | + | } | |||
| 929 | + | word.WriteRune(r) | |||
| 930 | + | wordW += runewidth.RuneWidth(r) | |||
| 931 | + | i++ | |||
| 932 | + | } | |||
| 933 | + | flushWord() | |||
| 934 | + | if line.Len() > 0 { | |||
| 935 | + | flushLine() | |||
| 936 | + | } | |||
| 937 | + | if len(out) == 0 { | |||
| 938 | + | out = []string{""} | |||
| 939 | + | } | |||
| 940 | + | return out | |||
| 941 | + | } | |||
| 942 | + | ||||
| 832 | func wrapLine(s string, width int, cont string) []string { | 943 | func wrapLine(s string, width int, cont string) []string { | ||
| 833 | if width <= 0 { | 944 | if width <= 0 { | ||
| 834 | return []string{s} | 945 | return []string{s} | ||
M internal/tui/view.go +3 -1
| @@ -435,7 +435,9 @@ func (v *View) renderMessage(m provider.Message, width int) []string { | |||
| 435 | 435 | case provider.TextBlock: | |
| 436 | 436 | md := RenderMarkdown(b.Text, v.Theme, inner) | |
| 437 | 437 | for _, l := range strings.Split(md, "\n") { | |
| 438 | - | lines = append(lines, indent+l) | |
| 438 | + | for _, w := range wrapANSILine(l, inner) { | |
| 439 | + | lines = append(lines, indent+w) | |
| 440 | + | } | |
| 439 | 441 | } | |
| 440 | 442 | case provider.ToolCallBlock: | |
| 441 | 443 | // Rule above the tool header frames the call as a | |
| @@ -435,7 +435,9 @@ func (v *View) renderMessage(m provider.Message, width int) []string { | |||||
| 435 | case provider.TextBlock: | 435 | case provider.TextBlock: | ||
| 436 | md := RenderMarkdown(b.Text, v.Theme, inner) | 436 | md := RenderMarkdown(b.Text, v.Theme, inner) | ||
| 437 | for _, l := range strings.Split(md, "\n") { | 437 | for _, l := range strings.Split(md, "\n") { | ||
| 438 | - | lines = append(lines, indent+l) | 438 | + | for _, w := range wrapANSILine(l, inner) { |
| 439 | + | lines = append(lines, indent+w) | |||
| 440 | + | } | |||
| 439 | } | 441 | } | ||
| 440 | case provider.ToolCallBlock: | 442 | case provider.ToolCallBlock: | ||
| 441 | // Rule above the tool header frames the call as a | 443 | // Rule above the tool header frames the call as a | ||