Skip to content

Commit 8cd0c98

Browse files
committed
fix: stderr panel content now expands when focused
The stderr viewport's scroll offset was calculated using the small (20%) height, so GotoBottom() positioned the view incorrectly when the panel expanded to 80%. Viewport dimensions are now updated on every panel switch.
1 parent 85fc004 commit 8cd0c98

File tree

3 files changed

+234
-9
lines changed

3 files changed

+234
-9
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **TUI: Fix stderr panel content not expanding when focused**: When selecting the stderr panel (via `5` or tab), the panel border grew to 80% of the screen but the log content stayed at the original 20% size with empty space below. The viewport's scroll offset was calculated using the small height, so `GotoBottom()` positioned the view incorrectly for the larger panel. Viewport dimensions are now updated whenever panel focus changes.
13+
1014
## [3.2.0] - 2026-02-07
1115

1216
### Changed

internal/tui/tui.go

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -401,24 +401,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
401401
logWidth := m.width - m.jobPanelWidth()
402402
totalLogHeight := m.height - 2 // header + status bar
403403

404-
// Stderr gets 20% of height, stdout gets 80%
405-
stderrHeight := totalLogHeight * 20 / 100
406-
if stderrHeight < 4 {
407-
stderrHeight = 4
408-
}
409-
stdoutHeight := totalLogHeight - stderrHeight
410-
411404
// Store log panel width for line wrapping
412405
m.logPanelWidth = logWidth - 4
413406

414-
m.stdoutView = viewport.New(logWidth-4, stdoutHeight-3)
415-
m.stderrView = viewport.New(logWidth-4, stderrHeight-3)
407+
// Create viewports with initial dimensions (updateLogViewportSizes
408+
// will set the correct heights based on activePanel)
409+
m.stdoutView = viewport.New(logWidth-4, 0)
410+
m.stderrView = viewport.New(logWidth-4, 0)
416411
m.jobListView = viewport.New(m.jobPanelWidth()-4, totalLogHeight-3)
417412

418413
// Enable horizontal scrolling on log viewports
419414
m.stdoutView.SetHorizontalStep(4)
420415
m.stderrView.SetHorizontalStep(4)
421416

417+
// Set correct viewport sizes based on active panel
418+
m.updateLogViewportSizes()
419+
422420
// Calculate visible rows for scrollable panels (matches renderPanels layout)
423421
totalH := m.height - 1 // height - status bar
424422
if totalH < 8 {
@@ -760,22 +758,27 @@ func (m Model) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
760758

761759
case "1":
762760
m.activePanel = panelJobs
761+
m.updateLogViewportSizes()
763762
telemetry.TUIActionExecute("switch_panel")
764763

765764
case "2":
766765
m.activePanel = panelPorts
766+
m.updateLogViewportSizes()
767767
telemetry.TUIActionExecute("switch_panel")
768768

769769
case "3":
770770
m.activePanel = panelRuns
771+
m.updateLogViewportSizes()
771772
telemetry.TUIActionExecute("switch_panel")
772773

773774
case "4":
774775
m.activePanel = panelStdout
776+
m.updateLogViewportSizes()
775777
telemetry.TUIActionExecute("switch_panel")
776778

777779
case "5":
778780
m.activePanel = panelStderr
781+
m.updateLogViewportSizes()
779782
telemetry.TUIActionExecute("switch_panel")
780783

781784
case "tab":
@@ -791,6 +794,7 @@ func (m Model) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
791794
case panelStderr:
792795
m.activePanel = panelJobs
793796
}
797+
m.updateLogViewportSizes()
794798
telemetry.TUIActionExecute("switch_panel")
795799

796800
case "shift+tab":
@@ -806,6 +810,7 @@ func (m Model) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
806810
case panelStderr:
807811
m.activePanel = panelStdout
808812
}
813+
m.updateLogViewportSizes()
809814
telemetry.TUIActionExecute("switch_panel")
810815

811816
case "?":
@@ -1369,6 +1374,45 @@ func (m Model) jobPanelWidth() int {
13691374
return w
13701375
}
13711376

1377+
// logPanelHeights returns the stdout and stderr panel heights based on the
1378+
// current active panel. Stderr expands to 80% when focused, otherwise 20%.
1379+
func (m Model) logPanelHeights() (stdoutH, stderrH int) {
1380+
totalH := m.height - 1 // height - status bar
1381+
if totalH < 8 {
1382+
totalH = 8
1383+
}
1384+
1385+
if m.activePanel == panelStderr {
1386+
stderrH = totalH * 80 / 100
1387+
} else {
1388+
stderrH = totalH * 20 / 100
1389+
}
1390+
if stderrH < 4 {
1391+
stderrH = 4
1392+
}
1393+
stdoutH = totalH - stderrH
1394+
return stdoutH, stderrH
1395+
}
1396+
1397+
// updateLogViewportSizes recalculates the stdout/stderr viewport dimensions
1398+
// based on the current active panel and window size. This must be called
1399+
// whenever activePanel or window size changes so that GotoBottom and scroll
1400+
// operations use the correct height.
1401+
func (m *Model) updateLogViewportSizes() {
1402+
rightPanelW := m.width - m.jobPanelWidth()
1403+
stdoutH, stderrH := m.logPanelHeights()
1404+
1405+
m.stdoutView.Width = rightPanelW - 4
1406+
m.stdoutView.Height = stdoutH - 3
1407+
m.stderrView.Width = rightPanelW - 4
1408+
m.stderrView.Height = stderrH - 3
1409+
1410+
if m.followLogs {
1411+
m.stdoutView.GotoBottom()
1412+
m.stderrView.GotoBottom()
1413+
}
1414+
}
1415+
13721416
// View renders the UI
13731417
func (m Model) View() string {
13741418
if !m.ready {

internal/tui/tui_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestLogPanelHeights_DefaultPanel(t *testing.T) {
8+
// When stderr is not focused, it should get 20% and stdout 80%
9+
m := Model{
10+
height: 101, // totalH = 100
11+
width: 200,
12+
activePanel: panelJobs,
13+
}
14+
15+
stdoutH, stderrH := m.logPanelHeights()
16+
17+
if stderrH != 20 {
18+
t.Errorf("stderrH = %d, want 20 (20%% of 100)", stderrH)
19+
}
20+
if stdoutH != 80 {
21+
t.Errorf("stdoutH = %d, want 80 (100 - 20)", stdoutH)
22+
}
23+
}
24+
25+
func TestLogPanelHeights_StderrFocused(t *testing.T) {
26+
// When stderr is focused, it should get 80% and stdout 20%
27+
m := Model{
28+
height: 101, // totalH = 100
29+
width: 200,
30+
activePanel: panelStderr,
31+
}
32+
33+
stdoutH, stderrH := m.logPanelHeights()
34+
35+
if stderrH != 80 {
36+
t.Errorf("stderrH = %d, want 80 (80%% of 100)", stderrH)
37+
}
38+
if stdoutH != 20 {
39+
t.Errorf("stdoutH = %d, want 20 (100 - 80)", stdoutH)
40+
}
41+
}
42+
43+
func TestLogPanelHeights_StdoutFocused(t *testing.T) {
44+
// Stdout focused should behave like default (stderr stays small)
45+
m := Model{
46+
height: 101,
47+
width: 200,
48+
activePanel: panelStdout,
49+
}
50+
51+
stdoutH, stderrH := m.logPanelHeights()
52+
53+
if stderrH != 20 {
54+
t.Errorf("stderrH = %d, want 20", stderrH)
55+
}
56+
if stdoutH != 80 {
57+
t.Errorf("stdoutH = %d, want 80", stdoutH)
58+
}
59+
}
60+
61+
func TestLogPanelHeights_MinimumStderrHeight(t *testing.T) {
62+
// With very small terminal, stderr should be at least 4
63+
m := Model{
64+
height: 10, // totalH = 9
65+
width: 200,
66+
activePanel: panelJobs,
67+
}
68+
69+
stdoutH, stderrH := m.logPanelHeights()
70+
71+
// 9 * 20 / 100 = 1, which is less than 4
72+
if stderrH < 4 {
73+
t.Errorf("stderrH = %d, want >= 4 (minimum)", stderrH)
74+
}
75+
if stdoutH+stderrH != 9 {
76+
t.Errorf("stdoutH(%d) + stderrH(%d) = %d, want 9 (totalH)", stdoutH, stderrH, stdoutH+stderrH)
77+
}
78+
}
79+
80+
func TestLogPanelHeights_MinimumTotalHeight(t *testing.T) {
81+
// With tiny terminal, totalH should be clamped to 8
82+
m := Model{
83+
height: 3, // totalH = 2, clamped to 8
84+
width: 200,
85+
activePanel: panelJobs,
86+
}
87+
88+
stdoutH, stderrH := m.logPanelHeights()
89+
90+
// totalH clamped to 8, stderrH = 8 * 20 / 100 = 1, clamped to 4
91+
if stderrH < 4 {
92+
t.Errorf("stderrH = %d, want >= 4 (minimum)", stderrH)
93+
}
94+
if stdoutH+stderrH != 8 {
95+
t.Errorf("stdoutH(%d) + stderrH(%d) = %d, want 8 (clamped totalH)", stdoutH, stderrH, stdoutH+stderrH)
96+
}
97+
}
98+
99+
func TestLogPanelHeights_SumEqualsTotal(t *testing.T) {
100+
// Heights should always sum to totalH for all panels
101+
panels := []panel{panelJobs, panelPorts, panelRuns, panelStdout, panelStderr}
102+
103+
for _, p := range panels {
104+
m := Model{
105+
height: 51, // totalH = 50
106+
width: 200,
107+
activePanel: p,
108+
}
109+
110+
stdoutH, stderrH := m.logPanelHeights()
111+
totalH := 50
112+
113+
if stdoutH+stderrH != totalH {
114+
t.Errorf("panel %d: stdoutH(%d) + stderrH(%d) = %d, want %d",
115+
p, stdoutH, stderrH, stdoutH+stderrH, totalH)
116+
}
117+
}
118+
}
119+
120+
func TestUpdateLogViewportSizes_SetsCorrectDimensions(t *testing.T) {
121+
m := New()
122+
m.width = 200
123+
m.height = 101 // totalH = 100
124+
m.activePanel = panelJobs
125+
126+
m.updateLogViewportSizes()
127+
128+
// jobPanelWidth for width=200: 200*40/100 = 80, capped at 60
129+
// rightPanelW = 200 - 60 = 140
130+
// viewportWidth = 140 - 4 = 136
131+
expectedWidth := 136
132+
133+
// stderrH = 100 * 20 / 100 = 20, viewportHeight = 20 - 3 = 17
134+
// stdoutH = 100 - 20 = 80, viewportHeight = 80 - 3 = 77
135+
expectedStderrHeight := 17
136+
expectedStdoutHeight := 77
137+
138+
if m.stderrView.Width != expectedWidth {
139+
t.Errorf("stderrView.Width = %d, want %d", m.stderrView.Width, expectedWidth)
140+
}
141+
if m.stderrView.Height != expectedStderrHeight {
142+
t.Errorf("stderrView.Height = %d, want %d", m.stderrView.Height, expectedStderrHeight)
143+
}
144+
if m.stdoutView.Width != expectedWidth {
145+
t.Errorf("stdoutView.Width = %d, want %d", m.stdoutView.Width, expectedWidth)
146+
}
147+
if m.stdoutView.Height != expectedStdoutHeight {
148+
t.Errorf("stdoutView.Height = %d, want %d", m.stdoutView.Height, expectedStdoutHeight)
149+
}
150+
}
151+
152+
func TestUpdateLogViewportSizes_StderrExpands(t *testing.T) {
153+
m := New()
154+
m.width = 200
155+
m.height = 101 // totalH = 100
156+
157+
// First set to jobs panel (stderr = 20%)
158+
m.activePanel = panelJobs
159+
m.updateLogViewportSizes()
160+
smallStderrHeight := m.stderrView.Height
161+
smallStdoutHeight := m.stdoutView.Height
162+
163+
// Switch to stderr panel (stderr = 80%)
164+
m.activePanel = panelStderr
165+
m.updateLogViewportSizes()
166+
bigStderrHeight := m.stderrView.Height
167+
bigStdoutHeight := m.stdoutView.Height
168+
169+
if bigStderrHeight <= smallStderrHeight {
170+
t.Errorf("stderr focused: Height %d should be > unfocused Height %d",
171+
bigStderrHeight, smallStderrHeight)
172+
}
173+
if bigStdoutHeight >= smallStdoutHeight {
174+
t.Errorf("stderr focused: stdout Height %d should be < unfocused stdout Height %d",
175+
bigStdoutHeight, smallStdoutHeight)
176+
}
177+
}

0 commit comments

Comments
 (0)