detached a2c470e

fix(tui): clip sidebar lines with ellipses

commit
a2c470e3988efeb4258f06a9817171c0a9b1e3e5
author
brice · · 2026-08-05 13:39 UTC
parent
071b798
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +47 -55
M packages/tui/rightbar.go +32 -41
@@ -60,8 +60,8 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) {
6060 // RenderRightBar renders a bounded, full-height side rail without adding a
6161 // second frame around the host-owned separator. Widgets are sorted by
6262 // extension name and ID so output stays deterministic even when extension
63-// frames arrive concurrently. Long content wraps to the rail width, and
64-// content that does not fit is replaced by a compact marker.
63+// frames arrive concurrently. Long lines are clipped to the rail width with
64+// a three-dot ellipsis.
6565 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
6666 if width <= 0 || height <= 0 {
6767 return nil
@@ -78,7 +78,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
7878 content := make([]string, 0, height)
7979 truncated := false
8080 padLine := func(text string) string {
81- text = truncateToWidth(text, width)
81+ text = truncateRightBarLine(text, width)
8282 if visible := visibleWidth(text); visible < width {
8383 text += strings.Repeat(" ", width-visible)
8484 }
@@ -116,18 +116,12 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
116116 }
117117 appendContent := func(text string, color int, dim, phase bool) bool {
118118 text = strings.ReplaceAll(text, "\r", "")
119- for _, physicalLine := range strings.Split(text, "\n") {
120- wrapped := rightBarWrapLine(physicalLine, width)
121- if len(wrapped) == 0 {
122- wrapped = []string{""}
123- }
124- for _, line := range wrapped {
125- if len(content) >= height {
126- truncated = true
127- return false
128- }
129- content = append(content, paintLine(line, color, dim, phase))
119+ for _, line := range strings.Split(text, "\n") {
120+ if len(content) >= height {
121+ truncated = true
122+ return false
130123 }
124+ content = append(content, paintLine(line, color, dim, phase))
131125 }
132126 return true
133127 }
@@ -197,33 +191,14 @@ func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool)
197191 }
198192 }
199193
200-func rightBarWrapLine(text string, width int) []string {
201- _, end, _, ok := rightBarChecklistMarker(text)
202- if !ok {
203- return WrapANSILine(text, width)
194+func truncateRightBarLine(text string, width int) string {
195+ if width <= 0 || visibleWidth(text) <= width {
196+ return text
204197 }
205-
206- bodyStart := end
207- if bodyStart < len(text) && text[bodyStart] == ' ' {
208- bodyStart++
209- }
210- prefix := text[:bodyStart]
211- prefixWidth := visibleWidth(prefix)
212- bodyWidth := width - prefixWidth
213- if bodyWidth < 1 {
214- return WrapANSILine(text, width)
215- }
216- bodyLines := WrapANSILine(text[bodyStart:], bodyWidth)
217- continuation := strings.Repeat(" ", prefixWidth)
218- wrapped := make([]string, len(bodyLines))
219- for i, bodyLine := range bodyLines {
220- if i == 0 {
221- wrapped[i] = prefix + bodyLine
222- } else {
223- wrapped[i] = continuation + bodyLine
224- }
198+ if width <= 3 {
199+ return truncateToWidth("...", width)
225200 }
226- return wrapped
201+ return truncateToWidth(text, width-3) + "..."
227202 }
228203
229204 func rightBarChecklistIndent(lines []string) int {
@@ -261,12 +236,12 @@ func rightBarChecklistPhaseName(text string) (start, end int, ok bool) {
261236 suffix := text[start:trimmedEnd]
262237 separator := strings.LastIndex(suffix, " ")
263238 if separator < 0 {
264- return 0, 0, false
239+ return rightBarFallbackPhaseName(text, start, trimmedEnd)
265240 }
266241 nameEnd := start + separator
267242 progress := strings.TrimSpace(text[nameEnd:trimmedEnd])
268243 if !rightBarProgressToken(progress) {
269- return 0, 0, false
244+ return rightBarFallbackPhaseName(text, start, trimmedEnd)
270245 }
271246 for nameEnd > start && text[nameEnd-1] == ' ' {
272247 nameEnd--
@@ -277,6 +252,22 @@ func rightBarChecklistPhaseName(text string) (start, end int, ok bool) {
277252 return start, nameEnd, true
278253 }
279254
255+func rightBarFallbackPhaseName(text string, start, end int) (int, int, bool) {
256+ for end > start && text[end-1] == ' ' {
257+ end--
258+ }
259+ if end-start >= 3 && text[end-3:end] == "..." {
260+ end -= 3
261+ }
262+ for end > start && text[end-1] == ' ' {
263+ end--
264+ }
265+ if end <= start {
266+ return 0, 0, false
267+ }
268+ return start, end, true
269+}
270+
280271 func rightBarProgressToken(value string) bool {
281272 separator := strings.IndexByte(value, '/')
282273 if separator <= 0 || separator == len(value)-1 {
@@ -60,8 +60,8 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) {
60 // RenderRightBar renders a bounded, full-height side rail without adding a60 // RenderRightBar renders a bounded, full-height side rail without adding a
61 // second frame around the host-owned separator. Widgets are sorted by61 // second frame around the host-owned separator. Widgets are sorted by
62 // extension name and ID so output stays deterministic even when extension62 // extension name and ID so output stays deterministic even when extension
63-// frames arrive concurrently. Long content wraps to the rail width, and63+// frames arrive concurrently. Long lines are clipped to the rail width with
64-// content that does not fit is replaced by a compact marker.64+// a three-dot ellipsis.
65 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {65 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
66 if width <= 0 || height <= 0 {66 if width <= 0 || height <= 0 {
67 return nil67 return nil
@@ -78,7 +78,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
78 content := make([]string, 0, height)78 content := make([]string, 0, height)
79 truncated := false79 truncated := false
80 padLine := func(text string) string {80 padLine := func(text string) string {
81- text = truncateToWidth(text, width)81+ text = truncateRightBarLine(text, width)
82 if visible := visibleWidth(text); visible < width {82 if visible := visibleWidth(text); visible < width {
83 text += strings.Repeat(" ", width-visible)83 text += strings.Repeat(" ", width-visible)
84 }84 }
@@ -116,18 +116,12 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
116 }116 }
117 appendContent := func(text string, color int, dim, phase bool) bool {117 appendContent := func(text string, color int, dim, phase bool) bool {
118 text = strings.ReplaceAll(text, "\r", "")118 text = strings.ReplaceAll(text, "\r", "")
119- for _, physicalLine := range strings.Split(text, "\n") {119+ for _, line := range strings.Split(text, "\n") {
120- wrapped := rightBarWrapLine(physicalLine, width)120+ if len(content) >= height {
121- if len(wrapped) == 0 {121+ truncated = true
122- wrapped = []string{""}122+ return false
123- }
124- for _, line := range wrapped {
125- if len(content) >= height {
126- truncated = true
127- return false
128- }
129- content = append(content, paintLine(line, color, dim, phase))
130 }123 }
124+ content = append(content, paintLine(line, color, dim, phase))
131 }125 }
132 return true126 return true
133 }127 }
@@ -197,33 +191,14 @@ func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool)
197 }191 }
198 }192 }
199 193
200-func rightBarWrapLine(text string, width int) []string {194+func truncateRightBarLine(text string, width int) string {
201- _, end, _, ok := rightBarChecklistMarker(text)195+ if width <= 0 || visibleWidth(text) <= width {
202- if !ok {196+ return text
203- return WrapANSILine(text, width)
204 }197 }
205-198+ if width <= 3 {
206- bodyStart := end199+ return truncateToWidth("...", width)
207- if bodyStart < len(text) && text[bodyStart] == ' ' {
208- bodyStart++
209- }
210- prefix := text[:bodyStart]
211- prefixWidth := visibleWidth(prefix)
212- bodyWidth := width - prefixWidth
213- if bodyWidth < 1 {
214- return WrapANSILine(text, width)
215- }
216- bodyLines := WrapANSILine(text[bodyStart:], bodyWidth)
217- continuation := strings.Repeat(" ", prefixWidth)
218- wrapped := make([]string, len(bodyLines))
219- for i, bodyLine := range bodyLines {
220- if i == 0 {
221- wrapped[i] = prefix + bodyLine
222- } else {
223- wrapped[i] = continuation + bodyLine
224- }
225 }200 }
226- return wrapped201+ return truncateToWidth(text, width-3) + "..."
227 }202 }
228 203
229 func rightBarChecklistIndent(lines []string) int {204 func rightBarChecklistIndent(lines []string) int {
@@ -261,12 +236,12 @@ func rightBarChecklistPhaseName(text string) (start, end int, ok bool) {
261 suffix := text[start:trimmedEnd]236 suffix := text[start:trimmedEnd]
262 separator := strings.LastIndex(suffix, " ")237 separator := strings.LastIndex(suffix, " ")
263 if separator < 0 {238 if separator < 0 {
264- return 0, 0, false239+ return rightBarFallbackPhaseName(text, start, trimmedEnd)
265 }240 }
266 nameEnd := start + separator241 nameEnd := start + separator
267 progress := strings.TrimSpace(text[nameEnd:trimmedEnd])242 progress := strings.TrimSpace(text[nameEnd:trimmedEnd])
268 if !rightBarProgressToken(progress) {243 if !rightBarProgressToken(progress) {
269- return 0, 0, false244+ return rightBarFallbackPhaseName(text, start, trimmedEnd)
270 }245 }
271 for nameEnd > start && text[nameEnd-1] == ' ' {246 for nameEnd > start && text[nameEnd-1] == ' ' {
272 nameEnd--247 nameEnd--
@@ -277,6 +252,22 @@ func rightBarChecklistPhaseName(text string) (start, end int, ok bool) {
277 return start, nameEnd, true252 return start, nameEnd, true
278 }253 }
279 254
255+func rightBarFallbackPhaseName(text string, start, end int) (int, int, bool) {
256+ for end > start && text[end-1] == ' ' {
257+ end--
258+ }
259+ if end-start >= 3 && text[end-3:end] == "..." {
260+ end -= 3
261+ }
262+ for end > start && text[end-1] == ' ' {
263+ end--
264+ }
265+ if end <= start {
266+ return 0, 0, false
267+ }
268+ return start, end, true
269+}
270+
280 func rightBarProgressToken(value string) bool {271 func rightBarProgressToken(value string) bool {
281 separator := strings.IndexByte(value, '/')272 separator := strings.IndexByte(value, '/')
282 if separator <= 0 || separator == len(value)-1 {273 if separator <= 0 || separator == len(value)-1 {
M packages/tui/rightbar_test.go +15 -14
@@ -51,7 +51,7 @@ func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) {
5151 }
5252 }
5353
54-func TestRenderRightBarWrapsLongWidgetLines(t *testing.T) {
54+func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) {
5555 lines := RenderRightBar(Dark, []RightBarWidget{{
5656 Extension: "plan",
5757 Lines: []string{"one two three four five six"},
@@ -61,17 +61,17 @@ func TestRenderRightBarWrapsLongWidgetLines(t *testing.T) {
6161 t.Fatalf("right bar rows = %d, want 8", len(lines))
6262 }
6363 plain := stripANSI(strings.Join(lines, "\n"))
64- if !strings.Contains(plain, "one two three") || !strings.Contains(plain, "four five six") {
65- t.Fatalf("long widget line was not wrapped: %q", plain)
64+ if !strings.Contains(plain, " one two thre...") || strings.Contains(plain, "four five six") {
65+ t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain)
6666 }
6767 for idx, line := range lines {
6868 if width := visibleWidth(line); width != 16 {
69- t.Fatalf("wrapped right bar line %d width = %d, want 16: %q", idx, width, line)
69+ t.Fatalf("clipped right bar line %d width = %d, want 16: %q", idx, width, line)
7070 }
7171 }
7272 }
7373
74-func TestRenderRightBarUsesChecklistIndentHangingWrapAndBoldPhases(t *testing.T) {
74+func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
7575 lines := RenderRightBar(Dark, []RightBarWidget{{
7676 Extension: "tasked-phases",
7777 Title: "p 0/1 | t 0/1",
@@ -81,14 +81,16 @@ func TestRenderRightBarUsesChecklistIndentHangingWrapAndBoldPhases(t *testing.T)
8181 },
8282 }}, 36, 8)
8383 plain := stripANSI(strings.Join(lines, "\n"))
84- for _, want := range []string{
85- " [>] Copper Lantern Setup 0/1",
86- " [ ] Sort the leftover star",
87- " stickers",
88- } {
89- if !strings.Contains(plain, want) {
90- t.Fatalf("right-bar checklist layout missing %q: %q", want, plain)
91- }
84+ if !strings.Contains(plain, " [>] Copper Lantern Setup 0/1") {
85+ t.Fatalf("phase row has the wrong indentation: %q", plain)
86+ }
87+ plainLines := strings.Split(plain, "\n")
88+ if len(plainLines) < 4 {
89+ t.Fatalf("checklist rows are missing: %q", plain)
90+ }
91+ expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36)
92+ if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
93+ t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
9294 }
9395 if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) {
9496 t.Fatalf("phase name is not bold: %q", lines)
@@ -96,7 +98,6 @@ func TestRenderRightBarUsesChecklistIndentHangingWrapAndBoldPhases(t *testing.T)
9698 if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) {
9799 t.Fatalf("task text should not be bold: %q", lines)
98100 }
99- plainLines := strings.Split(plain, "\n")
100101 if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" {
101102 t.Fatalf("widget title is not followed by an empty row: %q", plain)
102103 }
@@ -51,7 +51,7 @@ func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) {
51 }51 }
52 }52 }
53 53
54-func TestRenderRightBarWrapsLongWidgetLines(t *testing.T) {54+func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) {
55 lines := RenderRightBar(Dark, []RightBarWidget{{55 lines := RenderRightBar(Dark, []RightBarWidget{{
56 Extension: "plan",56 Extension: "plan",
57 Lines: []string{"one two three four five six"},57 Lines: []string{"one two three four five six"},
@@ -61,17 +61,17 @@ func TestRenderRightBarWrapsLongWidgetLines(t *testing.T) {
61 t.Fatalf("right bar rows = %d, want 8", len(lines))61 t.Fatalf("right bar rows = %d, want 8", len(lines))
62 }62 }
63 plain := stripANSI(strings.Join(lines, "\n"))63 plain := stripANSI(strings.Join(lines, "\n"))
64- if !strings.Contains(plain, "one two three") || !strings.Contains(plain, "four five six") {64+ if !strings.Contains(plain, " one two thre...") || strings.Contains(plain, "four five six") {
65- t.Fatalf("long widget line was not wrapped: %q", plain)65+ t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain)
66 }66 }
67 for idx, line := range lines {67 for idx, line := range lines {
68 if width := visibleWidth(line); width != 16 {68 if width := visibleWidth(line); width != 16 {
69- t.Fatalf("wrapped right bar line %d width = %d, want 16: %q", idx, width, line)69+ t.Fatalf("clipped right bar line %d width = %d, want 16: %q", idx, width, line)
70 }70 }
71 }71 }
72 }72 }
73 73
74-func TestRenderRightBarUsesChecklistIndentHangingWrapAndBoldPhases(t *testing.T) {74+func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
75 lines := RenderRightBar(Dark, []RightBarWidget{{75 lines := RenderRightBar(Dark, []RightBarWidget{{
76 Extension: "tasked-phases",76 Extension: "tasked-phases",
77 Title: "p 0/1 | t 0/1",77 Title: "p 0/1 | t 0/1",
@@ -81,14 +81,16 @@ func TestRenderRightBarUsesChecklistIndentHangingWrapAndBoldPhases(t *testing.T)
81 },81 },
82 }}, 36, 8)82 }}, 36, 8)
83 plain := stripANSI(strings.Join(lines, "\n"))83 plain := stripANSI(strings.Join(lines, "\n"))
84- for _, want := range []string{84+ if !strings.Contains(plain, " [>] Copper Lantern Setup 0/1") {
85- " [>] Copper Lantern Setup 0/1",85+ t.Fatalf("phase row has the wrong indentation: %q", plain)
86- " [ ] Sort the leftover star",86+ }
87- " stickers",87+ plainLines := strings.Split(plain, "\n")
88- } {88+ if len(plainLines) < 4 {
89- if !strings.Contains(plain, want) {89+ t.Fatalf("checklist rows are missing: %q", plain)
90- t.Fatalf("right-bar checklist layout missing %q: %q", want, plain)90+ }
91- }91+ expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36)
92+ if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
93+ t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
92 }94 }
93 if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) {95 if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) {
94 t.Fatalf("phase name is not bold: %q", lines)96 t.Fatalf("phase name is not bold: %q", lines)
@@ -96,7 +98,6 @@ func TestRenderRightBarUsesChecklistIndentHangingWrapAndBoldPhases(t *testing.T)
96 if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) {98 if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) {
97 t.Fatalf("task text should not be bold: %q", lines)99 t.Fatalf("task text should not be bold: %q", lines)
98 }100 }
99- plainLines := strings.Split(plain, "\n")
100 if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" {101 if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" {
101 t.Fatalf("widget title is not followed by an empty row: %q", plain)102 t.Fatalf("widget title is not followed by an empty row: %q", plain)
102 }103 }

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