fix(tui): pad right bar content
- commit
- d3cdac63810913e50312f30874e5ce87a1aee0d9
- author
- brice · · 2026-08-06 06:35 UTC
- parent
- 7141aff
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +32 -6
M packages/tui/rightbar.go +12 -4
| @@ -16,6 +16,10 @@ const ( | |||
| 16 | 16 | RightBarMaxWidth = 36 | |
| 17 | 17 | // RightBarMinMainWidth preserves enough room for the editor and chat. | |
| 18 | 18 | 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 | 25 | // RightBarWidget is declarative content for one persistent extension widget. | |
| @@ -60,13 +64,14 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) { | |||
| 60 | 64 | // RenderRightBar renders a bounded, full-height side rail without adding a | |
| 61 | 65 | // second frame around the host-owned separator. Widgets are sorted by | |
| 62 | 66 | // 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. | |
| 65 | 69 | func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string { | |
| 66 | 70 | if width <= 0 || height <= 0 { | |
| 67 | 71 | return nil | |
| 68 | 72 | } | |
| 69 | 73 | ||
| 74 | + | contentWidth := width - rightBarRightPadding | |
| 70 | 75 | ordered := append([]RightBarWidget(nil), widgets...) | |
| 71 | 76 | sort.SliceStable(ordered, func(a, b int) bool { | |
| 72 | 77 | if ordered[a].Extension != ordered[b].Extension { | |
| @@ -78,10 +83,13 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 78 | 83 | content := make([]string, 0, height) | |
| 79 | 84 | truncated := false | |
| 80 | 85 | padLine := func(text string, phase bool) string { | |
| 86 | + | if contentWidth <= 0 { | |
| 87 | + | return strings.Repeat(" ", width) | |
| 88 | + | } | |
| 81 | 89 | if phase { | |
| 82 | - | text = truncateRightBarPhaseLine(text, width) | |
| 90 | + | text = truncateRightBarPhaseLine(text, contentWidth) | |
| 83 | 91 | } else { | |
| 84 | - | text = truncateRightBarLine(text, width) | |
| 92 | + | text = truncateRightBarLine(text, contentWidth) | |
| 85 | 93 | } | |
| 86 | 94 | if visible := visibleWidth(text); visible < width { | |
| 87 | 95 | text += strings.Repeat(" ", width-visible) | |
| @@ -16,6 +16,10 @@ const ( | |||||
| 16 | RightBarMaxWidth = 36 | 16 | RightBarMaxWidth = 36 | ||
| 17 | // RightBarMinMainWidth preserves enough room for the editor and chat. | 17 | // RightBarMinMainWidth preserves enough room for the editor and chat. | ||
| 18 | RightBarMinMainWidth = 48 | 18 | 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 a | 64 | // RenderRightBar renders a bounded, full-height side rail without adding a | ||
| 61 | // second frame around the host-owned separator. Widgets are sorted by | 65 | // second frame around the host-owned separator. Widgets are sorted by | ||
| 62 | // extension name and ID so output stays deterministic even when extension | 66 | // extension name and ID so output stays deterministic even when extension | ||
| 63 | - | // frames arrive concurrently. Long lines are clipped to the rail width with | 67 | + | // 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 nil | 71 | 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 := false | 84 | 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) { | |||
| 61 | 61 | t.Fatalf("right bar rows = %d, want 8", len(lines)) | |
| 62 | 62 | } | |
| 63 | 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 | 65 | t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain) | |
| 66 | 66 | } | |
| 67 | 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 | 92 | func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) { | |
| 75 | 93 | lines := RenderRightBar(Dark, []RightBarWidget{ | |
| 76 | 94 | { | |
| @@ -105,7 +123,7 @@ func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) { | |||
| 105 | 123 | if len(plainLines) < 4 { | |
| 106 | 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 | 127 | if got := strings.TrimRight(plainLines[3], " "); got != expectedTask { | |
| 110 | 128 | t.Fatalf("task row = %q, want clipped row %q", got, expectedTask) | |
| 111 | 129 | } | |
| @@ -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 | } | ||