From c5ff16b9576f67f962e09b5c121351885dd1567e Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Thu, 9 Jul 2026 13:12:37 -0400 Subject: [PATCH 1/2] fix(simulate): make the run viewer page-scrollable so long content fits short terminals The list view rendered the run summary (and job list chrome) with no height budget, so on short terminals (e.g. 31 rows) the view exceeded the screen and the header plus the top of the job list scrolled off - a 10-job run showed only jobs 7-10. Instead of clamping individual sections, compose the whole page (header, description, counts, full job list, summary, logs) and window it to the terminal height with overflow markers, keeping the toast and hint bar pinned at the bottom. - mouse wheel and PgUp/PgDn scroll the page when no pane (detail, description, logs) has focus - arrow keys still move the job cursor; the page auto-scrolls to keep the cursor row visible - the job list's internal two-thirds-height window is gone - the full list renders and the page scrolls instead - the hint bar advertises PgUp/PgDn only when the page actually overflows --- cmd/lk/simulate_tui.go | 139 +++++++++++++++++++---------- cmd/lk/simulate_tui_layout_test.go | 115 ++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 49 deletions(-) create mode 100644 cmd/lk/simulate_tui_layout_test.go diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 041662db..a0bb4aab 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -167,7 +167,6 @@ type simulateModel struct { spinnerIdx int cursor int - scrollOff int detailJobID string detailScrollOff int showLogs bool @@ -176,6 +175,13 @@ type simulateModel struct { logPinnedTotal int showDescription bool descScrollOff int + // whole-page scrolling for the main list view: the full content (header, + // job list, summary, logs) is composed unbounded, then windowed to the + // terminal height with the toast and hint bar pinned below. + viewScrollOff int + followCursor bool // scroll the page to keep the cursor row visible + cursorViewLine int // absolute line of the cursor row in the composed page + pageOverflow bool // last render had more lines than fit toast string toastOK bool @@ -666,28 +672,22 @@ func (m *simulateModel) scrollActive(delta int, includeLogs bool) bool { return true } -// scrollBy routes a mouse-wheel step to the focused pane, falling back to the -// job-list cursor. +// scrollBy routes a mouse-wheel step to the focused pane, falling back to +// scrolling the whole page. func (m *simulateModel) scrollBy(delta int) { if m.scrollActive(delta, true) { return } - jobs := m.filteredJobs() - if len(jobs) == 0 { - return - } - m.cursor += delta - if m.cursor < 0 { - m.cursor = 0 - } - if m.cursor >= len(jobs) { - m.cursor = len(jobs) - 1 + m.viewScrollOff += delta // clamped on render + if m.viewScrollOff < 0 { + m.viewScrollOff = 0 } } func (m *simulateModel) moveCursor(delta int) { if n := len(m.filteredJobs()); n > 0 { m.cursor = ((m.cursor+delta)%n + n) % n + m.followCursor = true } } @@ -806,9 +806,16 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.moveCursor(1) } case "pgup": - m.scrollActive(-pageScroll, true) + if !m.scrollActive(-pageScroll, true) { + m.viewScrollOff -= pageScroll + if m.viewScrollOff < 0 { + m.viewScrollOff = 0 + } + } case "pgdown": - m.scrollActive(pageScroll, true) + if !m.scrollActive(pageScroll, true) { + m.viewScrollOff += pageScroll // clamped on render + } case "enter", "right": if m.detailJobID == "" { jobs := m.filteredJobs() @@ -1087,6 +1094,9 @@ func (m *simulateModel) viewRunning() string { } else if m.matrix.active { b.WriteString(m.matrix.render(m.buildMatrixRows())) } else { + // the job list renders in full; pageWindow scrolls the whole view. + // track the cursor row's absolute line so the page can follow it. + m.cursorViewLine = strings.Count(b.String(), "\n") + m.cursor b.WriteString(m.renderJobList()) if m.run.Status == livekit.SimulationRun_STATUS_SUMMARIZING { @@ -1106,8 +1116,64 @@ func (m *simulateModel) viewRunning() string { if m.showLogs && m.detailJobID == "" { b.WriteString(m.renderLogs("")) } - b.WriteString(m.renderToast()) - b.WriteString(m.renderHint()) + content := b.String() + if m.detailJobID == "" && !m.matrix.active { + content = m.pageWindow(content) + } + return content + m.renderToast() + m.renderHint() + "\n" +} + +// pageWindow clamps the composed page to the terminal height, showing a +// viewScrollOff-positioned window with overflow markers. Without this a long +// summary or job list pushes the header and the top of the list off-screen on +// short terminals. The toast and hint bar are appended after windowing so +// they stay pinned at the bottom. +func (m *simulateModel) pageWindow(content string) string { + lines := strings.Split(strings.TrimRight(content, "\n"), "\n") + reserved := 2 // hint bar + trailing newline + if m.toast != "" { + reserved += strings.Count(m.renderToast(), "\n") + } + budget := m.height - reserved + if budget < 5 { + budget = 5 + } + if len(lines) <= budget { + m.viewScrollOff = 0 + m.pageOverflow = false + return content + } + m.pageOverflow = true + + window := budget - 2 // top and bottom marker rows + maxScroll := len(lines) - window + if m.followCursor { + if m.cursorViewLine < m.viewScrollOff { + m.viewScrollOff = m.cursorViewLine + } else if m.cursorViewLine >= m.viewScrollOff+window { + m.viewScrollOff = m.cursorViewLine - window + 1 + } + m.followCursor = false + } + if m.viewScrollOff > maxScroll { + m.viewScrollOff = maxScroll + } + if m.viewScrollOff < 0 { + m.viewScrollOff = 0 + } + + start := m.viewScrollOff + end := start + window + var b strings.Builder + if start > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↑ %d more lines", start))) + } + b.WriteString("\n") + b.WriteString(strings.Join(lines[start:end], "\n")) + b.WriteString("\n") + if rem := len(lines) - end; rem > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more lines", rem))) + } b.WriteString("\n") return b.String() } @@ -1196,8 +1262,9 @@ func (m *simulateModel) renderCounts() string { return result } -// visibleWindow clamps m.cursor / m.scrollOff against the current filtered -// job list and returns the visible slice plus overflow counts. +// visibleWindow clamps m.cursor against the current filtered job list. The +// list renders in full — pageWindow scrolls the whole view — so there is no +// internal windowing and the overflow counts are always zero. func (m *simulateModel) visibleWindow() (jobs []indexedJob, winStart, winEnd, overflowAbove, overflowBelow int) { jobs = m.filteredJobs() if len(jobs) == 0 { @@ -1209,36 +1276,7 @@ func (m *simulateModel) visibleWindow() (jobs []indexedJob, winStart, winEnd, ov if m.cursor >= len(jobs) { m.cursor = len(jobs) - 1 } - availHeight := matrixAvailHeight(m.height) - maxJobListHeight := m.height * 2 / 3 - if maxJobListHeight < 5 { - maxJobListHeight = 5 - } - if availHeight > maxJobListHeight { - availHeight = maxJobListHeight - } - if m.cursor < m.scrollOff { - m.scrollOff = m.cursor - } else if m.cursor >= m.scrollOff+availHeight { - m.scrollOff = m.cursor - availHeight + 1 - } - if m.scrollOff < 0 { - m.scrollOff = 0 - } - if m.scrollOff > len(jobs)-availHeight { - m.scrollOff = len(jobs) - availHeight - } - if m.scrollOff < 0 { - m.scrollOff = 0 - } - winStart = m.scrollOff - winEnd = m.scrollOff + availHeight - if winEnd > len(jobs) { - winEnd = len(jobs) - } - overflowAbove = winStart - overflowBelow = len(jobs) - winEnd - return + return jobs, 0, len(jobs), 0, 0 } func (m *simulateModel) renderJobList() string { @@ -1785,6 +1823,9 @@ func (m *simulateModel) renderHint() string { default: // the collapsed description block already carries "(press d to expand)" nav := "↑↓ navigate · ENTER/→ detail" + if m.pageOverflow || m.viewScrollOff > 0 { + nav += " · PgUp/PgDn scroll" + } if m.canExportScenarios() { nav += " · s save scenarios" } diff --git a/cmd/lk/simulate_tui_layout_test.go b/cmd/lk/simulate_tui_layout_test.go new file mode 100644 index 00000000..9c4d99f4 --- /dev/null +++ b/cmd/lk/simulate_tui_layout_test.go @@ -0,0 +1,115 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" +) + +// pageScrollModel builds a finished 10-job run with a long summary — the shape +// that used to push the header and top of the job list off-screen on short +// terminals (a 31x156 terminal showed only jobs 7-10). +func pageScrollModel(width, height int) *simulateModel { + m := newSimulateModel(&simulateConfig{}) + m.width = width + m.height = height + m.setupDone = true + + jobs := make([]*livekit.SimulationRun_Job, 10) + for i := range jobs { + jobs[i] = &livekit.SimulationRun_Job{ + Id: fmt.Sprintf("SRJ_%02d", i+1), + Label: fmt.Sprintf("Scenario %d", i+1), + Status: livekit.SimulationRun_Job_STATUS_COMPLETED, + } + } + longText := strings.Repeat("The agent handled the flows well and confirmed values before completing. ", 6) + m.run = &livekit.SimulationRun{ + Id: "SR_test", + Status: livekit.SimulationRun_STATUS_COMPLETED, + Jobs: jobs, + Summary: &livekit.SimulationRunSummary{ + Passed: 8, + Failed: 2, + GoingWell: longText, + ToImprove: longText, + Issues: []*livekit.SimulationRunSummary_Issue{ + {Description: longText, Suggestion: longText}, + {Description: longText, Suggestion: longText}, + }, + }, + } + m.runFinished = true + return m +} + +func viewLines(m *simulateModel) []string { + return strings.Split(strings.TrimRight(m.View(), "\n"), "\n") +} + +// The page must fit the terminal height, anchored to the top by default: the +// header and the first jobs stay visible and the overflow is advertised. +func TestSimulatePageFitsShortTerminal(t *testing.T) { + m := pageScrollModel(156, 31) + view := m.View() + require.LessOrEqual(t, len(viewLines(m)), 31, "view must fit a 31-row terminal") + require.Contains(t, view, "Agent Simulation", "header must stay visible") + require.Contains(t, view, "Scenario 1", "top of the job list must stay visible") + require.Contains(t, view, "more lines", "overflow must be advertised") + require.Contains(t, view, "PgUp/PgDn scroll", "hint must advertise page scrolling") +} + +// Scrolling down reveals the bottom of the summary; the offset clamps at the +// end and the hint bar stays pinned. +func TestSimulatePageScrollsToBottom(t *testing.T) { + m := pageScrollModel(156, 31) + m.View() // establish pageOverflow + m.viewScrollOff = 1 << 20 + view := m.View() + require.LessOrEqual(t, len(viewLines(m)), 31) + require.Contains(t, view, "↑", "scrolled view must show the above-marker") + require.Contains(t, view, "Suggestion:", "bottom of the summary must be reachable") + require.Contains(t, view, "q quit", "hint bar stays pinned below the page") + require.NotContains(t, view, "Agent Simulation", "header scrolls off at the bottom") +} + +// Moving the cursor to the last job scrolls the page to keep it visible. +func TestSimulatePageFollowsCursor(t *testing.T) { + m := pageScrollModel(156, 12) // short enough that the list itself overflows + m.View() + for range 9 { + m.moveCursor(1) + } + view := m.View() + require.LessOrEqual(t, len(viewLines(m)), 12) + require.Contains(t, view, "Scenario 10", "cursor row must be scrolled into view") +} + +// Content that fits renders in full with no markers and no scroll hint. +func TestSimulatePageNoScrollWhenFits(t *testing.T) { + m := pageScrollModel(156, 60) + view := m.View() + require.NotContains(t, view, "more lines") + require.NotContains(t, view, "PgUp/PgDn scroll ·") + require.Contains(t, view, "Scenario 1") + require.Contains(t, view, "Scenario 10") + require.Contains(t, view, "Suggestion:") +} From da510b4b3d2ef58baf2af488b8a0c3223732b52a Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Thu, 9 Jul 2026 14:57:16 -0400 Subject: [PATCH 2/2] remove layout regression tests --- cmd/lk/simulate_tui_layout_test.go | 115 ----------------------------- 1 file changed, 115 deletions(-) delete mode 100644 cmd/lk/simulate_tui_layout_test.go diff --git a/cmd/lk/simulate_tui_layout_test.go b/cmd/lk/simulate_tui_layout_test.go deleted file mode 100644 index 9c4d99f4..00000000 --- a/cmd/lk/simulate_tui_layout_test.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2025 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - "fmt" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/livekit/protocol/livekit" -) - -// pageScrollModel builds a finished 10-job run with a long summary — the shape -// that used to push the header and top of the job list off-screen on short -// terminals (a 31x156 terminal showed only jobs 7-10). -func pageScrollModel(width, height int) *simulateModel { - m := newSimulateModel(&simulateConfig{}) - m.width = width - m.height = height - m.setupDone = true - - jobs := make([]*livekit.SimulationRun_Job, 10) - for i := range jobs { - jobs[i] = &livekit.SimulationRun_Job{ - Id: fmt.Sprintf("SRJ_%02d", i+1), - Label: fmt.Sprintf("Scenario %d", i+1), - Status: livekit.SimulationRun_Job_STATUS_COMPLETED, - } - } - longText := strings.Repeat("The agent handled the flows well and confirmed values before completing. ", 6) - m.run = &livekit.SimulationRun{ - Id: "SR_test", - Status: livekit.SimulationRun_STATUS_COMPLETED, - Jobs: jobs, - Summary: &livekit.SimulationRunSummary{ - Passed: 8, - Failed: 2, - GoingWell: longText, - ToImprove: longText, - Issues: []*livekit.SimulationRunSummary_Issue{ - {Description: longText, Suggestion: longText}, - {Description: longText, Suggestion: longText}, - }, - }, - } - m.runFinished = true - return m -} - -func viewLines(m *simulateModel) []string { - return strings.Split(strings.TrimRight(m.View(), "\n"), "\n") -} - -// The page must fit the terminal height, anchored to the top by default: the -// header and the first jobs stay visible and the overflow is advertised. -func TestSimulatePageFitsShortTerminal(t *testing.T) { - m := pageScrollModel(156, 31) - view := m.View() - require.LessOrEqual(t, len(viewLines(m)), 31, "view must fit a 31-row terminal") - require.Contains(t, view, "Agent Simulation", "header must stay visible") - require.Contains(t, view, "Scenario 1", "top of the job list must stay visible") - require.Contains(t, view, "more lines", "overflow must be advertised") - require.Contains(t, view, "PgUp/PgDn scroll", "hint must advertise page scrolling") -} - -// Scrolling down reveals the bottom of the summary; the offset clamps at the -// end and the hint bar stays pinned. -func TestSimulatePageScrollsToBottom(t *testing.T) { - m := pageScrollModel(156, 31) - m.View() // establish pageOverflow - m.viewScrollOff = 1 << 20 - view := m.View() - require.LessOrEqual(t, len(viewLines(m)), 31) - require.Contains(t, view, "↑", "scrolled view must show the above-marker") - require.Contains(t, view, "Suggestion:", "bottom of the summary must be reachable") - require.Contains(t, view, "q quit", "hint bar stays pinned below the page") - require.NotContains(t, view, "Agent Simulation", "header scrolls off at the bottom") -} - -// Moving the cursor to the last job scrolls the page to keep it visible. -func TestSimulatePageFollowsCursor(t *testing.T) { - m := pageScrollModel(156, 12) // short enough that the list itself overflows - m.View() - for range 9 { - m.moveCursor(1) - } - view := m.View() - require.LessOrEqual(t, len(viewLines(m)), 12) - require.Contains(t, view, "Scenario 10", "cursor row must be scrolled into view") -} - -// Content that fits renders in full with no markers and no scroll hint. -func TestSimulatePageNoScrollWhenFits(t *testing.T) { - m := pageScrollModel(156, 60) - view := m.View() - require.NotContains(t, view, "more lines") - require.NotContains(t, view, "PgUp/PgDn scroll ·") - require.Contains(t, view, "Scenario 1") - require.Contains(t, view, "Scenario 10") - require.Contains(t, view, "Suggestion:") -}