From 34ffe632d76e3f02a5337ced3e8259c825da6e3c Mon Sep 17 00:00:00 2001 From: CMS-20 <206795468+CMS-20@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:27:18 -0700 Subject: [PATCH 1/3] Fix dashboard overflow on shorter terminal heights effectiveViewMode() picked Hero/Compact using fixed height thresholds (e.g. height >= 22 for Hero) that no longer matched how many lines renderDashboard actually produces (28-37 lines depending on mode and terminal height). When the real content was taller than the terminal, the terminal itself scrolled the output, clipping the top of the TUI (title/graphs) while the footer stayed visible. Replace the static thresholds with a measurement-based pick: render each candidate mode and choose the largest one whose actual line count fits the current terminal height. Also clamp centerFrame's output to the terminal height as a safety net, trimming the tail (footer/hints) instead of relying on terminal scroll to hide overflow from the top. Adds a regression test that renders the dashboard across a grid of widths/heights and fails if any combination exceeds the terminal height; it reproduces the original bug when run against the old code. --- internal/ui/model.go | 23 ++++++++++++++++------- internal/ui/resize_test.go | 38 ++++++++++++++++++++++++++++++++++++++ internal/ui/views.go | 15 +++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 internal/ui/resize_test.go diff --git a/internal/ui/model.go b/internal/ui/model.go index 49cd576..1647176 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -421,16 +421,25 @@ func (m Model) effectiveViewMode() ViewMode { if m.height > 0 && m.height < 6 { return ViewTiny } + + candidates := []ViewMode{ViewHero, ViewCompact, ViewMini} if m.width > 0 && m.width < 60 { - return ViewCompact + candidates = []ViewMode{ViewCompact, ViewMini} } - if m.height > 0 && m.height < 16 { - return ViewMini - } - if m.height > 0 && m.height < 22 { - return ViewCompact + + // Pick the richest mode whose actual rendered height still fits the + // terminal, instead of relying on hardcoded line-count guesses that + // drift out of sync with the real layout as panels/footers change. + if m.height > 0 { + for _, mode := range candidates { + if dashboardLineCount(m, mode) <= m.height { + return mode + } + } + return ViewTiny } - return ViewHero + + return candidates[0] } func (m *Model) updateRollingMax(down, up float64) { diff --git a/internal/ui/resize_test.go b/internal/ui/resize_test.go new file mode 100644 index 0000000..e198ad6 --- /dev/null +++ b/internal/ui/resize_test.go @@ -0,0 +1,38 @@ +package ui + +import ( + "strings" + "testing" + "time" + + "github.com/programmersd21/flow/internal/history" +) + +// Regression test for a bug where shrinking the terminal produced a +// dashboard taller than the actual viewport, causing the terminal to +// scroll and clip the top of the TUI (title/graphs) instead of adapting +// the view mode. +func TestViewNeverExceedsTerminalHeight(t *testing.T) { + widths := []int{30, 40, 50, 60, 70, 80, 100, 120} + heights := []int{4, 6, 10, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40, 50} + + for _, w := range widths { + for _, h := range heights { + m := Model{ + width: w, + height: h, + tracker: history.NewTracker(), + downHist: history.New(60), + upHist: history.New(60), + refreshInterval: time.Second, + lastSampleTime: time.Now(), + } + + out := m.View() + lines := strings.Count(out, "\n") + 1 + if lines > h { + t.Errorf("w=%d h=%d mode=%v: rendered %d lines, exceeds terminal height", w, h, m.effectiveViewMode(), lines) + } + } + } +} diff --git a/internal/ui/views.go b/internal/ui/views.go index e74c7b2..f58369d 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -258,6 +258,14 @@ func TitleRow(breathe float64) string { return fmt.Sprintf("%s %s %s", dot, title, desc) } +// dashboardLineCount reports how many lines renderDashboard would actually +// produce for the given mode at the model's current terminal size. Used to +// pick the largest view mode that still fits, rather than hardcoded height +// thresholds that can drift out of sync with the real layout. +func dashboardLineCount(m Model, mode ViewMode) int { + return strings.Count(renderDashboard(m, mode), "\n") + 1 +} + func renderDashboard(m Model, mode ViewMode) string { termW := m.width if termW <= 0 { @@ -592,6 +600,13 @@ func centerFrame(content string, width, height int) string { for len(out) < height { out = append(out, "") } + // Never emit more rows than the terminal actually has: keep the top + // (title/current values), drop the tail (footer/hints) if it still + // doesn't fit. Without this, content taller than the viewport gets + // silently scrolled by the terminal itself, cutting off the top instead. + if height > 0 && len(out) > height { + out = out[:height] + } return strings.Join(out, "\n") } From 7015ffc9fe44accd7dc0ee1528693c988b65e7f1 Mon Sep 17 00:00:00 2001 From: CMS-20 <206795468+CMS-20@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:44:49 -0700 Subject: [PATCH 2/3] Fix mode-selection measurement and avoid redundant re-render Gemini Code Assist correctly flagged that dashboardLineCount measured renderDashboard's already-clamped output, so every candidate mode looked like it fit (centerFrame's safety net truncates to terminal height regardless of mode) and effectiveViewMode always picked Hero, silently relying on truncation instead of actually adapting. Fix it by measuring the pre-clamp content height directly. A second, self-inflicted bug surfaced while fixing the first: an earlier version of this measurement used len(contentLines) as the line count, but panel elements are single strings with embedded "\n"s (a bordered panel is one slice element, not one per row), which undercounted badly (12 vs. the real ~28). Fixed by counting real newlines in the joined content instead. Added TestEffectiveViewModeFitsBeforeClamping, which measures the pre-clamp content directly (not via dashboardLineCount, so it still catches a regression in that helper itself) and would have failed against both of the above. Verified it fails on the old code and passes now. Also addresses Sourcery's performance note: introduced pickViewModeAndContent, which builds each candidate's content once during mode selection and returns the winning mode's lines directly, so View() no longer re-renders the chosen mode from scratch a second time. Removed the now-dead renderHero/renderCompact/renderMini/ renderDashboard wrappers. --- internal/ui/model.go | 57 +++++++---------------------- internal/ui/resize_test.go | 38 +++++++++++++++++++ internal/ui/views.go | 75 ++++++++++++++++++++++++++++++++------ 3 files changed, 115 insertions(+), 55 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 1647176..b4184eb 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -392,54 +392,25 @@ func (m Model) View() string { if m.showProcesses { return renderProcesses(m) } - switch m.effectiveViewMode() { - case ViewTiny: + mode, lines := pickViewModeAndContent(m) + if mode == ViewTiny { return renderTiny(m) - case ViewMini: - return renderMini(m) - case ViewCompact: - return renderCompact(m) - default: - return renderHero(m) } + termW := m.width + termH := m.height + if termH <= 0 { + termH = 24 + } + return centerFrame(strings.Join(lines, "\n"), termW, termH) } +// effectiveViewMode reports which view mode pickViewModeAndContent would +// pick for the current terminal size, without needing its rendered +// content. Exists mainly for tests and callers that only care about the +// decision. func (m Model) effectiveViewMode() ViewMode { - if m.viewMode == ViewTiny { - return ViewTiny - } - if m.viewMode == ViewMini { - return ViewMini - } - if m.viewMode == ViewCompact { - return ViewCompact - } - - if m.width > 0 && m.width < 40 { - return ViewTiny - } - if m.height > 0 && m.height < 6 { - return ViewTiny - } - - candidates := []ViewMode{ViewHero, ViewCompact, ViewMini} - if m.width > 0 && m.width < 60 { - candidates = []ViewMode{ViewCompact, ViewMini} - } - - // Pick the richest mode whose actual rendered height still fits the - // terminal, instead of relying on hardcoded line-count guesses that - // drift out of sync with the real layout as panels/footers change. - if m.height > 0 { - for _, mode := range candidates { - if dashboardLineCount(m, mode) <= m.height { - return mode - } - } - return ViewTiny - } - - return candidates[0] + mode, _ := pickViewModeAndContent(m) + return mode } func (m *Model) updateRollingMax(down, up float64) { diff --git a/internal/ui/resize_test.go b/internal/ui/resize_test.go index e198ad6..71a7f98 100644 --- a/internal/ui/resize_test.go +++ b/internal/ui/resize_test.go @@ -36,3 +36,41 @@ func TestViewNeverExceedsTerminalHeight(t *testing.T) { } } } + +// TestEffectiveViewModeFitsBeforeClamping guards against a regression where +// the height measurement used to pick a view mode read centerFrame's +// already-clamped output instead of the raw content: since the clamp +// truncates everything to the terminal height anyway, that made every +// candidate mode look like it "fit", so effectiveViewMode always picked +// Hero and just relied on the clamp to hide the overflow — defeating +// adaptive mode selection while still passing TestViewNeverExceedsTerminalHeight +// above. This test deliberately uses dashboardContentLines directly (not +// dashboardLineCount) as an independent ground truth, so it still catches +// the bug even if dashboardLineCount itself is the thing that regresses. +func TestEffectiveViewModeFitsBeforeClamping(t *testing.T) { + widths := []int{60, 70, 80, 100, 120} + heights := []int{16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40, 50} + + for _, w := range widths { + for _, h := range heights { + m := Model{ + width: w, + height: h, + tracker: history.NewTracker(), + downHist: history.New(60), + upHist: history.New(60), + refreshInterval: time.Second, + lastSampleTime: time.Now(), + } + + mode := m.effectiveViewMode() + if mode == ViewTiny { + continue // single centered line, always fits trivially + } + content := strings.Join(dashboardContentLines(m, mode), "\n") + if got := strings.Count(content, "\n") + 1; got > h { + t.Errorf("w=%d h=%d: effectiveViewMode chose mode=%v needing %d pre-clamp lines, which doesn't fit (mode selection is not actually adaptive)", w, h, mode, got) + } + } + } +} diff --git a/internal/ui/views.go b/internal/ui/views.go index f58369d..d2ccd2e 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -17,10 +17,6 @@ const ( compactInnerMax = 68 ) -func renderHero(m Model) string { return renderDashboard(m, ViewHero) } -func renderCompact(m Model) string { return renderDashboard(m, ViewCompact) } -func renderMini(m Model) string { return renderDashboard(m, ViewMini) } - func renderTiny(m Model) string { downRatio := theme.SpeedRatio(m.dispDown, m.rollingMaxDown) upRatio := theme.SpeedRatio(m.dispUp, m.rollingMaxUp) @@ -258,15 +254,71 @@ func TitleRow(breathe float64) string { return fmt.Sprintf("%s %s %s", dot, title, desc) } -// dashboardLineCount reports how many lines renderDashboard would actually -// produce for the given mode at the model's current terminal size. Used to -// pick the largest view mode that still fits, rather than hardcoded height -// thresholds that can drift out of sync with the real layout. +// lineCount reports how many real terminal rows a set of content lines +// takes up. Elements can themselves be multi-line strings (e.g. a bordered +// panel is one element with embedded "\n"s), so join first and count real +// newlines rather than len(lines). +func lineCount(lines []string) int { + return strings.Count(strings.Join(lines, "\n"), "\n") + 1 +} + +// dashboardLineCount reports how many content lines the given mode actually +// needs at the model's current terminal size, BEFORE centerFrame pads or +// clamps it to fit the viewport. It must measure pre-clamp content, since +// measuring post-clamp output would always report "fits" (centerFrame's +// safety net truncates everything to the terminal height) and defeat mode +// selection entirely. func dashboardLineCount(m Model, mode ViewMode) int { - return strings.Count(renderDashboard(m, mode), "\n") + 1 + return lineCount(dashboardContentLines(m, mode)) +} + +// pickViewModeAndContent chooses the best-fitting view mode for the current +// terminal size and returns it together with its already-built content +// lines, so the caller doesn't need to render the winning candidate a +// second time — building a mode's content re-renders braille graphs and +// styled panels, so doing that twice per frame (once to measure, once to +// display) would be wasted work. Returns nil lines for ViewTiny, which is +// rendered separately by renderTiny. +func pickViewModeAndContent(m Model) (ViewMode, []string) { + if m.viewMode == ViewTiny { + return ViewTiny, nil + } + if m.viewMode == ViewMini { + return ViewMini, dashboardContentLines(m, ViewMini) + } + if m.viewMode == ViewCompact { + return ViewCompact, dashboardContentLines(m, ViewCompact) + } + + if m.width > 0 && m.width < 40 { + return ViewTiny, nil + } + if m.height > 0 && m.height < 6 { + return ViewTiny, nil + } + + candidates := []ViewMode{ViewHero, ViewCompact, ViewMini} + if m.width > 0 && m.width < 60 { + candidates = []ViewMode{ViewCompact, ViewMini} + } + + // Pick the richest mode whose actual rendered height still fits the + // terminal, instead of relying on hardcoded line-count guesses that + // drift out of sync with the real layout as panels/footers change. + if m.height > 0 { + for _, mode := range candidates { + lines := dashboardContentLines(m, mode) + if lineCount(lines) <= m.height { + return mode, lines + } + } + return ViewTiny, nil + } + + return candidates[0], dashboardContentLines(m, candidates[0]) } -func renderDashboard(m Model, mode ViewMode) string { +func dashboardContentLines(m Model, mode ViewMode) []string { termW := m.width if termW <= 0 { termW = 80 @@ -441,8 +493,7 @@ func renderDashboard(m Model, mode ViewMode) string { } } - content := strings.Join(lines, "\n") - return centerFrame(content, termW, termH) + return lines } func renderPanel(title string, value string, peak string, peakPulse float64, graph string, width int, borderColor lipgloss.Color, isMini bool) string { From e0114280b28b619fad526f7a8bfd14f60e72373d Mon Sep 17 00:00:00 2001 From: Soumalya Das Date: Tue, 7 Jul 2026 16:53:09 +0530 Subject: [PATCH 3/3] Update internal/ui/resize_test.go add gemini suggestion Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- internal/ui/resize_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/ui/resize_test.go b/internal/ui/resize_test.go index 71a7f98..f2205b7 100644 --- a/internal/ui/resize_test.go +++ b/internal/ui/resize_test.go @@ -28,10 +28,10 @@ func TestViewNeverExceedsTerminalHeight(t *testing.T) { lastSampleTime: time.Now(), } - out := m.View() - lines := strings.Count(out, "\n") + 1 - if lines > h { - t.Errorf("w=%d h=%d mode=%v: rendered %d lines, exceeds terminal height", w, h, m.effectiveViewMode(), lines) + expectedMode := m.effectiveViewMode() + rawLines := dashboardLineCount(m, expectedMode) + if rawLines > h && expectedMode != ViewTiny { + t.Errorf("w=%d h=%d mode=%v: untruncated lines %d exceeds terminal height %d", w, h, expectedMode, rawLines, h) } } }