fix(tui): sanitize untrusted text before layout to stop streaming glitches (#228)
* fix(tui): sanitize streamed bash commands before drawing tool boxes * refactor(tui): sanitize all untrusted text through one terminal-safe entry point Route user, assistant, tool, plan, and error text through sanitizeLine before layout, make visibleWidth count stray tabs at full width, and truncate tool headers by cell width. A fuzz test asserts every rendered row is control-free and box rows span the full width.
- commit
- ba2157039ad2ed19f695d369705cd33e59342498
- author
- bnema · · 2026-09-26 06:21 UTC
- committer
- GitHub · · 2026-09-26 06:21 UTC
- parent
- 22e42bf
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
6 files · +348 -177
M packages/tui/editor.go +7 -1
| @@ -891,8 +891,14 @@ func normalizeEditorText(s string) string { | |||
| 891 | 891 | return strings.ReplaceAll(s, "\r", "\n") | |
| 892 | 892 | } | |
| 893 | 893 | ||
| 894 | + | // visibleWidth returns the terminal cells s occupies, ignoring CSI and | |
| 895 | + | // OSC escapes. Text should be sanitized before layout; if a tab slips | |
| 896 | + | // through anyway it counts as a full terminal tab stop, the most cells | |
| 897 | + | // it can advance, so a drawn row is never wider than its measured width | |
| 898 | + | // and cannot wrap onto the next terminal row. | |
| 894 | 899 | func visibleWidth(s string) int { | |
| 895 | - | return runewidth.StringWidth(stripANSI(s)) | |
| 900 | + | plain := stripANSI(s) | |
| 901 | + | return runewidth.StringWidth(plain) + strings.Count(plain, "\t")*terminalTabStop | |
| 896 | 902 | } | |
| 897 | 903 | ||
| 898 | 904 | func stripANSI(s string) string { | |
| @@ -891,8 +891,14 @@ func normalizeEditorText(s string) string { | |||||
| 891 | return strings.ReplaceAll(s, "\r", "\n") | 891 | return strings.ReplaceAll(s, "\r", "\n") | ||
| 892 | } | 892 | } | ||
| 893 | 893 | ||||
| 894 | + | // visibleWidth returns the terminal cells s occupies, ignoring CSI and | |||
| 895 | + | // OSC escapes. Text should be sanitized before layout; if a tab slips | |||
| 896 | + | // through anyway it counts as a full terminal tab stop, the most cells | |||
| 897 | + | // it can advance, so a drawn row is never wider than its measured width | |||
| 898 | + | // and cannot wrap onto the next terminal row. | |||
| 894 | func visibleWidth(s string) int { | 899 | func visibleWidth(s string) int { | ||
| 895 | - | return runewidth.StringWidth(stripANSI(s)) | 900 | + | plain := stripANSI(s) |
| 901 | + | return runewidth.StringWidth(plain) + strings.Count(plain, "\t")*terminalTabStop | |||
| 896 | } | 902 | } | ||
| 897 | 903 | ||||
| 898 | func stripANSI(s string) string { | 904 | func stripANSI(s string) string { | ||
M packages/tui/markdown.go +6 -0
| @@ -37,6 +37,12 @@ func renderMarkdownRaw(lines []string, th Theme, width int, boundary func(line, | |||
| 37 | 37 | if width <= 0 { | |
| 38 | 38 | width = 80 | |
| 39 | 39 | } | |
| 40 | + | // Sanitize per line so boundary indexes still match the caller's lines. | |
| 41 | + | safe := make([]string, len(lines)) | |
| 42 | + | for i, l := range lines { | |
| 43 | + | safe[i] = sanitizeLine(l, codeTabStop) | |
| 44 | + | } | |
| 45 | + | lines = safe | |
| 40 | 46 | ||
| 41 | 47 | var out strings.Builder | |
| 42 | 48 | var fenceBuf strings.Builder | |
| @@ -37,6 +37,12 @@ func renderMarkdownRaw(lines []string, th Theme, width int, boundary func(line, | |||||
| 37 | if width <= 0 { | 37 | if width <= 0 { | ||
| 38 | width = 80 | 38 | width = 80 | ||
| 39 | } | 39 | } | ||
| 40 | + | // Sanitize per line so boundary indexes still match the caller's lines. | |||
| 41 | + | safe := make([]string, len(lines)) | |||
| 42 | + | for i, l := range lines { | |||
| 43 | + | safe[i] = sanitizeLine(l, codeTabStop) | |||
| 44 | + | } | |||
| 45 | + | lines = safe | |||
| 40 | 46 | ||||
| 41 | var out strings.Builder | 47 | var out strings.Builder | ||
| 42 | var fenceBuf strings.Builder | 48 | var fenceBuf strings.Builder | ||
A packages/tui/sanitize.go +146 -0
| @@ -0,0 +1,146 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "unicode" | |
| 6 | + | "unicode/utf8" | |
| 7 | + | ||
| 8 | + | "github.com/mattn/go-runewidth" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Tab stops used when expanding tabs. Code uses 4 columns so Go and | |
| 12 | + | // Makefiles stay compact; shell output keeps the terminal's 8 so tables | |
| 13 | + | // printed by commands such as ls or column stay aligned. | |
| 14 | + | const ( | |
| 15 | + | codeTabStop = 4 | |
| 16 | + | terminalTabStop = 8 | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Text from models, tools, and paste is untrusted terminal input. Layout | |
| 20 | + | // code measures rows with visibleWidth and pads box edges from that | |
| 21 | + | // measurement, so every such string must pass through sanitizeText or | |
| 22 | + | // sanitizeLine before layout. Tabs, carriage returns, escape sequences, and | |
| 23 | + | // other controls move the real cursor differently from what visibleWidth | |
| 24 | + | // counts, which misplaces box edges and leaves stale cells on screen. | |
| 25 | + | ||
| 26 | + | // splitLines splits s into rows, treating CRLF and lone CR as line breaks | |
| 27 | + | // so carriage-return progress output becomes separate rows. | |
| 28 | + | func splitLines(s string) []string { | |
| 29 | + | if strings.IndexByte(s, '\r') >= 0 { | |
| 30 | + | s = strings.ReplaceAll(s, "\r\n", "\n") | |
| 31 | + | s = strings.ReplaceAll(s, "\r", "\n") | |
| 32 | + | } | |
| 33 | + | return strings.Split(s, "\n") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // sanitizeText returns s with every row passed through sanitizeLine. | |
| 37 | + | // Line breaks (LF, CRLF, CR) are kept as "\n". | |
| 38 | + | func sanitizeText(s string, tabStop int) string { | |
| 39 | + | if isPlainText(s, true) { | |
| 40 | + | return s | |
| 41 | + | } | |
| 42 | + | lines := splitLines(s) | |
| 43 | + | for i, l := range lines { | |
| 44 | + | lines[i] = sanitizeLine(l, tabStop) | |
| 45 | + | } | |
| 46 | + | return strings.Join(lines, "\n") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // sanitizeLine makes one row safe to measure and draw: tabs expand to | |
| 50 | + | // spaces at tabStop columns, line breaks become spaces, and escape | |
| 51 | + | // sequences, other control characters, and invalid UTF-8 are dropped. | |
| 52 | + | // Styling is applied by the renderer afterwards, so no incoming escape | |
| 53 | + | // sequence is kept. | |
| 54 | + | func sanitizeLine(s string, tabStop int) string { | |
| 55 | + | if isPlainText(s, false) { | |
| 56 | + | return s | |
| 57 | + | } | |
| 58 | + | var b strings.Builder | |
| 59 | + | b.Grow(len(s)) | |
| 60 | + | col := 0 | |
| 61 | + | for i := 0; i < len(s); { | |
| 62 | + | if s[i] == 0x1b { | |
| 63 | + | i = skipEscapeSequence(s, i) | |
| 64 | + | continue | |
| 65 | + | } | |
| 66 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 67 | + | i += size | |
| 68 | + | switch { | |
| 69 | + | case r == '\t': | |
| 70 | + | n := tabStop - col%tabStop | |
| 71 | + | b.WriteString(strings.Repeat(" ", n)) | |
| 72 | + | col += n | |
| 73 | + | case r == '\n' || r == '\r': | |
| 74 | + | b.WriteByte(' ') | |
| 75 | + | col++ | |
| 76 | + | case r == utf8.RuneError && size == 1, r < 0x20, r >= 0x7f && r < 0xa0: | |
| 77 | + | // Invalid byte, C0/C1 control, or DEL: not printable. | |
| 78 | + | case unicode.Is(unicode.Cf, r): | |
| 79 | + | // Invisible format characters (bidi marks, joiners, prepended | |
| 80 | + | // marks). Terminals disagree on how they join neighbouring | |
| 81 | + | // cells, so their drawn width cannot be predicted. | |
| 82 | + | default: | |
| 83 | + | b.WriteRune(r) | |
| 84 | + | col += runewidth.RuneWidth(r) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return b.String() | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // isPlainText reports whether s is printable ASCII (plus LF when | |
| 91 | + | // allowNewline), the common case that needs no rewriting. | |
| 92 | + | func isPlainText(s string, allowNewline bool) bool { | |
| 93 | + | for i := 0; i < len(s); i++ { | |
| 94 | + | c := s[i] | |
| 95 | + | if c == '\n' && allowNewline { | |
| 96 | + | continue | |
| 97 | + | } | |
| 98 | + | if c < 0x20 || c >= 0x7f { | |
| 99 | + | return false | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | return true | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // skipEscapeSequence returns the index just past the escape sequence | |
| 106 | + | // starting at s[i]. | |
| 107 | + | func skipEscapeSequence(s string, i int) int { | |
| 108 | + | if i >= len(s) || s[i] != 0x1b { | |
| 109 | + | return i + 1 | |
| 110 | + | } | |
| 111 | + | if i+1 >= len(s) { | |
| 112 | + | return len(s) | |
| 113 | + | } | |
| 114 | + | switch s[i+1] { | |
| 115 | + | case '[': // CSI: ESC [ ... final byte 0x40-0x7e | |
| 116 | + | j := i + 2 | |
| 117 | + | for j < len(s) { | |
| 118 | + | c := s[j] | |
| 119 | + | j++ | |
| 120 | + | if c >= 0x40 && c <= 0x7e { | |
| 121 | + | break | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | return j | |
| 125 | + | case ']': // OSC: ESC ] ... BEL or ST | |
| 126 | + | return skipStringEscape(s, i+2) | |
| 127 | + | case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc. | |
| 128 | + | return skipStringEscape(s, i+2) | |
| 129 | + | default: | |
| 130 | + | // Two-byte escape (cursor save/restore, charset select, etc.). | |
| 131 | + | return i + 2 | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func skipStringEscape(s string, i int) int { | |
| 136 | + | for i < len(s) { | |
| 137 | + | if s[i] == 0x07 { // BEL | |
| 138 | + | return i + 1 | |
| 139 | + | } | |
| 140 | + | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST | |
| 141 | + | return i + 2 | |
| 142 | + | } | |
| 143 | + | i++ | |
| 144 | + | } | |
| 145 | + | return len(s) | |
| 146 | + | } | |
| @@ -0,0 +1,146 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "unicode" | |||
| 6 | + | "unicode/utf8" | |||
| 7 | + | ||||
| 8 | + | "github.com/mattn/go-runewidth" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Tab stops used when expanding tabs. Code uses 4 columns so Go and | |||
| 12 | + | // Makefiles stay compact; shell output keeps the terminal's 8 so tables | |||
| 13 | + | // printed by commands such as ls or column stay aligned. | |||
| 14 | + | const ( | |||
| 15 | + | codeTabStop = 4 | |||
| 16 | + | terminalTabStop = 8 | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Text from models, tools, and paste is untrusted terminal input. Layout | |||
| 20 | + | // code measures rows with visibleWidth and pads box edges from that | |||
| 21 | + | // measurement, so every such string must pass through sanitizeText or | |||
| 22 | + | // sanitizeLine before layout. Tabs, carriage returns, escape sequences, and | |||
| 23 | + | // other controls move the real cursor differently from what visibleWidth | |||
| 24 | + | // counts, which misplaces box edges and leaves stale cells on screen. | |||
| 25 | + | ||||
| 26 | + | // splitLines splits s into rows, treating CRLF and lone CR as line breaks | |||
| 27 | + | // so carriage-return progress output becomes separate rows. | |||
| 28 | + | func splitLines(s string) []string { | |||
| 29 | + | if strings.IndexByte(s, '\r') >= 0 { | |||
| 30 | + | s = strings.ReplaceAll(s, "\r\n", "\n") | |||
| 31 | + | s = strings.ReplaceAll(s, "\r", "\n") | |||
| 32 | + | } | |||
| 33 | + | return strings.Split(s, "\n") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // sanitizeText returns s with every row passed through sanitizeLine. | |||
| 37 | + | // Line breaks (LF, CRLF, CR) are kept as "\n". | |||
| 38 | + | func sanitizeText(s string, tabStop int) string { | |||
| 39 | + | if isPlainText(s, true) { | |||
| 40 | + | return s | |||
| 41 | + | } | |||
| 42 | + | lines := splitLines(s) | |||
| 43 | + | for i, l := range lines { | |||
| 44 | + | lines[i] = sanitizeLine(l, tabStop) | |||
| 45 | + | } | |||
| 46 | + | return strings.Join(lines, "\n") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // sanitizeLine makes one row safe to measure and draw: tabs expand to | |||
| 50 | + | // spaces at tabStop columns, line breaks become spaces, and escape | |||
| 51 | + | // sequences, other control characters, and invalid UTF-8 are dropped. | |||
| 52 | + | // Styling is applied by the renderer afterwards, so no incoming escape | |||
| 53 | + | // sequence is kept. | |||
| 54 | + | func sanitizeLine(s string, tabStop int) string { | |||
| 55 | + | if isPlainText(s, false) { | |||
| 56 | + | return s | |||
| 57 | + | } | |||
| 58 | + | var b strings.Builder | |||
| 59 | + | b.Grow(len(s)) | |||
| 60 | + | col := 0 | |||
| 61 | + | for i := 0; i < len(s); { | |||
| 62 | + | if s[i] == 0x1b { | |||
| 63 | + | i = skipEscapeSequence(s, i) | |||
| 64 | + | continue | |||
| 65 | + | } | |||
| 66 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |||
| 67 | + | i += size | |||
| 68 | + | switch { | |||
| 69 | + | case r == '\t': | |||
| 70 | + | n := tabStop - col%tabStop | |||
| 71 | + | b.WriteString(strings.Repeat(" ", n)) | |||
| 72 | + | col += n | |||
| 73 | + | case r == '\n' || r == '\r': | |||
| 74 | + | b.WriteByte(' ') | |||
| 75 | + | col++ | |||
| 76 | + | case r == utf8.RuneError && size == 1, r < 0x20, r >= 0x7f && r < 0xa0: | |||
| 77 | + | // Invalid byte, C0/C1 control, or DEL: not printable. | |||
| 78 | + | case unicode.Is(unicode.Cf, r): | |||
| 79 | + | // Invisible format characters (bidi marks, joiners, prepended | |||
| 80 | + | // marks). Terminals disagree on how they join neighbouring | |||
| 81 | + | // cells, so their drawn width cannot be predicted. | |||
| 82 | + | default: | |||
| 83 | + | b.WriteRune(r) | |||
| 84 | + | col += runewidth.RuneWidth(r) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | return b.String() | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // isPlainText reports whether s is printable ASCII (plus LF when | |||
| 91 | + | // allowNewline), the common case that needs no rewriting. | |||
| 92 | + | func isPlainText(s string, allowNewline bool) bool { | |||
| 93 | + | for i := 0; i < len(s); i++ { | |||
| 94 | + | c := s[i] | |||
| 95 | + | if c == '\n' && allowNewline { | |||
| 96 | + | continue | |||
| 97 | + | } | |||
| 98 | + | if c < 0x20 || c >= 0x7f { | |||
| 99 | + | return false | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | return true | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // skipEscapeSequence returns the index just past the escape sequence | |||
| 106 | + | // starting at s[i]. | |||
| 107 | + | func skipEscapeSequence(s string, i int) int { | |||
| 108 | + | if i >= len(s) || s[i] != 0x1b { | |||
| 109 | + | return i + 1 | |||
| 110 | + | } | |||
| 111 | + | if i+1 >= len(s) { | |||
| 112 | + | return len(s) | |||
| 113 | + | } | |||
| 114 | + | switch s[i+1] { | |||
| 115 | + | case '[': // CSI: ESC [ ... final byte 0x40-0x7e | |||
| 116 | + | j := i + 2 | |||
| 117 | + | for j < len(s) { | |||
| 118 | + | c := s[j] | |||
| 119 | + | j++ | |||
| 120 | + | if c >= 0x40 && c <= 0x7e { | |||
| 121 | + | break | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | return j | |||
| 125 | + | case ']': // OSC: ESC ] ... BEL or ST | |||
| 126 | + | return skipStringEscape(s, i+2) | |||
| 127 | + | case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc. | |||
| 128 | + | return skipStringEscape(s, i+2) | |||
| 129 | + | default: | |||
| 130 | + | // Two-byte escape (cursor save/restore, charset select, etc.). | |||
| 131 | + | return i + 2 | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func skipStringEscape(s string, i int) int { | |||
| 136 | + | for i < len(s) { | |||
| 137 | + | if s[i] == 0x07 { // BEL | |||
| 138 | + | return i + 1 | |||
| 139 | + | } | |||
| 140 | + | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST | |||
| 141 | + | return i + 2 | |||
| 142 | + | } | |||
| 143 | + | i++ | |||
| 144 | + | } | |||
| 145 | + | return len(s) | |||
| 146 | + | } | |||
A packages/tui/terminal_safe_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | + | "github.com/mattn/go-runewidth" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // FuzzViewRowsStayTerminalSafe feeds untrusted text through every entry | |
| 13 | + | // point that reaches the chat view and checks the rendered rows. Any path | |
| 14 | + | // that forgets to sanitize shows up as a control byte or a misaligned | |
| 15 | + | // box edge. | |
| 16 | + | func FuzzViewRowsStayTerminalSafe(f *testing.F) { | |
| 17 | + | for _, seed := range []string{ | |
| 18 | + | "plain text", | |
| 19 | + | "old=\"\"\"\t\t\tdrainAllFrames(sends)\n\tif tt.saturated {", | |
| 20 | + | "progress 10%\rprogress 100%\r\n", | |
| 21 | + | "\x1b[2J\x1b[H\x1b]0;title\x07\x1bPdcs\x1b\\bell\a back\b", | |
| 22 | + | "--- a.go\n+++ a.go\n@@ -1,2 +1,2 @@\n-\told\n+\tnew\n \tctx", | |
| 23 | + | "```go\nfunc main() {\n\tprintln(1)\n}\n```", | |
| 24 | + | "wide 日本語\tcol\x7f\u0085\xff", | |
| 25 | + | "\u0604", // invisible format character | |
| 26 | + | "00\n\r0\t", // CR inside a numbered-file gutter | |
| 27 | + | "&0&0\u05a30", // zero-width mark in a truncated tool header | |
| 28 | + | } { | |
| 29 | + | f.Add(seed) | |
| 30 | + | } | |
| 31 | + | f.Fuzz(func(t *testing.T, text string) { | |
| 32 | + | for _, width := range []int{40, 100} { | |
| 33 | + | assertTerminalSafeRows(t, transcriptView(text).Build(width), width) | |
| 34 | + | for _, v := range liveViews(text) { | |
| 35 | + | assertTerminalSafeRows(t, v.BuildLive(width), width) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func transcriptView(text string) *View { | |
| 42 | + | callArgs := func(k, v string) json.RawMessage { | |
| 43 | + | b, _ := json.Marshal(map[string]string{k: v}) | |
| 44 | + | return b | |
| 45 | + | } | |
| 46 | + | msgs := []provider.Message{ | |
| 47 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}, | |
| 48 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 49 | + | provider.TextBlock{Text: text}, | |
| 50 | + | provider.ToolCallBlock{ID: "t1", Name: "bash", Arguments: callArgs("command", text)}, | |
| 51 | + | provider.ToolCallBlock{ID: "t2", Name: "read", Arguments: callArgs("path", "a.go")}, | |
| 52 | + | provider.ToolCallBlock{ID: "t3", Name: "custom", Arguments: callArgs("q", text)}, | |
| 53 | + | }}, | |
| 54 | + | } | |
| 55 | + | for id, result := range map[string]string{ | |
| 56 | + | "t1": "$ " + text + "\n" + text + "\n[exit 0]", | |
| 57 | + | "t2": text, | |
| 58 | + | "t3": text, | |
| 59 | + | } { | |
| 60 | + | msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{ | |
| 61 | + | provider.ToolResultBlock{CallID: id, Content: []provider.Content{provider.TextBlock{Text: result}}}, | |
| 62 | + | }}) | |
| 63 | + | } | |
| 64 | + | return &View{Theme: Dark, ExpandAll: true, Messages: msgs, Err: text} | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func liveViews(text string) []*View { | |
| 68 | + | var out []*View | |
| 69 | + | for _, tc := range []struct{ name, field string }{ | |
| 70 | + | {"bash", "command"}, | |
| 71 | + | {"write", "content"}, | |
| 72 | + | } { | |
| 73 | + | args, _ := json.Marshal(map[string]string{tc.field: text, "path": "a.go"}) | |
| 74 | + | out = append(out, &View{Theme: Dark, ToolCalls: []ToolCallView{{ | |
| 75 | + | ID: "live", Name: tc.name, Args: ShortArgs(tc.name, args), RawJSONBuf: string(args), LivePath: "a.go", | |
| 76 | + | }}}) | |
| 77 | + | } | |
| 78 | + | edit, _ := json.Marshal(map[string]any{"path": "a.go", "edits": []map[string]string{{"oldText": "x", "newText": text}}}) | |
| 79 | + | out = append(out, &View{Theme: Dark, ToolCalls: []ToolCallView{{ | |
| 80 | + | ID: "edit", Name: "edit", RawJSONBuf: string(edit), LivePath: "a.go", | |
| 81 | + | }}}) | |
| 82 | + | out = append(out, &View{Theme: Dark, StreamingActive: true, Streaming: "x" + text, ToolCalls: []ToolCallView{{ | |
| 83 | + | ID: "res", Name: "bash", Args: "cmd", Result: text, Done: true, | |
| 84 | + | }}}) | |
| 85 | + | return out | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // assertTerminalSafeRows checks the invariant the renderer relies on: | |
| 89 | + | // once SGR color codes are removed, every row contains only printable | |
| 90 | + | // text, fits the terminal, and box side rows reach the right edge. | |
| 91 | + | func assertTerminalSafeRows(t *testing.T, rows []string, width int) { | |
| 92 | + | t.Helper() | |
| 93 | + | for i, row := range rows { | |
| 94 | + | plain := stripSGR(row) | |
| 95 | + | for _, r := range plain { | |
| 96 | + | if r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0) { | |
| 97 | + | t.Fatalf("row %d contains control %U: %q", i, r, row) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | w := runewidth.StringWidth(plain) | |
| 101 | + | if w > width { | |
| 102 | + | t.Fatalf("row %d is %d cols wide, terminal is %d: %q", i, w, width, plain) | |
| 103 | + | } | |
| 104 | + | trimmed := strings.TrimSpace(plain) | |
| 105 | + | if strings.HasPrefix(trimmed, "│") && w != width { | |
| 106 | + | t.Fatalf("box row %d is %d cols wide, want %d: %q", i, w, width, plain) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // stripSGR removes only CSI ... m color sequences. Any other escape is | |
| 112 | + | // left in place so the control check above flags it. | |
| 113 | + | func stripSGR(s string) string { | |
| 114 | + | var b strings.Builder | |
| 115 | + | for i := 0; i < len(s); { | |
| 116 | + | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { | |
| 117 | + | j := i + 2 | |
| 118 | + | for j < len(s) && s[j] >= 0x30 && s[j] <= 0x3f { | |
| 119 | + | j++ | |
| 120 | + | } | |
| 121 | + | if j < len(s) && s[j] == 'm' { | |
| 122 | + | i = j + 1 | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | b.WriteByte(s[i]) | |
| 127 | + | i++ | |
| 128 | + | } | |
| 129 | + | return b.String() | |
| 130 | + | } | |
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | + | "github.com/mattn/go-runewidth" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // FuzzViewRowsStayTerminalSafe feeds untrusted text through every entry | |||
| 13 | + | // point that reaches the chat view and checks the rendered rows. Any path | |||
| 14 | + | // that forgets to sanitize shows up as a control byte or a misaligned | |||
| 15 | + | // box edge. | |||
| 16 | + | func FuzzViewRowsStayTerminalSafe(f *testing.F) { | |||
| 17 | + | for _, seed := range []string{ | |||
| 18 | + | "plain text", | |||
| 19 | + | "old=\"\"\"\t\t\tdrainAllFrames(sends)\n\tif tt.saturated {", | |||
| 20 | + | "progress 10%\rprogress 100%\r\n", | |||
| 21 | + | "\x1b[2J\x1b[H\x1b]0;title\x07\x1bPdcs\x1b\\bell\a back\b", | |||
| 22 | + | "--- a.go\n+++ a.go\n@@ -1,2 +1,2 @@\n-\told\n+\tnew\n \tctx", | |||
| 23 | + | "```go\nfunc main() {\n\tprintln(1)\n}\n```", | |||
| 24 | + | "wide 日本語\tcol\x7f\u0085\xff", | |||
| 25 | + | "\u0604", // invisible format character | |||
| 26 | + | "00\n\r0\t", // CR inside a numbered-file gutter | |||
| 27 | + | "&0&0\u05a30", // zero-width mark in a truncated tool header | |||
| 28 | + | } { | |||
| 29 | + | f.Add(seed) | |||
| 30 | + | } | |||
| 31 | + | f.Fuzz(func(t *testing.T, text string) { | |||
| 32 | + | for _, width := range []int{40, 100} { | |||
| 33 | + | assertTerminalSafeRows(t, transcriptView(text).Build(width), width) | |||
| 34 | + | for _, v := range liveViews(text) { | |||
| 35 | + | assertTerminalSafeRows(t, v.BuildLive(width), width) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func transcriptView(text string) *View { | |||
| 42 | + | callArgs := func(k, v string) json.RawMessage { | |||
| 43 | + | b, _ := json.Marshal(map[string]string{k: v}) | |||
| 44 | + | return b | |||
| 45 | + | } | |||
| 46 | + | msgs := []provider.Message{ | |||
| 47 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}, | |||
| 48 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |||
| 49 | + | provider.TextBlock{Text: text}, | |||
| 50 | + | provider.ToolCallBlock{ID: "t1", Name: "bash", Arguments: callArgs("command", text)}, | |||
| 51 | + | provider.ToolCallBlock{ID: "t2", Name: "read", Arguments: callArgs("path", "a.go")}, | |||
| 52 | + | provider.ToolCallBlock{ID: "t3", Name: "custom", Arguments: callArgs("q", text)}, | |||
| 53 | + | }}, | |||
| 54 | + | } | |||
| 55 | + | for id, result := range map[string]string{ | |||
| 56 | + | "t1": "$ " + text + "\n" + text + "\n[exit 0]", | |||
| 57 | + | "t2": text, | |||
| 58 | + | "t3": text, | |||
| 59 | + | } { | |||
| 60 | + | msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{ | |||
| 61 | + | provider.ToolResultBlock{CallID: id, Content: []provider.Content{provider.TextBlock{Text: result}}}, | |||
| 62 | + | }}) | |||
| 63 | + | } | |||
| 64 | + | return &View{Theme: Dark, ExpandAll: true, Messages: msgs, Err: text} | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func liveViews(text string) []*View { | |||
| 68 | + | var out []*View | |||
| 69 | + | for _, tc := range []struct{ name, field string }{ | |||
| 70 | + | {"bash", "command"}, | |||
| 71 | + | {"write", "content"}, | |||
| 72 | + | } { | |||
| 73 | + | args, _ := json.Marshal(map[string]string{tc.field: text, "path": "a.go"}) | |||
| 74 | + | out = append(out, &View{Theme: Dark, ToolCalls: []ToolCallView{{ | |||
| 75 | + | ID: "live", Name: tc.name, Args: ShortArgs(tc.name, args), RawJSONBuf: string(args), LivePath: "a.go", | |||
| 76 | + | }}}) | |||
| 77 | + | } | |||
| 78 | + | edit, _ := json.Marshal(map[string]any{"path": "a.go", "edits": []map[string]string{{"oldText": "x", "newText": text}}}) | |||
| 79 | + | out = append(out, &View{Theme: Dark, ToolCalls: []ToolCallView{{ | |||
| 80 | + | ID: "edit", Name: "edit", RawJSONBuf: string(edit), LivePath: "a.go", | |||
| 81 | + | }}}) | |||
| 82 | + | out = append(out, &View{Theme: Dark, StreamingActive: true, Streaming: "x" + text, ToolCalls: []ToolCallView{{ | |||
| 83 | + | ID: "res", Name: "bash", Args: "cmd", Result: text, Done: true, | |||
| 84 | + | }}}) | |||
| 85 | + | return out | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // assertTerminalSafeRows checks the invariant the renderer relies on: | |||
| 89 | + | // once SGR color codes are removed, every row contains only printable | |||
| 90 | + | // text, fits the terminal, and box side rows reach the right edge. | |||
| 91 | + | func assertTerminalSafeRows(t *testing.T, rows []string, width int) { | |||
| 92 | + | t.Helper() | |||
| 93 | + | for i, row := range rows { | |||
| 94 | + | plain := stripSGR(row) | |||
| 95 | + | for _, r := range plain { | |||
| 96 | + | if r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0) { | |||
| 97 | + | t.Fatalf("row %d contains control %U: %q", i, r, row) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | w := runewidth.StringWidth(plain) | |||
| 101 | + | if w > width { | |||
| 102 | + | t.Fatalf("row %d is %d cols wide, terminal is %d: %q", i, w, width, plain) | |||
| 103 | + | } | |||
| 104 | + | trimmed := strings.TrimSpace(plain) | |||
| 105 | + | if strings.HasPrefix(trimmed, "│") && w != width { | |||
| 106 | + | t.Fatalf("box row %d is %d cols wide, want %d: %q", i, w, width, plain) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // stripSGR removes only CSI ... m color sequences. Any other escape is | |||
| 112 | + | // left in place so the control check above flags it. | |||
| 113 | + | func stripSGR(s string) string { | |||
| 114 | + | var b strings.Builder | |||
| 115 | + | for i := 0; i < len(s); { | |||
| 116 | + | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { | |||
| 117 | + | j := i + 2 | |||
| 118 | + | for j < len(s) && s[j] >= 0x30 && s[j] <= 0x3f { | |||
| 119 | + | j++ | |||
| 120 | + | } | |||
| 121 | + | if j < len(s) && s[j] == 'm' { | |||
| 122 | + | i = j + 1 | |||
| 123 | + | continue | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | b.WriteByte(s[i]) | |||
| 127 | + | i++ | |||
| 128 | + | } | |||
| 129 | + | return b.String() | |||
| 130 | + | } | |||
M packages/tui/view.go +42 -176
| @@ -14,45 +14,6 @@ import ( | |||
| 14 | 14 | "github.com/mattn/go-runewidth" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | - | // expandTabs replaces tab characters with 4 spaces so code from | |
| 18 | - | // tab-indented languages (Go, Makefiles, etc.) renders at a | |
| 19 | - | // consistent width instead of the terminal's default 8-column tabs. | |
| 20 | - | func expandTabs(s string) string { | |
| 21 | - | return strings.ReplaceAll(s, "\t", " ") | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | // sanitizeUserBubbleLine prepares a single user-bubble row for safe | |
| 25 | - | // rendering. Pasted content from another terminal can contain | |
| 26 | - | // embedded ANSI escape sequences, control bytes, and tabs that | |
| 27 | - | // either reset the bubble's background colour or move the cursor in | |
| 28 | - | // ways that break the bubble's painted column. | |
| 29 | - | func sanitizeUserBubbleLine(s string) string { | |
| 30 | - | if s == "" { | |
| 31 | - | return s | |
| 32 | - | } | |
| 33 | - | s = expandTabs(s) | |
| 34 | - | var b strings.Builder | |
| 35 | - | b.Grow(len(s)) | |
| 36 | - | for i := 0; i < len(s); { | |
| 37 | - | c := s[i] | |
| 38 | - | if c == 0x1b { // ESC: drop CSI/OSC/DCS and simple escapes. | |
| 39 | - | i = skipEscapeSequence(s, i) | |
| 40 | - | continue | |
| 41 | - | } | |
| 42 | - | if c == '\r' || c == '\b' || c == 0x07 { | |
| 43 | - | i++ | |
| 44 | - | continue | |
| 45 | - | } | |
| 46 | - | if c < 0x20 || c == 0x7f { | |
| 47 | - | i++ | |
| 48 | - | continue | |
| 49 | - | } | |
| 50 | - | b.WriteByte(c) | |
| 51 | - | i++ | |
| 52 | - | } | |
| 53 | - | return b.String() | |
| 54 | - | } | |
| 55 | - | ||
| 56 | 17 | // pathFromToolArgs returns the "path" argument from a tool_call's | |
| 57 | 18 | // JSON arguments, or "" if the args aren't a JSON object or don't | |
| 58 | 19 | // include one. Used to pick a syntax language for rendering the | |
| @@ -501,7 +462,7 @@ func (v *View) renderErr(width int) []string { | |||
| 501 | 462 | var out []string | |
| 502 | 463 | // Each returned row must represent one terminal line, including when | |
| 503 | 464 | // the error contains explicit newlines or blank separator lines. | |
| 504 | - | for _, paragraph := range strings.Split(v.Err, "\n") { | |
| 465 | + | for _, paragraph := range splitLines(sanitizeText(v.Err, terminalTabStop)) { | |
| 505 | 466 | for _, line := range wrapLine(paragraph, wrapWidth, "") { | |
| 506 | 467 | prefix := indent | |
| 507 | 468 | if len(out) == 0 { | |
| @@ -979,7 +940,7 @@ func (v *View) renderPlanUpdate(update core.PlanUpdate, width int) []string { | |||
| 979 | 940 | var body []string | |
| 980 | 941 | if update.Explanation != nil { | |
| 981 | 942 | if explanation := strings.TrimSpace(*update.Explanation); explanation != "" { | |
| 982 | - | for _, line := range wrapLine(explanation, bodyWidth, "") { | |
| 943 | + | for _, line := range wrapLine(sanitizeLine(explanation, codeTabStop), bodyWidth, "") { | |
| 983 | 944 | body = append(body, Dim(Italic(line))) | |
| 984 | 945 | } | |
| 985 | 946 | } | |
| @@ -1000,7 +961,7 @@ func (v *View) renderPlanUpdate(update core.PlanUpdate, width int) []string { | |||
| 1000 | 961 | if stepWidth < 1 { | |
| 1001 | 962 | stepWidth = 1 | |
| 1002 | 963 | } | |
| 1003 | - | wrapped := wrapLine(step.Step, stepWidth, "") | |
| 964 | + | wrapped := wrapLine(sanitizeLine(step.Step, codeTabStop), stepWidth, "") | |
| 1004 | 965 | if len(wrapped) == 0 { | |
| 1005 | 966 | wrapped = []string{""} | |
| 1006 | 967 | } | |
| @@ -1100,8 +1061,8 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 1100 | 1061 | for _, c := range m.Content { | |
| 1101 | 1062 | switch b := c.(type) { | |
| 1102 | 1063 | case provider.TextBlock: | |
| 1103 | - | for _, l := range strings.Split(b.Text, "\n") { | |
| 1104 | - | l = sanitizeUserBubbleLine(l) | |
| 1064 | + | for _, l := range splitLines(b.Text) { | |
| 1065 | + | l = sanitizeLine(l, codeTabStop) | |
| 1105 | 1066 | for _, w := range wrapLine(l, innerWidth, "") { | |
| 1106 | 1067 | bubble = append(bubble, row(w)) | |
| 1107 | 1068 | } | |
| @@ -1562,7 +1523,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string { | |||
| 1562 | 1523 | } | |
| 1563 | 1524 | prompt := v.Theme.FGColor(v.Theme.Muted, "$ ") | |
| 1564 | 1525 | var out []string | |
| 1565 | - | for i, line := range strings.Split(command, "\n") { | |
| 1526 | + | for i, line := range splitLines(sanitizeText(command, terminalTabStop)) { | |
| 1566 | 1527 | firstPrefix := " " | |
| 1567 | 1528 | if i == 0 { | |
| 1568 | 1529 | firstPrefix += prompt | |
| @@ -1650,13 +1611,7 @@ func toolBoxTop(th Theme, label string, width int) string { | |||
| 1650 | 1611 | // Label overflows; truncate with an ellipsis so the right | |
| 1651 | 1612 | // corner still lands on the right edge. Rare on real | |
| 1652 | 1613 | // terminals (the chat column is usually wide enough). | |
| 1653 | - | over := -fill | |
| 1654 | - | runes := []rune(label) | |
| 1655 | - | if over+3 < len(runes) { | |
| 1656 | - | label = string(runes[:len(runes)-over-3]) + "..." | |
| 1657 | - | } else { | |
| 1658 | - | label = "..." | |
| 1659 | - | } | |
| 1614 | + | label = truncateWithEllipsis(label, visibleWidth(label)+fill) | |
| 1660 | 1615 | used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix) | |
| 1661 | 1616 | fill = w - used - 1 | |
| 1662 | 1617 | if fill < 0 { | |
| @@ -1668,8 +1623,22 @@ func toolBoxTop(th Theme, label string, width int) string { | |||
| 1668 | 1623 | return margin + th.FGColor(th.Muted, prefix) + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest+suffix+fillStr+"┐") + margin | |
| 1669 | 1624 | } | |
| 1670 | 1625 | ||
| 1626 | + | // truncateWithEllipsis shortens plain text to at most limit terminal | |
| 1627 | + | // cells, ending with "..." when it had to cut. Counting cells rather than | |
| 1628 | + | // runes keeps wide and zero-width characters from pushing the row past | |
| 1629 | + | // limit. | |
| 1630 | + | func truncateWithEllipsis(s string, limit int) string { | |
| 1631 | + | if visibleWidth(s) <= limit { | |
| 1632 | + | return s | |
| 1633 | + | } | |
| 1634 | + | if limit <= 3 { | |
| 1635 | + | return strings.Repeat(".", max(0, limit)) | |
| 1636 | + | } | |
| 1637 | + | return truncateToWidth(s, limit-3) + "..." | |
| 1638 | + | } | |
| 1639 | + | ||
| 1671 | 1640 | func oneLineToolLabel(label string) string { | |
| 1672 | - | return strings.Join(strings.Fields(label), " ") | |
| 1641 | + | return strings.Join(strings.Fields(sanitizeLine(label, 1)), " ") | |
| 1673 | 1642 | } | |
| 1674 | 1643 | ||
| 1675 | 1644 | func splitToolLabel(label string) (name, rest string) { | |
| @@ -1713,10 +1682,8 @@ func flatToolHeader(th Theme, label string, width int) string { | |||
| 1713 | 1682 | avail = 12 | |
| 1714 | 1683 | } | |
| 1715 | 1684 | if visibleWidth(name+rest) > avail { | |
| 1716 | - | over := visibleWidth(name+rest) - avail | |
| 1717 | - | runes := []rune(rest) | |
| 1718 | - | if over+3 < len(runes) { | |
| 1719 | - | rest = string(runes[:len(runes)-over-3]) + "..." | |
| 1685 | + | if visibleWidth(name)+3 < avail { | |
| 1686 | + | rest = truncateWithEllipsis(rest, avail-visibleWidth(name)) | |
| 1720 | 1687 | } else if visibleWidth(name) <= avail { | |
| 1721 | 1688 | rest = "" | |
| 1722 | 1689 | } | |
| @@ -1740,10 +1707,8 @@ func compactToolHeader(th Theme, label string, width int) string { | |||
| 1740 | 1707 | avail = 12 | |
| 1741 | 1708 | } | |
| 1742 | 1709 | if visibleWidth(name+rest) > avail { | |
| 1743 | - | over := visibleWidth(name+rest) - avail | |
| 1744 | - | runes := []rune(rest) | |
| 1745 | - | if over+3 < len(runes) { | |
| 1746 | - | rest = string(runes[:len(runes)-over-3]) + "..." | |
| 1710 | + | if visibleWidth(name)+3 < avail { | |
| 1711 | + | rest = truncateWithEllipsis(rest, avail-visibleWidth(name)) | |
| 1747 | 1712 | } else if visibleWidth(name) <= avail { | |
| 1748 | 1713 | rest = "" | |
| 1749 | 1714 | } | |
| @@ -2167,7 +2132,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor | |||
| 2167 | 2132 | ||
| 2168 | 2133 | // No truncation — the full tool output is rendered into chat and | |
| 2169 | 2134 | // becomes part of the scrollback you can page back through. | |
| 2170 | - | lines := strings.Split(text, "\n") | |
| 2135 | + | lines := splitLines(text) | |
| 2171 | 2136 | ||
| 2172 | 2137 | // Bash-result styling: when the first row looks like a shell | |
| 2173 | 2138 | // prompt line ("$ ...") emitted by the bash tool, style the | |
| @@ -2185,7 +2150,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor | |||
| 2185 | 2150 | // Detect diff header: "--- name" followed somewhere by "+++ name". | |
| 2186 | 2151 | if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") { | |
| 2187 | 2152 | inDiff = true | |
| 2188 | - | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, l)) | |
| 2153 | + | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, sanitizeLine(l, terminalTabStop))) | |
| 2189 | 2154 | continue | |
| 2190 | 2155 | } | |
| 2191 | 2156 | // Hunk header "@@ -a,b +c,d @@" resets the counters so patches | |
| @@ -2194,7 +2159,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor | |||
| 2194 | 2159 | if o, n, ok := parseHunkHeader(l); ok { | |
| 2195 | 2160 | oldLine, newLine = o, n | |
| 2196 | 2161 | } | |
| 2197 | - | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, l)) | |
| 2162 | + | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, sanitizeLine(l, terminalTabStop))) | |
| 2198 | 2163 | continue | |
| 2199 | 2164 | } | |
| 2200 | 2165 | if inDiff && strings.TrimSpace(l) == "..." { | |
| @@ -2220,7 +2185,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor | |||
| 2220 | 2185 | } | |
| 2221 | 2186 | } | |
| 2222 | 2187 | // Regular line. | |
| 2223 | - | for _, w := range wrapLine(l, width-4, " ") { | |
| 2188 | + | for _, w := range wrapLine(sanitizeLine(l, terminalTabStop), width-4, " ") { | |
| 2224 | 2189 | out = append(out, " "+v.Theme.FGColor(defaultColor, w)) | |
| 2225 | 2190 | } | |
| 2226 | 2191 | } | |
| @@ -2278,7 +2243,7 @@ func (v *View) renderDiffRow(line string, width int, color TerminalColor, lineNo | |||
| 2278 | 2243 | if len(line) == 0 { | |
| 2279 | 2244 | return []string{""} | |
| 2280 | 2245 | } | |
| 2281 | - | code := expandTabs(line[1:]) // strip the leading marker; expand tabs | |
| 2246 | + | code := sanitizeLine(line[1:], codeTabStop) // strip the leading marker | |
| 2282 | 2247 | ||
| 2283 | 2248 | // Gutter shape: sign + number share a color so they read as one | |
| 2284 | 2249 | // visual token ("+123") instead of a neutral line number next to | |
| @@ -2477,11 +2442,11 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||
| 2477 | 2442 | if idx < 0 || !numberedLineRE.MatchString(l) { | |
| 2478 | 2443 | // Non-code footer (e.g. "[truncated at 2000 lines]"). | |
| 2479 | 2444 | gutters = append(gutters, "") | |
| 2480 | - | codes = append(codes, l) | |
| 2445 | + | codes = append(codes, sanitizeLine(l, terminalTabStop)) | |
| 2481 | 2446 | continue | |
| 2482 | 2447 | } | |
| 2483 | - | gutter := l[:idx] + " " // replace tab with single space | |
| 2484 | - | code := expandTabs(l[idx+1:]) | |
| 2448 | + | gutter := sanitizeLine(l[:idx], 1) + " " // replace tab with single space | |
| 2449 | + | code := sanitizeLine(l[idx+1:], codeTabStop) | |
| 2485 | 2450 | gutters = append(gutters, gutter) | |
| 2486 | 2451 | codes = append(codes, code) | |
| 2487 | 2452 | } | |
| @@ -2529,7 +2494,9 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||
| 2529 | 2494 | // in muted type, everything else on the default tool-output color. | |
| 2530 | 2495 | // Called from renderToolText when the first line starts with "$ ". | |
| 2531 | 2496 | func (v *View) renderBashResult(lines []string, width int, defaultColor TerminalColor) []string { | |
| 2532 | - | lines = normalizeBashOutputLines(lines) | |
| 2497 | + | for i, l := range lines { | |
| 2498 | + | lines[i] = sanitizeLine(l, terminalTabStop) | |
| 2499 | + | } | |
| 2533 | 2500 | // Identify the footer line (exit + timing). The bash tool writes | |
| 2534 | 2501 | // it as the last non-empty line of the result. | |
| 2535 | 2502 | footerIdx := -1 | |
| @@ -2567,108 +2534,6 @@ func (v *View) renderBashResult(lines []string, width int, defaultColor Terminal | |||
| 2567 | 2534 | return out | |
| 2568 | 2535 | } | |
| 2569 | 2536 | ||
| 2570 | - | // normalizeBashOutputLines turns arbitrary terminal output into plain, | |
| 2571 | - | // box-safe rows before wrapping. Unlike read/write/edit output, bash | |
| 2572 | - | // stdout/stderr may contain tabs, carriage returns, ANSI/OSC escapes, | |
| 2573 | - | // and other C0 controls from subprocesses. If those reach the bordered | |
| 2574 | - | // tool box, the width calculator can undercount what the terminal will | |
| 2575 | - | // draw and the right edge appears broken. Keep printable text, expand | |
| 2576 | - | // tabs to spaces, split carriage-return progress rows, and drop escape | |
| 2577 | - | // / control sequences. | |
| 2578 | - | func normalizeBashOutputLines(lines []string) []string { | |
| 2579 | - | var out []string | |
| 2580 | - | for _, line := range lines { | |
| 2581 | - | for _, part := range strings.Split(strings.ReplaceAll(line, "\r", "\n"), "\n") { | |
| 2582 | - | out = append(out, normalizeBashOutputLine(part)) | |
| 2583 | - | } | |
| 2584 | - | } | |
| 2585 | - | if len(out) == 0 { | |
| 2586 | - | return []string{""} | |
| 2587 | - | } | |
| 2588 | - | return out | |
| 2589 | - | } | |
| 2590 | - | ||
| 2591 | - | func normalizeBashOutputLine(s string) string { | |
| 2592 | - | var b strings.Builder | |
| 2593 | - | col := 0 | |
| 2594 | - | for i := 0; i < len(s); { | |
| 2595 | - | c := s[i] | |
| 2596 | - | if c == 0x1b { // ESC: strip CSI/OSC/DCS and simple escapes. | |
| 2597 | - | i = skipEscapeSequence(s, i) | |
| 2598 | - | continue | |
| 2599 | - | } | |
| 2600 | - | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 2601 | - | if r == utf8.RuneError && size == 1 { | |
| 2602 | - | i++ | |
| 2603 | - | continue | |
| 2604 | - | } | |
| 2605 | - | switch r { | |
| 2606 | - | case '\t': | |
| 2607 | - | spaces := 8 - (col % 8) | |
| 2608 | - | if spaces == 0 { | |
| 2609 | - | spaces = 8 | |
| 2610 | - | } | |
| 2611 | - | b.WriteString(strings.Repeat(" ", spaces)) | |
| 2612 | - | col += spaces | |
| 2613 | - | case '\b': | |
| 2614 | - | // Backspace-overstrike output is common in spinners/progress bars. | |
| 2615 | - | // Dropping it is safer than moving the TUI cursor backwards. | |
| 2616 | - | case '\n', '\r': | |
| 2617 | - | // Already split by normalizeBashOutputLines. | |
| 2618 | - | default: | |
| 2619 | - | if r < 0x20 || r == 0x7f { | |
| 2620 | - | // Drop non-printing controls. | |
| 2621 | - | break | |
| 2622 | - | } | |
| 2623 | - | b.WriteRune(r) | |
| 2624 | - | col += runewidth.RuneWidth(r) | |
| 2625 | - | } | |
| 2626 | - | i += size | |
| 2627 | - | } | |
| 2628 | - | return b.String() | |
| 2629 | - | } | |
| 2630 | - | ||
| 2631 | - | func skipEscapeSequence(s string, i int) int { | |
| 2632 | - | if i >= len(s) || s[i] != 0x1b { | |
| 2633 | - | return i + 1 | |
| 2634 | - | } | |
| 2635 | - | if i+1 >= len(s) { | |
| 2636 | - | return len(s) | |
| 2637 | - | } | |
| 2638 | - | switch s[i+1] { | |
| 2639 | - | case '[': // CSI: ESC [ ... final byte 0x40-0x7e | |
| 2640 | - | j := i + 2 | |
| 2641 | - | for j < len(s) { | |
| 2642 | - | c := s[j] | |
| 2643 | - | j++ | |
| 2644 | - | if c >= 0x40 && c <= 0x7e { | |
| 2645 | - | break | |
| 2646 | - | } | |
| 2647 | - | } | |
| 2648 | - | return j | |
| 2649 | - | case ']': // OSC: ESC ] ... BEL or ST | |
| 2650 | - | return skipStringEscape(s, i+2) | |
| 2651 | - | case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc. | |
| 2652 | - | return skipStringEscape(s, i+2) | |
| 2653 | - | default: | |
| 2654 | - | // Two-byte escape (cursor save/restore, charset select, etc.). | |
| 2655 | - | return i + 2 | |
| 2656 | - | } | |
| 2657 | - | } | |
| 2658 | - | ||
| 2659 | - | func skipStringEscape(s string, i int) int { | |
| 2660 | - | for i < len(s) { | |
| 2661 | - | if s[i] == 0x07 { // BEL | |
| 2662 | - | return i + 1 | |
| 2663 | - | } | |
| 2664 | - | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST | |
| 2665 | - | return i + 2 | |
| 2666 | - | } | |
| 2667 | - | i++ | |
| 2668 | - | } | |
| 2669 | - | return len(s) | |
| 2670 | - | } | |
| 2671 | - | ||
| 2672 | 2537 | // astRewriteArgs reports whether a completed AST call requested mutation. | |
| 2673 | 2538 | func astRewriteArgs(name string, raw json.RawMessage) bool { | |
| 2674 | 2539 | if !strings.EqualFold(name, "ast") { | |
| @@ -2747,7 +2612,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st | |||
| 2747 | 2612 | oldLine++ | |
| 2748 | 2613 | newLine++ | |
| 2749 | 2614 | default: | |
| 2750 | - | for _, w := range wrapLine(l, width-4, " ") { | |
| 2615 | + | for _, w := range wrapLine(sanitizeLine(l, terminalTabStop), width-4, " ") { | |
| 2751 | 2616 | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, w)) | |
| 2752 | 2617 | } | |
| 2753 | 2618 | } | |
| @@ -2792,10 +2657,11 @@ func (v *View) renderRawFile(text, sourcePath string, startLine int) []string { | |||
| 2792 | 2657 | code := lines[:codeEnd] | |
| 2793 | 2658 | footer := lines[codeEnd:] | |
| 2794 | 2659 | ||
| 2795 | - | // Expand tabs to 4 spaces so Go / Makefile code renders | |
| 2796 | - | // at a consistent width. | |
| 2797 | 2660 | for i, c := range code { | |
| 2798 | - | code[i] = expandTabs(c) | |
| 2661 | + | code[i] = sanitizeLine(c, codeTabStop) | |
| 2662 | + | } | |
| 2663 | + | for i, f := range footer { | |
| 2664 | + | footer[i] = sanitizeLine(f, terminalTabStop) | |
| 2799 | 2665 | } | |
| 2800 | 2666 | ||
| 2801 | 2667 | lang := LanguageFromPath(sourcePath) | |
| @@ -14,45 +14,6 @@ import ( | |||||
| 14 | "github.com/mattn/go-runewidth" | 14 | "github.com/mattn/go-runewidth" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | - | // expandTabs replaces tab characters with 4 spaces so code from | |||
| 18 | - | // tab-indented languages (Go, Makefiles, etc.) renders at a | |||
| 19 | - | // consistent width instead of the terminal's default 8-column tabs. | |||
| 20 | - | func expandTabs(s string) string { | |||
| 21 | - | return strings.ReplaceAll(s, "\t", " ") | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | // sanitizeUserBubbleLine prepares a single user-bubble row for safe | |||
| 25 | - | // rendering. Pasted content from another terminal can contain | |||
| 26 | - | // embedded ANSI escape sequences, control bytes, and tabs that | |||
| 27 | - | // either reset the bubble's background colour or move the cursor in | |||
| 28 | - | // ways that break the bubble's painted column. | |||
| 29 | - | func sanitizeUserBubbleLine(s string) string { | |||
| 30 | - | if s == "" { | |||
| 31 | - | return s | |||
| 32 | - | } | |||
| 33 | - | s = expandTabs(s) | |||
| 34 | - | var b strings.Builder | |||
| 35 | - | b.Grow(len(s)) | |||
| 36 | - | for i := 0; i < len(s); { | |||
| 37 | - | c := s[i] | |||
| 38 | - | if c == 0x1b { // ESC: drop CSI/OSC/DCS and simple escapes. | |||
| 39 | - | i = skipEscapeSequence(s, i) | |||
| 40 | - | continue | |||
| 41 | - | } | |||
| 42 | - | if c == '\r' || c == '\b' || c == 0x07 { | |||
| 43 | - | i++ | |||
| 44 | - | continue | |||
| 45 | - | } | |||
| 46 | - | if c < 0x20 || c == 0x7f { | |||
| 47 | - | i++ | |||
| 48 | - | continue | |||
| 49 | - | } | |||
| 50 | - | b.WriteByte(c) | |||
| 51 | - | i++ | |||
| 52 | - | } | |||
| 53 | - | return b.String() | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | // pathFromToolArgs returns the "path" argument from a tool_call's | 17 | // pathFromToolArgs returns the "path" argument from a tool_call's | ||
| 57 | // JSON arguments, or "" if the args aren't a JSON object or don't | 18 | // JSON arguments, or "" if the args aren't a JSON object or don't | ||
| 58 | // include one. Used to pick a syntax language for rendering the | 19 | // include one. Used to pick a syntax language for rendering the | ||
| @@ -501,7 +462,7 @@ func (v *View) renderErr(width int) []string { | |||||
| 501 | var out []string | 462 | var out []string | ||
| 502 | // Each returned row must represent one terminal line, including when | 463 | // Each returned row must represent one terminal line, including when | ||
| 503 | // the error contains explicit newlines or blank separator lines. | 464 | // the error contains explicit newlines or blank separator lines. | ||
| 504 | - | for _, paragraph := range strings.Split(v.Err, "\n") { | 465 | + | for _, paragraph := range splitLines(sanitizeText(v.Err, terminalTabStop)) { |
| 505 | for _, line := range wrapLine(paragraph, wrapWidth, "") { | 466 | for _, line := range wrapLine(paragraph, wrapWidth, "") { | ||
| 506 | prefix := indent | 467 | prefix := indent | ||
| 507 | if len(out) == 0 { | 468 | if len(out) == 0 { | ||
| @@ -979,7 +940,7 @@ func (v *View) renderPlanUpdate(update core.PlanUpdate, width int) []string { | |||||
| 979 | var body []string | 940 | var body []string | ||
| 980 | if update.Explanation != nil { | 941 | if update.Explanation != nil { | ||
| 981 | if explanation := strings.TrimSpace(*update.Explanation); explanation != "" { | 942 | if explanation := strings.TrimSpace(*update.Explanation); explanation != "" { | ||
| 982 | - | for _, line := range wrapLine(explanation, bodyWidth, "") { | 943 | + | for _, line := range wrapLine(sanitizeLine(explanation, codeTabStop), bodyWidth, "") { |
| 983 | body = append(body, Dim(Italic(line))) | 944 | body = append(body, Dim(Italic(line))) | ||
| 984 | } | 945 | } | ||
| 985 | } | 946 | } | ||
| @@ -1000,7 +961,7 @@ func (v *View) renderPlanUpdate(update core.PlanUpdate, width int) []string { | |||||
| 1000 | if stepWidth < 1 { | 961 | if stepWidth < 1 { | ||
| 1001 | stepWidth = 1 | 962 | stepWidth = 1 | ||
| 1002 | } | 963 | } | ||
| 1003 | - | wrapped := wrapLine(step.Step, stepWidth, "") | 964 | + | wrapped := wrapLine(sanitizeLine(step.Step, codeTabStop), stepWidth, "") |
| 1004 | if len(wrapped) == 0 { | 965 | if len(wrapped) == 0 { | ||
| 1005 | wrapped = []string{""} | 966 | wrapped = []string{""} | ||
| 1006 | } | 967 | } | ||
| @@ -1100,8 +1061,8 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 1100 | for _, c := range m.Content { | 1061 | for _, c := range m.Content { | ||
| 1101 | switch b := c.(type) { | 1062 | switch b := c.(type) { | ||
| 1102 | case provider.TextBlock: | 1063 | case provider.TextBlock: | ||
| 1103 | - | for _, l := range strings.Split(b.Text, "\n") { | 1064 | + | for _, l := range splitLines(b.Text) { |
| 1104 | - | l = sanitizeUserBubbleLine(l) | 1065 | + | l = sanitizeLine(l, codeTabStop) |
| 1105 | for _, w := range wrapLine(l, innerWidth, "") { | 1066 | for _, w := range wrapLine(l, innerWidth, "") { | ||
| 1106 | bubble = append(bubble, row(w)) | 1067 | bubble = append(bubble, row(w)) | ||
| 1107 | } | 1068 | } | ||
| @@ -1562,7 +1523,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string { | |||||
| 1562 | } | 1523 | } | ||
| 1563 | prompt := v.Theme.FGColor(v.Theme.Muted, "$ ") | 1524 | prompt := v.Theme.FGColor(v.Theme.Muted, "$ ") | ||
| 1564 | var out []string | 1525 | var out []string | ||
| 1565 | - | for i, line := range strings.Split(command, "\n") { | 1526 | + | for i, line := range splitLines(sanitizeText(command, terminalTabStop)) { |
| 1566 | firstPrefix := " " | 1527 | firstPrefix := " " | ||
| 1567 | if i == 0 { | 1528 | if i == 0 { | ||
| 1568 | firstPrefix += prompt | 1529 | firstPrefix += prompt | ||
| @@ -1650,13 +1611,7 @@ func toolBoxTop(th Theme, label string, width int) string { | |||||
| 1650 | // Label overflows; truncate with an ellipsis so the right | 1611 | // Label overflows; truncate with an ellipsis so the right | ||
| 1651 | // corner still lands on the right edge. Rare on real | 1612 | // corner still lands on the right edge. Rare on real | ||
| 1652 | // terminals (the chat column is usually wide enough). | 1613 | // terminals (the chat column is usually wide enough). | ||
| 1653 | - | over := -fill | 1614 | + | label = truncateWithEllipsis(label, visibleWidth(label)+fill) |
| 1654 | - | runes := []rune(label) | |||
| 1655 | - | if over+3 < len(runes) { | |||
| 1656 | - | label = string(runes[:len(runes)-over-3]) + "..." | |||
| 1657 | - | } else { | |||
| 1658 | - | label = "..." | |||
| 1659 | - | } | |||
| 1660 | used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix) | 1615 | used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix) | ||
| 1661 | fill = w - used - 1 | 1616 | fill = w - used - 1 | ||
| 1662 | if fill < 0 { | 1617 | if fill < 0 { | ||
| @@ -1668,8 +1623,22 @@ func toolBoxTop(th Theme, label string, width int) string { | |||||
| 1668 | return margin + th.FGColor(th.Muted, prefix) + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest+suffix+fillStr+"┐") + margin | 1623 | return margin + th.FGColor(th.Muted, prefix) + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest+suffix+fillStr+"┐") + margin | ||
| 1669 | } | 1624 | } | ||
| 1670 | 1625 | ||||
| 1626 | + | // truncateWithEllipsis shortens plain text to at most limit terminal | |||
| 1627 | + | // cells, ending with "..." when it had to cut. Counting cells rather than | |||
| 1628 | + | // runes keeps wide and zero-width characters from pushing the row past | |||
| 1629 | + | // limit. | |||
| 1630 | + | func truncateWithEllipsis(s string, limit int) string { | |||
| 1631 | + | if visibleWidth(s) <= limit { | |||
| 1632 | + | return s | |||
| 1633 | + | } | |||
| 1634 | + | if limit <= 3 { | |||
| 1635 | + | return strings.Repeat(".", max(0, limit)) | |||
| 1636 | + | } | |||
| 1637 | + | return truncateToWidth(s, limit-3) + "..." | |||
| 1638 | + | } | |||
| 1639 | + | ||||
| 1671 | func oneLineToolLabel(label string) string { | 1640 | func oneLineToolLabel(label string) string { | ||
| 1672 | - | return strings.Join(strings.Fields(label), " ") | 1641 | + | return strings.Join(strings.Fields(sanitizeLine(label, 1)), " ") |
| 1673 | } | 1642 | } | ||
| 1674 | 1643 | ||||
| 1675 | func splitToolLabel(label string) (name, rest string) { | 1644 | func splitToolLabel(label string) (name, rest string) { | ||
| @@ -1713,10 +1682,8 @@ func flatToolHeader(th Theme, label string, width int) string { | |||||
| 1713 | avail = 12 | 1682 | avail = 12 | ||
| 1714 | } | 1683 | } | ||
| 1715 | if visibleWidth(name+rest) > avail { | 1684 | if visibleWidth(name+rest) > avail { | ||
| 1716 | - | over := visibleWidth(name+rest) - avail | 1685 | + | if visibleWidth(name)+3 < avail { |
| 1717 | - | runes := []rune(rest) | 1686 | + | rest = truncateWithEllipsis(rest, avail-visibleWidth(name)) |
| 1718 | - | if over+3 < len(runes) { | |||
| 1719 | - | rest = string(runes[:len(runes)-over-3]) + "..." | |||
| 1720 | } else if visibleWidth(name) <= avail { | 1687 | } else if visibleWidth(name) <= avail { | ||
| 1721 | rest = "" | 1688 | rest = "" | ||
| 1722 | } | 1689 | } | ||
| @@ -1740,10 +1707,8 @@ func compactToolHeader(th Theme, label string, width int) string { | |||||
| 1740 | avail = 12 | 1707 | avail = 12 | ||
| 1741 | } | 1708 | } | ||
| 1742 | if visibleWidth(name+rest) > avail { | 1709 | if visibleWidth(name+rest) > avail { | ||
| 1743 | - | over := visibleWidth(name+rest) - avail | 1710 | + | if visibleWidth(name)+3 < avail { |
| 1744 | - | runes := []rune(rest) | 1711 | + | rest = truncateWithEllipsis(rest, avail-visibleWidth(name)) |
| 1745 | - | if over+3 < len(runes) { | |||
| 1746 | - | rest = string(runes[:len(runes)-over-3]) + "..." | |||
| 1747 | } else if visibleWidth(name) <= avail { | 1712 | } else if visibleWidth(name) <= avail { | ||
| 1748 | rest = "" | 1713 | rest = "" | ||
| 1749 | } | 1714 | } | ||
| @@ -2167,7 +2132,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor | |||||
| 2167 | 2132 | ||||
| 2168 | // No truncation — the full tool output is rendered into chat and | 2133 | // No truncation — the full tool output is rendered into chat and | ||
| 2169 | // becomes part of the scrollback you can page back through. | 2134 | // becomes part of the scrollback you can page back through. | ||
| 2170 | - | lines := strings.Split(text, "\n") | 2135 | + | lines := splitLines(text) |
| 2171 | 2136 | ||||
| 2172 | // Bash-result styling: when the first row looks like a shell | 2137 | // Bash-result styling: when the first row looks like a shell | ||
| 2173 | // prompt line ("$ ...") emitted by the bash tool, style the | 2138 | // prompt line ("$ ...") emitted by the bash tool, style the | ||
| @@ -2185,7 +2150,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor | |||||
| 2185 | // Detect diff header: "--- name" followed somewhere by "+++ name". | 2150 | // Detect diff header: "--- name" followed somewhere by "+++ name". | ||
| 2186 | if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") { | 2151 | if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") { | ||
| 2187 | inDiff = true | 2152 | inDiff = true | ||
| 2188 | - | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, l)) | 2153 | + | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, sanitizeLine(l, terminalTabStop))) |
| 2189 | continue | 2154 | continue | ||
| 2190 | } | 2155 | } | ||
| 2191 | // Hunk header "@@ -a,b +c,d @@" resets the counters so patches | 2156 | // Hunk header "@@ -a,b +c,d @@" resets the counters so patches | ||
| @@ -2194,7 +2159,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor | |||||
| 2194 | if o, n, ok := parseHunkHeader(l); ok { | 2159 | if o, n, ok := parseHunkHeader(l); ok { | ||
| 2195 | oldLine, newLine = o, n | 2160 | oldLine, newLine = o, n | ||
| 2196 | } | 2161 | } | ||
| 2197 | - | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, l)) | 2162 | + | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, sanitizeLine(l, terminalTabStop))) |
| 2198 | continue | 2163 | continue | ||
| 2199 | } | 2164 | } | ||
| 2200 | if inDiff && strings.TrimSpace(l) == "..." { | 2165 | if inDiff && strings.TrimSpace(l) == "..." { | ||
| @@ -2220,7 +2185,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor | |||||
| 2220 | } | 2185 | } | ||
| 2221 | } | 2186 | } | ||
| 2222 | // Regular line. | 2187 | // Regular line. | ||
| 2223 | - | for _, w := range wrapLine(l, width-4, " ") { | 2188 | + | for _, w := range wrapLine(sanitizeLine(l, terminalTabStop), width-4, " ") { |
| 2224 | out = append(out, " "+v.Theme.FGColor(defaultColor, w)) | 2189 | out = append(out, " "+v.Theme.FGColor(defaultColor, w)) | ||
| 2225 | } | 2190 | } | ||
| 2226 | } | 2191 | } | ||
| @@ -2278,7 +2243,7 @@ func (v *View) renderDiffRow(line string, width int, color TerminalColor, lineNo | |||||
| 2278 | if len(line) == 0 { | 2243 | if len(line) == 0 { | ||
| 2279 | return []string{""} | 2244 | return []string{""} | ||
| 2280 | } | 2245 | } | ||
| 2281 | - | code := expandTabs(line[1:]) // strip the leading marker; expand tabs | 2246 | + | code := sanitizeLine(line[1:], codeTabStop) // strip the leading marker |
| 2282 | 2247 | ||||
| 2283 | // Gutter shape: sign + number share a color so they read as one | 2248 | // Gutter shape: sign + number share a color so they read as one | ||
| 2284 | // visual token ("+123") instead of a neutral line number next to | 2249 | // visual token ("+123") instead of a neutral line number next to | ||
| @@ -2477,11 +2442,11 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||||
| 2477 | if idx < 0 || !numberedLineRE.MatchString(l) { | 2442 | if idx < 0 || !numberedLineRE.MatchString(l) { | ||
| 2478 | // Non-code footer (e.g. "[truncated at 2000 lines]"). | 2443 | // Non-code footer (e.g. "[truncated at 2000 lines]"). | ||
| 2479 | gutters = append(gutters, "") | 2444 | gutters = append(gutters, "") | ||
| 2480 | - | codes = append(codes, l) | 2445 | + | codes = append(codes, sanitizeLine(l, terminalTabStop)) |
| 2481 | continue | 2446 | continue | ||
| 2482 | } | 2447 | } | ||
| 2483 | - | gutter := l[:idx] + " " // replace tab with single space | 2448 | + | gutter := sanitizeLine(l[:idx], 1) + " " // replace tab with single space |
| 2484 | - | code := expandTabs(l[idx+1:]) | 2449 | + | code := sanitizeLine(l[idx+1:], codeTabStop) |
| 2485 | gutters = append(gutters, gutter) | 2450 | gutters = append(gutters, gutter) | ||
| 2486 | codes = append(codes, code) | 2451 | codes = append(codes, code) | ||
| 2487 | } | 2452 | } | ||
| @@ -2529,7 +2494,9 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||||
| 2529 | // in muted type, everything else on the default tool-output color. | 2494 | // in muted type, everything else on the default tool-output color. | ||
| 2530 | // Called from renderToolText when the first line starts with "$ ". | 2495 | // Called from renderToolText when the first line starts with "$ ". | ||
| 2531 | func (v *View) renderBashResult(lines []string, width int, defaultColor TerminalColor) []string { | 2496 | func (v *View) renderBashResult(lines []string, width int, defaultColor TerminalColor) []string { | ||
| 2532 | - | lines = normalizeBashOutputLines(lines) | 2497 | + | for i, l := range lines { |
| 2498 | + | lines[i] = sanitizeLine(l, terminalTabStop) | |||
| 2499 | + | } | |||
| 2533 | // Identify the footer line (exit + timing). The bash tool writes | 2500 | // Identify the footer line (exit + timing). The bash tool writes | ||
| 2534 | // it as the last non-empty line of the result. | 2501 | // it as the last non-empty line of the result. | ||
| 2535 | footerIdx := -1 | 2502 | footerIdx := -1 | ||
| @@ -2567,108 +2534,6 @@ func (v *View) renderBashResult(lines []string, width int, defaultColor Terminal | |||||
| 2567 | return out | 2534 | return out | ||
| 2568 | } | 2535 | } | ||
| 2569 | 2536 | ||||
| 2570 | - | // normalizeBashOutputLines turns arbitrary terminal output into plain, | |||
| 2571 | - | // box-safe rows before wrapping. Unlike read/write/edit output, bash | |||
| 2572 | - | // stdout/stderr may contain tabs, carriage returns, ANSI/OSC escapes, | |||
| 2573 | - | // and other C0 controls from subprocesses. If those reach the bordered | |||
| 2574 | - | // tool box, the width calculator can undercount what the terminal will | |||
| 2575 | - | // draw and the right edge appears broken. Keep printable text, expand | |||
| 2576 | - | // tabs to spaces, split carriage-return progress rows, and drop escape | |||
| 2577 | - | // / control sequences. | |||
| 2578 | - | func normalizeBashOutputLines(lines []string) []string { | |||
| 2579 | - | var out []string | |||
| 2580 | - | for _, line := range lines { | |||
| 2581 | - | for _, part := range strings.Split(strings.ReplaceAll(line, "\r", "\n"), "\n") { | |||
| 2582 | - | out = append(out, normalizeBashOutputLine(part)) | |||
| 2583 | - | } | |||
| 2584 | - | } | |||
| 2585 | - | if len(out) == 0 { | |||
| 2586 | - | return []string{""} | |||
| 2587 | - | } | |||
| 2588 | - | return out | |||
| 2589 | - | } | |||
| 2590 | - | ||||
| 2591 | - | func normalizeBashOutputLine(s string) string { | |||
| 2592 | - | var b strings.Builder | |||
| 2593 | - | col := 0 | |||
| 2594 | - | for i := 0; i < len(s); { | |||
| 2595 | - | c := s[i] | |||
| 2596 | - | if c == 0x1b { // ESC: strip CSI/OSC/DCS and simple escapes. | |||
| 2597 | - | i = skipEscapeSequence(s, i) | |||
| 2598 | - | continue | |||
| 2599 | - | } | |||
| 2600 | - | r, size := utf8.DecodeRuneInString(s[i:]) | |||
| 2601 | - | if r == utf8.RuneError && size == 1 { | |||
| 2602 | - | i++ | |||
| 2603 | - | continue | |||
| 2604 | - | } | |||
| 2605 | - | switch r { | |||
| 2606 | - | case '\t': | |||
| 2607 | - | spaces := 8 - (col % 8) | |||
| 2608 | - | if spaces == 0 { | |||
| 2609 | - | spaces = 8 | |||
| 2610 | - | } | |||
| 2611 | - | b.WriteString(strings.Repeat(" ", spaces)) | |||
| 2612 | - | col += spaces | |||
| 2613 | - | case '\b': | |||
| 2614 | - | // Backspace-overstrike output is common in spinners/progress bars. | |||
| 2615 | - | // Dropping it is safer than moving the TUI cursor backwards. | |||
| 2616 | - | case '\n', '\r': | |||
| 2617 | - | // Already split by normalizeBashOutputLines. | |||
| 2618 | - | default: | |||
| 2619 | - | if r < 0x20 || r == 0x7f { | |||
| 2620 | - | // Drop non-printing controls. | |||
| 2621 | - | break | |||
| 2622 | - | } | |||
| 2623 | - | b.WriteRune(r) | |||
| 2624 | - | col += runewidth.RuneWidth(r) | |||
| 2625 | - | } | |||
| 2626 | - | i += size | |||
| 2627 | - | } | |||
| 2628 | - | return b.String() | |||
| 2629 | - | } | |||
| 2630 | - | ||||
| 2631 | - | func skipEscapeSequence(s string, i int) int { | |||
| 2632 | - | if i >= len(s) || s[i] != 0x1b { | |||
| 2633 | - | return i + 1 | |||
| 2634 | - | } | |||
| 2635 | - | if i+1 >= len(s) { | |||
| 2636 | - | return len(s) | |||
| 2637 | - | } | |||
| 2638 | - | switch s[i+1] { | |||
| 2639 | - | case '[': // CSI: ESC [ ... final byte 0x40-0x7e | |||
| 2640 | - | j := i + 2 | |||
| 2641 | - | for j < len(s) { | |||
| 2642 | - | c := s[j] | |||
| 2643 | - | j++ | |||
| 2644 | - | if c >= 0x40 && c <= 0x7e { | |||
| 2645 | - | break | |||
| 2646 | - | } | |||
| 2647 | - | } | |||
| 2648 | - | return j | |||
| 2649 | - | case ']': // OSC: ESC ] ... BEL or ST | |||
| 2650 | - | return skipStringEscape(s, i+2) | |||
| 2651 | - | case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc. | |||
| 2652 | - | return skipStringEscape(s, i+2) | |||
| 2653 | - | default: | |||
| 2654 | - | // Two-byte escape (cursor save/restore, charset select, etc.). | |||
| 2655 | - | return i + 2 | |||
| 2656 | - | } | |||
| 2657 | - | } | |||
| 2658 | - | ||||
| 2659 | - | func skipStringEscape(s string, i int) int { | |||
| 2660 | - | for i < len(s) { | |||
| 2661 | - | if s[i] == 0x07 { // BEL | |||
| 2662 | - | return i + 1 | |||
| 2663 | - | } | |||
| 2664 | - | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST | |||
| 2665 | - | return i + 2 | |||
| 2666 | - | } | |||
| 2667 | - | i++ | |||
| 2668 | - | } | |||
| 2669 | - | return len(s) | |||
| 2670 | - | } | |||
| 2671 | - | ||||
| 2672 | // astRewriteArgs reports whether a completed AST call requested mutation. | 2537 | // astRewriteArgs reports whether a completed AST call requested mutation. | ||
| 2673 | func astRewriteArgs(name string, raw json.RawMessage) bool { | 2538 | func astRewriteArgs(name string, raw json.RawMessage) bool { | ||
| 2674 | if !strings.EqualFold(name, "ast") { | 2539 | if !strings.EqualFold(name, "ast") { | ||
| @@ -2747,7 +2612,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st | |||||
| 2747 | oldLine++ | 2612 | oldLine++ | ||
| 2748 | newLine++ | 2613 | newLine++ | ||
| 2749 | default: | 2614 | default: | ||
| 2750 | - | for _, w := range wrapLine(l, width-4, " ") { | 2615 | + | for _, w := range wrapLine(sanitizeLine(l, terminalTabStop), width-4, " ") { |
| 2751 | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, w)) | 2616 | out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, w)) | ||
| 2752 | } | 2617 | } | ||
| 2753 | } | 2618 | } | ||
| @@ -2792,10 +2657,11 @@ func (v *View) renderRawFile(text, sourcePath string, startLine int) []string { | |||||
| 2792 | code := lines[:codeEnd] | 2657 | code := lines[:codeEnd] | ||
| 2793 | footer := lines[codeEnd:] | 2658 | footer := lines[codeEnd:] | ||
| 2794 | 2659 | ||||
| 2795 | - | // Expand tabs to 4 spaces so Go / Makefile code renders | |||
| 2796 | - | // at a consistent width. | |||
| 2797 | for i, c := range code { | 2660 | for i, c := range code { | ||
| 2798 | - | code[i] = expandTabs(c) | 2661 | + | code[i] = sanitizeLine(c, codeTabStop) |
| 2662 | + | } | |||
| 2663 | + | for i, f := range footer { | |||
| 2664 | + | footer[i] = sanitizeLine(f, terminalTabStop) | |||
| 2799 | } | 2665 | } | ||
| 2800 | 2666 | ||||
| 2801 | lang := LanguageFromPath(sourcePath) | 2667 | lang := LanguageFromPath(sourcePath) | ||
M packages/tui/view_tool_overlay_test.go +17 -0
| @@ -82,6 +82,23 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) { | |||
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | // A streamed bash command can carry tabs (heredocs of Go code) and | |
| 86 | + | // raw escapes. They must not reach the terminal: a tab advances the | |
| 87 | + | // cursor without erasing, so the box edge and stale cells glitch. | |
| 88 | + | func TestLiveBashCommandWithTabsKeepsBoxRowsAligned(t *testing.T) { | |
| 89 | + | args, _ := json.Marshal(map[string]string{ | |
| 90 | + | "command": "python3 - <<'EOF'\nold=\"\"\"\t\t\tdrainAllFrames(sends)\n\tif tt.saturated {\x1b[2J\r\nEOF", | |
| 91 | + | }) | |
| 92 | + | const width = 100 | |
| 93 | + | v := View{ | |
| 94 | + | Theme: Dark, | |
| 95 | + | ToolCalls: []ToolCallView{ | |
| 96 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), RawJSONBuf: string(args)}, | |
| 97 | + | }, | |
| 98 | + | } | |
| 99 | + | assertTerminalSafeRows(t, v.BuildLive(width), width) | |
| 100 | + | } | |
| 101 | + | ||
| 85 | 102 | func TestLiveToolCacheSeparatesCallsAtTheSameRevision(t *testing.T) { | |
| 86 | 103 | v := View{Theme: Dark, ToolCalls: []ToolCallView{ | |
| 87 | 104 | { | |
| @@ -82,6 +82,23 @@ func TestLiveToolOverlayShowsFullBashCommandBeforeResult(t *testing.T) { | |||||
| 82 | } | 82 | } | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | + | // A streamed bash command can carry tabs (heredocs of Go code) and | |||
| 86 | + | // raw escapes. They must not reach the terminal: a tab advances the | |||
| 87 | + | // cursor without erasing, so the box edge and stale cells glitch. | |||
| 88 | + | func TestLiveBashCommandWithTabsKeepsBoxRowsAligned(t *testing.T) { | |||
| 89 | + | args, _ := json.Marshal(map[string]string{ | |||
| 90 | + | "command": "python3 - <<'EOF'\nold=\"\"\"\t\t\tdrainAllFrames(sends)\n\tif tt.saturated {\x1b[2J\r\nEOF", | |||
| 91 | + | }) | |||
| 92 | + | const width = 100 | |||
| 93 | + | v := View{ | |||
| 94 | + | Theme: Dark, | |||
| 95 | + | ToolCalls: []ToolCallView{ | |||
| 96 | + | {ID: "toolu_1", Name: "bash", Args: ShortArgs("bash", args), RawJSONBuf: string(args)}, | |||
| 97 | + | }, | |||
| 98 | + | } | |||
| 99 | + | assertTerminalSafeRows(t, v.BuildLive(width), width) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 85 | func TestLiveToolCacheSeparatesCallsAtTheSameRevision(t *testing.T) { | 102 | func TestLiveToolCacheSeparatesCallsAtTheSameRevision(t *testing.T) { | ||
| 86 | v := View{Theme: Dark, ToolCalls: []ToolCallView{ | 103 | v := View{Theme: Dark, ToolCalls: []ToolCallView{ | ||
| 87 | { | 104 | { | ||