From c3f3ea9cea3be30f34ecf375a21a820403e78d80 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Thu, 9 Jul 2026 12:00:00 -0400 Subject: [PATCH] fix(simulate): keep the job list visible when the summary overflows short terminals The list view appended the full run summary below the job list with no height budget, so on short terminals (e.g. 31 rows) the rendered 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. Clamp the summary to the height remaining under the list with a "press S to expand" marker, and add an expanded state (S to toggle, arrows/PgUp/PgDn to scroll, esc/q to collapse) that shows the full summary in place of the list, mirroring the detail/description patterns. --- cmd/lk/simulate_tui.go | 106 +++++++++++++++++++++++++++- cmd/lk/simulate_tui_layout_test.go | 108 +++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 2 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..6ee25fed 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -176,6 +176,13 @@ type simulateModel struct { logPinnedTotal int showDescription bool descScrollOff int + // summaryExpanded shows the run summary full-height (scrollable) instead + // of the job list; in the list view the summary is clamped to fit. + // summaryTruncated records whether the last render actually clamped it, + // gating the S binding and its hint. + summaryExpanded bool + summaryScrollOff int + summaryTruncated bool toast string toastOK bool @@ -651,6 +658,11 @@ func (m *simulateModel) scrollActive(delta int, includeLogs bool) bool { if m.descScrollOff < 0 { m.descScrollOff = 0 } + case m.summaryExpanded: + m.summaryScrollOff += delta + if m.summaryScrollOff < 0 { + m.summaryScrollOff = 0 + } case includeLogs && m.showLogs: // logScrollOff counts up from the bottom; scrolling down decreases it m.logScrollOff -= delta @@ -782,6 +794,14 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.showDescription = !m.showDescription m.descScrollOff = 0 } + case "S": + // only meaningful when the collapsed view had to clamp the summary + // (or to collapse an already-expanded one) + if m.detailJobID == "" && m.run != nil && m.run.Summary != nil && + (m.summaryExpanded || m.summaryTruncated) { + m.summaryExpanded = !m.summaryExpanded + m.summaryScrollOff = 0 + } case "s": if m.canExportScenarios() && m.detailJobID == "" { m.saving = true @@ -810,7 +830,7 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "pgdown": m.scrollActive(pageScroll, true) case "enter", "right": - if m.detailJobID == "" { + if m.detailJobID == "" && !m.summaryExpanded { jobs := m.filteredJobs() if m.cursor >= 0 && m.cursor < len(jobs) { m.detailJobID = jobs[m.cursor].job.Id @@ -821,6 +841,9 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.detailJobID != "" { m.detailJobID = "" m.detailScrollOff = 0 + } else if m.summaryExpanded { + m.summaryExpanded = false + m.summaryScrollOff = 0 } else if m.showDescription { m.showDescription = false m.descScrollOff = 0 @@ -830,6 +853,9 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case m.detailJobID != "": m.detailJobID = "" m.detailScrollOff = 0 + case m.summaryExpanded: + m.summaryExpanded = false + m.summaryScrollOff = 0 case m.showDescription: m.showDescription = false m.descScrollOff = 0 @@ -1086,13 +1112,15 @@ func (m *simulateModel) viewRunning() string { b.WriteString(m.scrolledDetail()) } else if m.matrix.active { b.WriteString(m.matrix.render(m.buildMatrixRows())) + } else if m.summaryExpanded && m.run.Summary != nil { + b.WriteString(m.scrolledSummary()) } else { b.WriteString(m.renderJobList()) if m.run.Status == livekit.SimulationRun_STATUS_SUMMARIZING { fmt.Fprintf(&b, "\n %s %s %s\n", yellowStyle().Render("⏺"), yellowStyle().Render("Generating summary..."), m.spinner()) } else if m.run.Summary != nil { - b.WriteString(m.renderSummary()) + b.WriteString(m.clampedSummary(strings.Count(b.String(), "\n"))) } else if isTerminalRunStatus(m.run.Status) { msg := "The summary for this run is not available" if m.run.Error != "" { @@ -1583,6 +1611,75 @@ func (m *simulateModel) renderSummary() string { return b.String() } +// clampedSummary caps the summary block so the whole list view fits the +// terminal height; without this, a long summary pushes the header and the top +// of the job list off-screen on short terminals. usedLines is the number of +// lines already rendered above the summary. +func (m *simulateModel) clampedSummary(usedLines int) string { + content := m.renderSummary() + lines := strings.Split(strings.TrimRight(content, "\n"), "\n") + reserved := 3 // toast + hint + trailing blank line + if m.showLogs { + reserved += m.height/3 + 2 + } + budget := m.height - usedLines - reserved + if budget < 3 { + budget = 3 + } + m.summaryTruncated = len(lines) > budget + if !m.summaryTruncated { + return content + } + var b strings.Builder + for _, line := range lines[:budget-1] { + b.WriteString(line + "\n") + } + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more summary lines · press S to expand", len(lines)-(budget-1))) + "\n") + return b.String() +} + +// scrolledSummary renders the full summary in place of the job list, windowed +// by summaryScrollOff — the same pattern as scrolledDetail. +func (m *simulateModel) scrolledSummary() string { + content := m.renderSummary() + lines := strings.Split(strings.TrimRight(content, "\n"), "\n") + budget := m.height - 12 + if budget < 5 { + budget = 5 + } + if len(lines) <= budget { + m.summaryScrollOff = 0 + return content + } + + maxScroll := len(lines) - budget + if m.summaryScrollOff > maxScroll { + m.summaryScrollOff = maxScroll + } + if m.summaryScrollOff < 0 { + m.summaryScrollOff = 0 + } + + start := m.summaryScrollOff + end := start + budget + if end > len(lines) { + end = len(lines) + } + + var b strings.Builder + if start > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↑ %d more lines above", start))) + b.WriteString("\n") + } + b.WriteString(strings.Join(lines[start:end], "\n")) + b.WriteString("\n") + if end < len(lines) { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more lines below", len(lines)-end))) + b.WriteString("\n") + } + return b.String() +} + func (m *simulateModel) renderChatTranscript(jobID string) string { if m.run.Summary == nil || m.run.Summary.ChatHistory == nil { return "" @@ -1782,9 +1879,14 @@ func (m *simulateModel) renderHint() string { } case m.descriptionExpanded(): parts = append(parts, "↑↓ scroll · d collapse description") + case m.summaryExpanded: + parts = append(parts, "↑↓ scroll · S/ESC collapse summary") default: // the collapsed description block already carries "(press d to expand)" nav := "↑↓ navigate · ENTER/→ detail" + if m.run != nil && m.run.Summary != nil && m.summaryTruncated { + nav += " · S summary" + } 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..06bf0712 --- /dev/null +++ b/cmd/lk/simulate_tui_layout_test.go @@ -0,0 +1,108 @@ +// 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" +) + +// summaryTestModel 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 summaryTestModel(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 +} + +// The list view must fit the terminal height so the job list stays visible; a +// long summary is clamped rather than pushing the top of the view off-screen. +func TestSimulateViewFitsShortTerminal(t *testing.T) { + m := summaryTestModel(156, 31) + view := m.View() + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + require.LessOrEqual(t, len(lines), 31, "list view must fit a 31-row terminal") + require.Contains(t, view, "Scenario 1", "first job must remain visible") + require.Contains(t, view, "Scenario 10", "last job must remain visible") + require.Contains(t, view, "more summary lines", "clamped summary must advertise expansion") +} + +// Expanding the summary shows it windowed and scrollable in place of the list. +func TestSimulateSummaryExpandScroll(t *testing.T) { + m := summaryTestModel(156, 31) + m.summaryExpanded = true + view := m.View() + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + require.LessOrEqual(t, len(lines), 31, "expanded summary view must fit the terminal") + require.Contains(t, view, "more lines below") + + m.summaryScrollOff = 1000 // clamped to max scroll on render + view = m.View() + require.Contains(t, view, "more lines above") + require.NotContains(t, view, "Scenario 1", "job list is replaced while the summary is expanded") +} + +// A short summary renders in full, unclamped. +func TestSimulateShortSummaryUnclamped(t *testing.T) { + m := summaryTestModel(156, 40) + m.run.Summary.GoingWell = "All good." + m.run.Summary.ToImprove = "" + m.run.Summary.Issues = nil + view := m.View() + require.NotContains(t, view, "more summary lines") + require.Contains(t, view, "All good.") + require.NotContains(t, view, "S summary", "hint must not advertise expansion when nothing is clamped") +} + +// The hint bar advertises S only when the summary was actually clamped. +func TestSimulateSummaryHintOnlyWhenTruncated(t *testing.T) { + m := summaryTestModel(156, 31) + view := m.View() + require.Contains(t, view, "S summary") +}