Skip to content

Commit c5ff16b

Browse files
committed
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
1 parent 0232fdb commit c5ff16b

2 files changed

Lines changed: 205 additions & 49 deletions

File tree

cmd/lk/simulate_tui.go

Lines changed: 90 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,6 @@ type simulateModel struct {
167167
spinnerIdx int
168168

169169
cursor int
170-
scrollOff int
171170
detailJobID string
172171
detailScrollOff int
173172
showLogs bool
@@ -176,6 +175,13 @@ type simulateModel struct {
176175
logPinnedTotal int
177176
showDescription bool
178177
descScrollOff int
178+
// whole-page scrolling for the main list view: the full content (header,
179+
// job list, summary, logs) is composed unbounded, then windowed to the
180+
// terminal height with the toast and hint bar pinned below.
181+
viewScrollOff int
182+
followCursor bool // scroll the page to keep the cursor row visible
183+
cursorViewLine int // absolute line of the cursor row in the composed page
184+
pageOverflow bool // last render had more lines than fit
179185

180186
toast string
181187
toastOK bool
@@ -666,28 +672,22 @@ func (m *simulateModel) scrollActive(delta int, includeLogs bool) bool {
666672
return true
667673
}
668674

669-
// scrollBy routes a mouse-wheel step to the focused pane, falling back to the
670-
// job-list cursor.
675+
// scrollBy routes a mouse-wheel step to the focused pane, falling back to
676+
// scrolling the whole page.
671677
func (m *simulateModel) scrollBy(delta int) {
672678
if m.scrollActive(delta, true) {
673679
return
674680
}
675-
jobs := m.filteredJobs()
676-
if len(jobs) == 0 {
677-
return
678-
}
679-
m.cursor += delta
680-
if m.cursor < 0 {
681-
m.cursor = 0
682-
}
683-
if m.cursor >= len(jobs) {
684-
m.cursor = len(jobs) - 1
681+
m.viewScrollOff += delta // clamped on render
682+
if m.viewScrollOff < 0 {
683+
m.viewScrollOff = 0
685684
}
686685
}
687686

688687
func (m *simulateModel) moveCursor(delta int) {
689688
if n := len(m.filteredJobs()); n > 0 {
690689
m.cursor = ((m.cursor+delta)%n + n) % n
690+
m.followCursor = true
691691
}
692692
}
693693

@@ -806,9 +806,16 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
806806
m.moveCursor(1)
807807
}
808808
case "pgup":
809-
m.scrollActive(-pageScroll, true)
809+
if !m.scrollActive(-pageScroll, true) {
810+
m.viewScrollOff -= pageScroll
811+
if m.viewScrollOff < 0 {
812+
m.viewScrollOff = 0
813+
}
814+
}
810815
case "pgdown":
811-
m.scrollActive(pageScroll, true)
816+
if !m.scrollActive(pageScroll, true) {
817+
m.viewScrollOff += pageScroll // clamped on render
818+
}
812819
case "enter", "right":
813820
if m.detailJobID == "" {
814821
jobs := m.filteredJobs()
@@ -1087,6 +1094,9 @@ func (m *simulateModel) viewRunning() string {
10871094
} else if m.matrix.active {
10881095
b.WriteString(m.matrix.render(m.buildMatrixRows()))
10891096
} else {
1097+
// the job list renders in full; pageWindow scrolls the whole view.
1098+
// track the cursor row's absolute line so the page can follow it.
1099+
m.cursorViewLine = strings.Count(b.String(), "\n") + m.cursor
10901100
b.WriteString(m.renderJobList())
10911101

10921102
if m.run.Status == livekit.SimulationRun_STATUS_SUMMARIZING {
@@ -1106,8 +1116,64 @@ func (m *simulateModel) viewRunning() string {
11061116
if m.showLogs && m.detailJobID == "" {
11071117
b.WriteString(m.renderLogs(""))
11081118
}
1109-
b.WriteString(m.renderToast())
1110-
b.WriteString(m.renderHint())
1119+
content := b.String()
1120+
if m.detailJobID == "" && !m.matrix.active {
1121+
content = m.pageWindow(content)
1122+
}
1123+
return content + m.renderToast() + m.renderHint() + "\n"
1124+
}
1125+
1126+
// pageWindow clamps the composed page to the terminal height, showing a
1127+
// viewScrollOff-positioned window with overflow markers. Without this a long
1128+
// summary or job list pushes the header and the top of the list off-screen on
1129+
// short terminals. The toast and hint bar are appended after windowing so
1130+
// they stay pinned at the bottom.
1131+
func (m *simulateModel) pageWindow(content string) string {
1132+
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
1133+
reserved := 2 // hint bar + trailing newline
1134+
if m.toast != "" {
1135+
reserved += strings.Count(m.renderToast(), "\n")
1136+
}
1137+
budget := m.height - reserved
1138+
if budget < 5 {
1139+
budget = 5
1140+
}
1141+
if len(lines) <= budget {
1142+
m.viewScrollOff = 0
1143+
m.pageOverflow = false
1144+
return content
1145+
}
1146+
m.pageOverflow = true
1147+
1148+
window := budget - 2 // top and bottom marker rows
1149+
maxScroll := len(lines) - window
1150+
if m.followCursor {
1151+
if m.cursorViewLine < m.viewScrollOff {
1152+
m.viewScrollOff = m.cursorViewLine
1153+
} else if m.cursorViewLine >= m.viewScrollOff+window {
1154+
m.viewScrollOff = m.cursorViewLine - window + 1
1155+
}
1156+
m.followCursor = false
1157+
}
1158+
if m.viewScrollOff > maxScroll {
1159+
m.viewScrollOff = maxScroll
1160+
}
1161+
if m.viewScrollOff < 0 {
1162+
m.viewScrollOff = 0
1163+
}
1164+
1165+
start := m.viewScrollOff
1166+
end := start + window
1167+
var b strings.Builder
1168+
if start > 0 {
1169+
b.WriteString(dimStyle.Render(fmt.Sprintf(" ↑ %d more lines", start)))
1170+
}
1171+
b.WriteString("\n")
1172+
b.WriteString(strings.Join(lines[start:end], "\n"))
1173+
b.WriteString("\n")
1174+
if rem := len(lines) - end; rem > 0 {
1175+
b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more lines", rem)))
1176+
}
11111177
b.WriteString("\n")
11121178
return b.String()
11131179
}
@@ -1196,8 +1262,9 @@ func (m *simulateModel) renderCounts() string {
11961262
return result
11971263
}
11981264

1199-
// visibleWindow clamps m.cursor / m.scrollOff against the current filtered
1200-
// job list and returns the visible slice plus overflow counts.
1265+
// visibleWindow clamps m.cursor against the current filtered job list. The
1266+
// list renders in full — pageWindow scrolls the whole view — so there is no
1267+
// internal windowing and the overflow counts are always zero.
12011268
func (m *simulateModel) visibleWindow() (jobs []indexedJob, winStart, winEnd, overflowAbove, overflowBelow int) {
12021269
jobs = m.filteredJobs()
12031270
if len(jobs) == 0 {
@@ -1209,36 +1276,7 @@ func (m *simulateModel) visibleWindow() (jobs []indexedJob, winStart, winEnd, ov
12091276
if m.cursor >= len(jobs) {
12101277
m.cursor = len(jobs) - 1
12111278
}
1212-
availHeight := matrixAvailHeight(m.height)
1213-
maxJobListHeight := m.height * 2 / 3
1214-
if maxJobListHeight < 5 {
1215-
maxJobListHeight = 5
1216-
}
1217-
if availHeight > maxJobListHeight {
1218-
availHeight = maxJobListHeight
1219-
}
1220-
if m.cursor < m.scrollOff {
1221-
m.scrollOff = m.cursor
1222-
} else if m.cursor >= m.scrollOff+availHeight {
1223-
m.scrollOff = m.cursor - availHeight + 1
1224-
}
1225-
if m.scrollOff < 0 {
1226-
m.scrollOff = 0
1227-
}
1228-
if m.scrollOff > len(jobs)-availHeight {
1229-
m.scrollOff = len(jobs) - availHeight
1230-
}
1231-
if m.scrollOff < 0 {
1232-
m.scrollOff = 0
1233-
}
1234-
winStart = m.scrollOff
1235-
winEnd = m.scrollOff + availHeight
1236-
if winEnd > len(jobs) {
1237-
winEnd = len(jobs)
1238-
}
1239-
overflowAbove = winStart
1240-
overflowBelow = len(jobs) - winEnd
1241-
return
1279+
return jobs, 0, len(jobs), 0, 0
12421280
}
12431281

12441282
func (m *simulateModel) renderJobList() string {
@@ -1785,6 +1823,9 @@ func (m *simulateModel) renderHint() string {
17851823
default:
17861824
// the collapsed description block already carries "(press d to expand)"
17871825
nav := "↑↓ navigate · ENTER/→ detail"
1826+
if m.pageOverflow || m.viewScrollOff > 0 {
1827+
nav += " · PgUp/PgDn scroll"
1828+
}
17881829
if m.canExportScenarios() {
17891830
nav += " · s save scenarios"
17901831
}

cmd/lk/simulate_tui_layout_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright 2025 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"fmt"
19+
"strings"
20+
"testing"
21+
22+
"github.com/stretchr/testify/require"
23+
24+
"github.com/livekit/protocol/livekit"
25+
)
26+
27+
// pageScrollModel builds a finished 10-job run with a long summary — the shape
28+
// that used to push the header and top of the job list off-screen on short
29+
// terminals (a 31x156 terminal showed only jobs 7-10).
30+
func pageScrollModel(width, height int) *simulateModel {
31+
m := newSimulateModel(&simulateConfig{})
32+
m.width = width
33+
m.height = height
34+
m.setupDone = true
35+
36+
jobs := make([]*livekit.SimulationRun_Job, 10)
37+
for i := range jobs {
38+
jobs[i] = &livekit.SimulationRun_Job{
39+
Id: fmt.Sprintf("SRJ_%02d", i+1),
40+
Label: fmt.Sprintf("Scenario %d", i+1),
41+
Status: livekit.SimulationRun_Job_STATUS_COMPLETED,
42+
}
43+
}
44+
longText := strings.Repeat("The agent handled the flows well and confirmed values before completing. ", 6)
45+
m.run = &livekit.SimulationRun{
46+
Id: "SR_test",
47+
Status: livekit.SimulationRun_STATUS_COMPLETED,
48+
Jobs: jobs,
49+
Summary: &livekit.SimulationRunSummary{
50+
Passed: 8,
51+
Failed: 2,
52+
GoingWell: longText,
53+
ToImprove: longText,
54+
Issues: []*livekit.SimulationRunSummary_Issue{
55+
{Description: longText, Suggestion: longText},
56+
{Description: longText, Suggestion: longText},
57+
},
58+
},
59+
}
60+
m.runFinished = true
61+
return m
62+
}
63+
64+
func viewLines(m *simulateModel) []string {
65+
return strings.Split(strings.TrimRight(m.View(), "\n"), "\n")
66+
}
67+
68+
// The page must fit the terminal height, anchored to the top by default: the
69+
// header and the first jobs stay visible and the overflow is advertised.
70+
func TestSimulatePageFitsShortTerminal(t *testing.T) {
71+
m := pageScrollModel(156, 31)
72+
view := m.View()
73+
require.LessOrEqual(t, len(viewLines(m)), 31, "view must fit a 31-row terminal")
74+
require.Contains(t, view, "Agent Simulation", "header must stay visible")
75+
require.Contains(t, view, "Scenario 1", "top of the job list must stay visible")
76+
require.Contains(t, view, "more lines", "overflow must be advertised")
77+
require.Contains(t, view, "PgUp/PgDn scroll", "hint must advertise page scrolling")
78+
}
79+
80+
// Scrolling down reveals the bottom of the summary; the offset clamps at the
81+
// end and the hint bar stays pinned.
82+
func TestSimulatePageScrollsToBottom(t *testing.T) {
83+
m := pageScrollModel(156, 31)
84+
m.View() // establish pageOverflow
85+
m.viewScrollOff = 1 << 20
86+
view := m.View()
87+
require.LessOrEqual(t, len(viewLines(m)), 31)
88+
require.Contains(t, view, "↑", "scrolled view must show the above-marker")
89+
require.Contains(t, view, "Suggestion:", "bottom of the summary must be reachable")
90+
require.Contains(t, view, "q quit", "hint bar stays pinned below the page")
91+
require.NotContains(t, view, "Agent Simulation", "header scrolls off at the bottom")
92+
}
93+
94+
// Moving the cursor to the last job scrolls the page to keep it visible.
95+
func TestSimulatePageFollowsCursor(t *testing.T) {
96+
m := pageScrollModel(156, 12) // short enough that the list itself overflows
97+
m.View()
98+
for range 9 {
99+
m.moveCursor(1)
100+
}
101+
view := m.View()
102+
require.LessOrEqual(t, len(viewLines(m)), 12)
103+
require.Contains(t, view, "Scenario 10", "cursor row must be scrolled into view")
104+
}
105+
106+
// Content that fits renders in full with no markers and no scroll hint.
107+
func TestSimulatePageNoScrollWhenFits(t *testing.T) {
108+
m := pageScrollModel(156, 60)
109+
view := m.View()
110+
require.NotContains(t, view, "more lines")
111+
require.NotContains(t, view, "PgUp/PgDn scroll ·")
112+
require.Contains(t, view, "Scenario 1")
113+
require.Contains(t, view, "Scenario 10")
114+
require.Contains(t, view, "Suggestion:")
115+
}

0 commit comments

Comments
 (0)