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") +}