Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 90 additions & 49 deletions cmd/lk/simulate_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ type simulateModel struct {
spinnerIdx int

cursor int
scrollOff int
detailJobID string
detailScrollOff int
showLogs bool
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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"
}
Expand Down
Loading