detached ba21570

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 UnverifiedB5690EEEBB952194 The key is unknown
6 files · +348 -177
M packages/tui/editor.go +7 -1
@@ -891,8 +891,14 @@ func normalizeEditorText(s string) string {
891891 return strings.ReplaceAll(s, "\r", "\n")
892892 }
893893
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.
894899 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
896902 }
897903
898904 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,
3737 if width <= 0 {
3838 width = 80
3939 }
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
4046
4147 var out strings.Builder
4248 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 = 8038 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.Builder47 var out strings.Builder
42 var fenceBuf strings.Builder48 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 (
1414 "github.com/mattn/go-runewidth"
1515 )
1616
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-
5617 // pathFromToolArgs returns the "path" argument from a tool_call's
5718 // JSON arguments, or "" if the args aren't a JSON object or don't
5819 // include one. Used to pick a syntax language for rendering the
@@ -501,7 +462,7 @@ func (v *View) renderErr(width int) []string {
501462 var out []string
502463 // Each returned row must represent one terminal line, including when
503464 // 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)) {
505466 for _, line := range wrapLine(paragraph, wrapWidth, "") {
506467 prefix := indent
507468 if len(out) == 0 {
@@ -979,7 +940,7 @@ func (v *View) renderPlanUpdate(update core.PlanUpdate, width int) []string {
979940 var body []string
980941 if update.Explanation != nil {
981942 if explanation := strings.TrimSpace(*update.Explanation); explanation != "" {
982- for _, line := range wrapLine(explanation, bodyWidth, "") {
943+ for _, line := range wrapLine(sanitizeLine(explanation, codeTabStop), bodyWidth, "") {
983944 body = append(body, Dim(Italic(line)))
984945 }
985946 }
@@ -1000,7 +961,7 @@ func (v *View) renderPlanUpdate(update core.PlanUpdate, width int) []string {
1000961 if stepWidth < 1 {
1001962 stepWidth = 1
1002963 }
1003- wrapped := wrapLine(step.Step, stepWidth, "")
964+ wrapped := wrapLine(sanitizeLine(step.Step, codeTabStop), stepWidth, "")
1004965 if len(wrapped) == 0 {
1005966 wrapped = []string{""}
1006967 }
@@ -1100,8 +1061,8 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
11001061 for _, c := range m.Content {
11011062 switch b := c.(type) {
11021063 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)
11051066 for _, w := range wrapLine(l, innerWidth, "") {
11061067 bubble = append(bubble, row(w))
11071068 }
@@ -1562,7 +1523,7 @@ func (v *View) renderLiveBashCommand(command string, width int) []string {
15621523 }
15631524 prompt := v.Theme.FGColor(v.Theme.Muted, "$ ")
15641525 var out []string
1565- for i, line := range strings.Split(command, "\n") {
1526+ for i, line := range splitLines(sanitizeText(command, terminalTabStop)) {
15661527 firstPrefix := " "
15671528 if i == 0 {
15681529 firstPrefix += prompt
@@ -1650,13 +1611,7 @@ func toolBoxTop(th Theme, label string, width int) string {
16501611 // Label overflows; truncate with an ellipsis so the right
16511612 // corner still lands on the right edge. Rare on real
16521613 // 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)
16601615 used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix)
16611616 fill = w - used - 1
16621617 if fill < 0 {
@@ -1668,8 +1623,22 @@ func toolBoxTop(th Theme, label string, width int) string {
16681623 return margin + th.FGColor(th.Muted, prefix) + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest+suffix+fillStr+"┐") + margin
16691624 }
16701625
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+
16711640 func oneLineToolLabel(label string) string {
1672- return strings.Join(strings.Fields(label), " ")
1641+ return strings.Join(strings.Fields(sanitizeLine(label, 1)), " ")
16731642 }
16741643
16751644 func splitToolLabel(label string) (name, rest string) {
@@ -1713,10 +1682,8 @@ func flatToolHeader(th Theme, label string, width int) string {
17131682 avail = 12
17141683 }
17151684 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))
17201687 } else if visibleWidth(name) <= avail {
17211688 rest = ""
17221689 }
@@ -1740,10 +1707,8 @@ func compactToolHeader(th Theme, label string, width int) string {
17401707 avail = 12
17411708 }
17421709 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))
17471712 } else if visibleWidth(name) <= avail {
17481713 rest = ""
17491714 }
@@ -2167,7 +2132,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor
21672132
21682133 // No truncation — the full tool output is rendered into chat and
21692134 // becomes part of the scrollback you can page back through.
2170- lines := strings.Split(text, "\n")
2135+ lines := splitLines(text)
21712136
21722137 // Bash-result styling: when the first row looks like a shell
21732138 // prompt line ("$ ...") emitted by the bash tool, style the
@@ -2185,7 +2150,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor
21852150 // Detect diff header: "--- name" followed somewhere by "+++ name".
21862151 if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
21872152 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)))
21892154 continue
21902155 }
21912156 // 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
21942159 if o, n, ok := parseHunkHeader(l); ok {
21952160 oldLine, newLine = o, n
21962161 }
2197- out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, l))
2162+ out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, sanitizeLine(l, terminalTabStop)))
21982163 continue
21992164 }
22002165 if inDiff && strings.TrimSpace(l) == "..." {
@@ -2220,7 +2185,7 @@ func (v *View) renderToolText(text string, width int, defaultColor TerminalColor
22202185 }
22212186 }
22222187 // Regular line.
2223- for _, w := range wrapLine(l, width-4, " ") {
2188+ for _, w := range wrapLine(sanitizeLine(l, terminalTabStop), width-4, " ") {
22242189 out = append(out, " "+v.Theme.FGColor(defaultColor, w))
22252190 }
22262191 }
@@ -2278,7 +2243,7 @@ func (v *View) renderDiffRow(line string, width int, color TerminalColor, lineNo
22782243 if len(line) == 0 {
22792244 return []string{""}
22802245 }
2281- code := expandTabs(line[1:]) // strip the leading marker; expand tabs
2246+ code := sanitizeLine(line[1:], codeTabStop) // strip the leading marker
22822247
22832248 // Gutter shape: sign + number share a color so they read as one
22842249 // visual token ("+123") instead of a neutral line number next to
@@ -2477,11 +2442,11 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
24772442 if idx < 0 || !numberedLineRE.MatchString(l) {
24782443 // Non-code footer (e.g. "[truncated at 2000 lines]").
24792444 gutters = append(gutters, "")
2480- codes = append(codes, l)
2445+ codes = append(codes, sanitizeLine(l, terminalTabStop))
24812446 continue
24822447 }
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)
24852450 gutters = append(gutters, gutter)
24862451 codes = append(codes, code)
24872452 }
@@ -2529,7 +2494,9 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
25292494 // in muted type, everything else on the default tool-output color.
25302495 // Called from renderToolText when the first line starts with "$ ".
25312496 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+ }
25332500 // Identify the footer line (exit + timing). The bash tool writes
25342501 // it as the last non-empty line of the result.
25352502 footerIdx := -1
@@ -2567,108 +2534,6 @@ func (v *View) renderBashResult(lines []string, width int, defaultColor Terminal
25672534 return out
25682535 }
25692536
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-
26722537 // astRewriteArgs reports whether a completed AST call requested mutation.
26732538 func astRewriteArgs(name string, raw json.RawMessage) bool {
26742539 if !strings.EqualFold(name, "ast") {
@@ -2747,7 +2612,7 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st
27472612 oldLine++
27482613 newLine++
27492614 default:
2750- for _, w := range wrapLine(l, width-4, " ") {
2615+ for _, w := range wrapLine(sanitizeLine(l, terminalTabStop), width-4, " ") {
27512616 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, w))
27522617 }
27532618 }
@@ -2792,10 +2657,11 @@ func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
27922657 code := lines[:codeEnd]
27932658 footer := lines[codeEnd:]
27942659
2795- // Expand tabs to 4 spaces so Go / Makefile code renders
2796- // at a consistent width.
27972660 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)
27992665 }
28002666
28012667 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's17 // pathFromToolArgs returns the "path" argument from a tool_call's
57 // JSON arguments, or "" if the args aren't a JSON object or don't18 // 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 the19 // 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 []string462 var out []string
502 // Each returned row must represent one terminal line, including when463 // 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 := indent467 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 []string940 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 = 1962 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 []string1525 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 += prompt1529 firstPrefix += prompt
@@ -1650,13 +1611,7 @@ func toolBoxTop(th Theme, label string, width int) string {
1650 // Label overflows; truncate with an ellipsis so the right1611 // Label overflows; truncate with an ellipsis so the right
1651 // corner still lands on the right edge. Rare on real1612 // 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 := -fill1614+ 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 - 11616 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+"┐") + margin1623 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 = 121682 avail = 12
1714 }1683 }
1715 if visibleWidth(name+rest) > avail {1684 if visibleWidth(name+rest) > avail {
1716- over := visibleWidth(name+rest) - avail1685+ 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 = 121707 avail = 12
1741 }1708 }
1742 if visibleWidth(name+rest) > avail {1709 if visibleWidth(name+rest) > avail {
1743- over := visibleWidth(name+rest) - avail1710+ 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 and2133 // 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 shell2137 // Bash-result styling: when the first row looks like a shell
2173 // prompt line ("$ ...") emitted by the bash tool, style the2138 // 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 = true2152 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 continue2154 continue
2190 }2155 }
2191 // Hunk header "@@ -a,b +c,d @@" resets the counters so patches2156 // 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, n2160 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 continue2163 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 tabs2246+ code := sanitizeLine(line[1:], codeTabStop) // strip the leading marker
2282 2247
2283 // Gutter shape: sign + number share a color so they read as one2248 // Gutter shape: sign + number share a color so they read as one
2284 // visual token ("+123") instead of a neutral line number next to2249 // 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 continue2446 continue
2482 }2447 }
2483- gutter := l[:idx] + " " // replace tab with single space2448+ 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 writes2500 // 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 := -12502 footerIdx := -1
@@ -2567,108 +2534,6 @@ func (v *View) renderBashResult(lines []string, width int, defaultColor Terminal
2567 return out2534 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) {
8282 }
8383 }
8484
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+
85102 func TestLiveToolCacheSeparatesCallsAtTheSameRevision(t *testing.T) {
86103 v := View{Theme: Dark, ToolCalls: []ToolCallView{
87104 {
@@ -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 {

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