detached 569d28d

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 {
829829 return string(out)
830830 }
831831
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+
832943 func wrapLine(s string, width int, cont string) []string {
833944 if width <= 0 {
834945 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 {
435435 case provider.TextBlock:
436436 md := RenderMarkdown(b.Text, v.Theme, inner)
437437 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+ }
439441 }
440442 case provider.ToolCallBlock:
441443 // 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 a443 // Rule above the tool header frames the call as a

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close