detached 692ebcf

Merge pull request #38 from bnema/fix/right-bar-right-padding

fix(tui): pad right bar content
commit
692ebcfc0363de66572d937fd53a76a9f9774646
author
bnema · · 2026-08-06 06:43 UTC
committer
GitHub · · 2026-08-06 06:43 UTC
merge of
7141aff + 7f38ec6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.1.2
2 files · +43 -7
M packages/tui/rightbar.go +16 -5
@@ -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)
@@ -216,7 +224,10 @@ func truncateRightBarPhaseLine(text string, width int) string {
216224 prefix := text[:nameStart]
217225 suffix := text[nameEnd:]
218226 nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix)
219- if nameWidth <= 0 {
227+ if nameWidth == 0 {
228+ return prefix + suffix
229+ }
230+ if nameWidth < 0 {
220231 return truncateRightBarLine(text, width)
221232 }
222233 return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix
@@ -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)
@@ -216,7 +224,10 @@ func truncateRightBarPhaseLine(text string, width int) string {
216 prefix := text[:nameStart]224 prefix := text[:nameStart]
217 suffix := text[nameEnd:]225 suffix := text[nameEnd:]
218 nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix)226 nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix)
219- if nameWidth <= 0 {227+ if nameWidth == 0 {
228+ return prefix + suffix
229+ }
230+ if nameWidth < 0 {
220 return truncateRightBarLine(text, width)231 return truncateRightBarLine(text, width)
221 }232 }
222 return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix233 return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix
M packages/tui/rightbar_test.go +27 -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 {
@@ -88,6 +106,13 @@ func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) {
88106 }
89107 }
90108
109+func TestTruncateRightBarPhaseLinePreservesProgressWithoutRoomForName(t *testing.T) {
110+ got := truncateRightBarPhaseLine("[>] Current phase 0/1", 9)
111+ if want := "[>] 0/1"; got != want {
112+ t.Fatalf("truncated phase = %q, want %q", got, want)
113+ }
114+}
115+
91116 func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
92117 lines := RenderRightBar(Dark, []RightBarWidget{{
93118 Extension: "tasked-phases",
@@ -105,7 +130,7 @@ func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
105130 if len(plainLines) < 4 {
106131 t.Fatalf("checklist rows are missing: %q", plain)
107132 }
108- expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36)
133+ const expectedTask = " [ ] Sort the leftover star sti..."
109134 if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
110135 t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
111136 }
@@ -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 {
@@ -88,6 +106,13 @@ func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) {
88 }106 }
89 }107 }
90 108
109+func TestTruncateRightBarPhaseLinePreservesProgressWithoutRoomForName(t *testing.T) {
110+ got := truncateRightBarPhaseLine("[>] Current phase 0/1", 9)
111+ if want := "[>] 0/1"; got != want {
112+ t.Fatalf("truncated phase = %q, want %q", got, want)
113+ }
114+}
115+
91 func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {116 func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
92 lines := RenderRightBar(Dark, []RightBarWidget{{117 lines := RenderRightBar(Dark, []RightBarWidget{{
93 Extension: "tasked-phases",118 Extension: "tasked-phases",
@@ -105,7 +130,7 @@ func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
105 if len(plainLines) < 4 {130 if len(plainLines) < 4 {
106 t.Fatalf("checklist rows are missing: %q", plain)131 t.Fatalf("checklist rows are missing: %q", plain)
107 }132 }
108- expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36)133+ const expectedTask = " [ ] Sort the leftover star sti..."
109 if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {134 if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
110 t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)135 t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
111 }136 }

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