@@ -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 .
671677func (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
688687func (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.
12011268func (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
12441282func (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 }
0 commit comments