v0.51.0 a9d674e

terminal_safe_test.go RawHistoryFolder

1package tui
2
3import (
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.
16func 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
41func 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
67func 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.
91func 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.
113func 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}

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