detached d3cdac6

fix(tui): pad right bar content

commit
d3cdac63810913e50312f30874e5ce87a1aee0d9
author
brice · · 2026-08-06 06:35 UTC
parent
7141aff
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +32 -6
M packages/tui/rightbar.go +12 -4
@@ -16,6 +16,10 @@ const (
1616 RightBarMaxWidth = 36
1717 // RightBarMinMainWidth preserves enough room for the editor and chat.
1818 RightBarMinMainWidth = 48
19+
20+ // rightBarRightPadding leaves a small gutter between widget text and the
21+ // terminal's right edge.
22+ rightBarRightPadding = 1
1923 )
2024
2125 // RightBarWidget is declarative content for one persistent extension widget.
@@ -60,13 +64,14 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) {
6064 // RenderRightBar renders a bounded, full-height side rail without adding a
6165 // second frame around the host-owned separator. Widgets are sorted by
6266 // extension name and ID so output stays deterministic even when extension
63-// frames arrive concurrently. Long lines are clipped to the rail width with
64-// a three-dot ellipsis.
67+// frames arrive concurrently. Long lines are clipped with a three-dot ellipsis
68+// while reserving one cell of right padding.
6569 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
6670 if width <= 0 || height <= 0 {
6771 return nil
6872 }
6973
74+ contentWidth := width - rightBarRightPadding
7075 ordered := append([]RightBarWidget(nil), widgets...)
7176 sort.SliceStable(ordered, func(a, b int) bool {
7277 if ordered[a].Extension != ordered[b].Extension {
@@ -78,10 +83,13 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
7883 content := make([]string, 0, height)
7984 truncated := false
8085 padLine := func(text string, phase bool) string {
86+ if contentWidth <= 0 {
87+ return strings.Repeat(" ", width)
88+ }
8189 if phase {
82- text = truncateRightBarPhaseLine(text, width)
90+ text = truncateRightBarPhaseLine(text, contentWidth)
8391 } else {
84- text = truncateRightBarLine(text, width)
92+ text = truncateRightBarLine(text, contentWidth)
8593 }
8694 if visible := visibleWidth(text); visible < width {
8795 text += strings.Repeat(" ", width-visible)
@@ -16,6 +16,10 @@ const (
16 RightBarMaxWidth = 3616 RightBarMaxWidth = 36
17 // RightBarMinMainWidth preserves enough room for the editor and chat.17 // RightBarMinMainWidth preserves enough room for the editor and chat.
18 RightBarMinMainWidth = 4818 RightBarMinMainWidth = 48
19+
20+ // rightBarRightPadding leaves a small gutter between widget text and the
21+ // terminal's right edge.
22+ rightBarRightPadding = 1
19 )23 )
20 24
21 // RightBarWidget is declarative content for one persistent extension widget.25 // RightBarWidget is declarative content for one persistent extension widget.
@@ -60,13 +64,14 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) {
60 // RenderRightBar renders a bounded, full-height side rail without adding a64 // RenderRightBar renders a bounded, full-height side rail without adding a
61 // second frame around the host-owned separator. Widgets are sorted by65 // second frame around the host-owned separator. Widgets are sorted by
62 // extension name and ID so output stays deterministic even when extension66 // extension name and ID so output stays deterministic even when extension
63-// frames arrive concurrently. Long lines are clipped to the rail width with67+// frames arrive concurrently. Long lines are clipped with a three-dot ellipsis
64-// a three-dot ellipsis.68+// while reserving one cell of right padding.
65 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {69 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
66 if width <= 0 || height <= 0 {70 if width <= 0 || height <= 0 {
67 return nil71 return nil
68 }72 }
69 73
74+ contentWidth := width - rightBarRightPadding
70 ordered := append([]RightBarWidget(nil), widgets...)75 ordered := append([]RightBarWidget(nil), widgets...)
71 sort.SliceStable(ordered, func(a, b int) bool {76 sort.SliceStable(ordered, func(a, b int) bool {
72 if ordered[a].Extension != ordered[b].Extension {77 if ordered[a].Extension != ordered[b].Extension {
@@ -78,10 +83,13 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
78 content := make([]string, 0, height)83 content := make([]string, 0, height)
79 truncated := false84 truncated := false
80 padLine := func(text string, phase bool) string {85 padLine := func(text string, phase bool) string {
86+ if contentWidth <= 0 {
87+ return strings.Repeat(" ", width)
88+ }
81 if phase {89 if phase {
82- text = truncateRightBarPhaseLine(text, width)90+ text = truncateRightBarPhaseLine(text, contentWidth)
83 } else {91 } else {
84- text = truncateRightBarLine(text, width)92+ text = truncateRightBarLine(text, contentWidth)
85 }93 }
86 if visible := visibleWidth(text); visible < width {94 if visible := visibleWidth(text); visible < width {
87 text += strings.Repeat(" ", width-visible)95 text += strings.Repeat(" ", width-visible)
M packages/tui/rightbar_test.go +20 -2
@@ -61,7 +61,7 @@ func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(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 thre...") || strings.Contains(plain, "four five six") {
64+ if !strings.Contains(plain, " one two thr...") || strings.Contains(plain, "four five six") {
6565 t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain)
6666 }
6767 for idx, line := range lines {
@@ -71,6 +71,24 @@ func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) {
7171 }
7272 }
7373
74+func TestRenderRightBarLeavesOneCellOfRightPadding(t *testing.T) {
75+ lines := RenderRightBar(Dark, []RightBarWidget{{
76+ Extension: "tasked-phases",
77+ Title: "p 0/1 | t 0/1",
78+ Lines: []string{"[>] A phase title that is much too long to fit 0/1"},
79+ }}, 24, 4)
80+
81+ for idx, line := range lines {
82+ plain := stripANSI(line)
83+ if !strings.HasSuffix(plain, " ") {
84+ t.Fatalf("right bar line %d lacks right padding: %q", idx, plain)
85+ }
86+ if visibleWidth(strings.TrimRight(plain, " ")) > 23 {
87+ t.Fatalf("right bar line %d content reaches the right edge: %q", idx, plain)
88+ }
89+ }
90+}
91+
7492 func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) {
7593 lines := RenderRightBar(Dark, []RightBarWidget{
7694 {
@@ -105,7 +123,7 @@ func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
105123 if len(plainLines) < 4 {
106124 t.Fatalf("checklist rows are missing: %q", plain)
107125 }
108- expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36)
126+ expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 35)
109127 if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
110128 t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
111129 }
@@ -61,7 +61,7 @@ func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(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 thre...") || strings.Contains(plain, "four five six") {64+ if !strings.Contains(plain, " one two thr...") || strings.Contains(plain, "four five six") {
65 t.Fatalf("long widget line was not clipped with an ellipsis: %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 {
@@ -71,6 +71,24 @@ func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) {
71 }71 }
72 }72 }
73 73
74+func TestRenderRightBarLeavesOneCellOfRightPadding(t *testing.T) {
75+ lines := RenderRightBar(Dark, []RightBarWidget{{
76+ Extension: "tasked-phases",
77+ Title: "p 0/1 | t 0/1",
78+ Lines: []string{"[>] A phase title that is much too long to fit 0/1"},
79+ }}, 24, 4)
80+
81+ for idx, line := range lines {
82+ plain := stripANSI(line)
83+ if !strings.HasSuffix(plain, " ") {
84+ t.Fatalf("right bar line %d lacks right padding: %q", idx, plain)
85+ }
86+ if visibleWidth(strings.TrimRight(plain, " ")) > 23 {
87+ t.Fatalf("right bar line %d content reaches the right edge: %q", idx, plain)
88+ }
89+ }
90+}
91+
74 func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) {92 func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) {
75 lines := RenderRightBar(Dark, []RightBarWidget{93 lines := RenderRightBar(Dark, []RightBarWidget{
76 {94 {
@@ -105,7 +123,7 @@ func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
105 if len(plainLines) < 4 {123 if len(plainLines) < 4 {
106 t.Fatalf("checklist rows are missing: %q", plain)124 t.Fatalf("checklist rows are missing: %q", plain)
107 }125 }
108- expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36)126+ expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 35)
109 if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {127 if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
110 t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)128 t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
111 }129 }

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