Skip to content

Commit c3f3ea9

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

2 files changed

Lines changed: 212 additions & 2 deletions

File tree

cmd/lk/simulate_tui.go

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,13 @@ type simulateModel struct {
176176
logPinnedTotal int
177177
showDescription bool
178178
descScrollOff int
179+
// summaryExpanded shows the run summary full-height (scrollable) instead
180+
// of the job list; in the list view the summary is clamped to fit.
181+
// summaryTruncated records whether the last render actually clamped it,
182+
// gating the S binding and its hint.
183+
summaryExpanded bool
184+
summaryScrollOff int
185+
summaryTruncated bool
179186

180187
toast string
181188
toastOK bool
@@ -651,6 +658,11 @@ func (m *simulateModel) scrollActive(delta int, includeLogs bool) bool {
651658
if m.descScrollOff < 0 {
652659
m.descScrollOff = 0
653660
}
661+
case m.summaryExpanded:
662+
m.summaryScrollOff += delta
663+
if m.summaryScrollOff < 0 {
664+
m.summaryScrollOff = 0
665+
}
654666
case includeLogs && m.showLogs:
655667
// logScrollOff counts up from the bottom; scrolling down decreases it
656668
m.logScrollOff -= delta
@@ -782,6 +794,14 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
782794
m.showDescription = !m.showDescription
783795
m.descScrollOff = 0
784796
}
797+
case "S":
798+
// only meaningful when the collapsed view had to clamp the summary
799+
// (or to collapse an already-expanded one)
800+
if m.detailJobID == "" && m.run != nil && m.run.Summary != nil &&
801+
(m.summaryExpanded || m.summaryTruncated) {
802+
m.summaryExpanded = !m.summaryExpanded
803+
m.summaryScrollOff = 0
804+
}
785805
case "s":
786806
if m.canExportScenarios() && m.detailJobID == "" {
787807
m.saving = true
@@ -810,7 +830,7 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
810830
case "pgdown":
811831
m.scrollActive(pageScroll, true)
812832
case "enter", "right":
813-
if m.detailJobID == "" {
833+
if m.detailJobID == "" && !m.summaryExpanded {
814834
jobs := m.filteredJobs()
815835
if m.cursor >= 0 && m.cursor < len(jobs) {
816836
m.detailJobID = jobs[m.cursor].job.Id
@@ -821,6 +841,9 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
821841
if m.detailJobID != "" {
822842
m.detailJobID = ""
823843
m.detailScrollOff = 0
844+
} else if m.summaryExpanded {
845+
m.summaryExpanded = false
846+
m.summaryScrollOff = 0
824847
} else if m.showDescription {
825848
m.showDescription = false
826849
m.descScrollOff = 0
@@ -830,6 +853,9 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
830853
case m.detailJobID != "":
831854
m.detailJobID = ""
832855
m.detailScrollOff = 0
856+
case m.summaryExpanded:
857+
m.summaryExpanded = false
858+
m.summaryScrollOff = 0
833859
case m.showDescription:
834860
m.showDescription = false
835861
m.descScrollOff = 0
@@ -1086,13 +1112,15 @@ func (m *simulateModel) viewRunning() string {
10861112
b.WriteString(m.scrolledDetail())
10871113
} else if m.matrix.active {
10881114
b.WriteString(m.matrix.render(m.buildMatrixRows()))
1115+
} else if m.summaryExpanded && m.run.Summary != nil {
1116+
b.WriteString(m.scrolledSummary())
10891117
} else {
10901118
b.WriteString(m.renderJobList())
10911119

10921120
if m.run.Status == livekit.SimulationRun_STATUS_SUMMARIZING {
10931121
fmt.Fprintf(&b, "\n %s %s %s\n", yellowStyle().Render("⏺"), yellowStyle().Render("Generating summary..."), m.spinner())
10941122
} else if m.run.Summary != nil {
1095-
b.WriteString(m.renderSummary())
1123+
b.WriteString(m.clampedSummary(strings.Count(b.String(), "\n")))
10961124
} else if isTerminalRunStatus(m.run.Status) {
10971125
msg := "The summary for this run is not available"
10981126
if m.run.Error != "" {
@@ -1583,6 +1611,75 @@ func (m *simulateModel) renderSummary() string {
15831611
return b.String()
15841612
}
15851613

1614+
// clampedSummary caps the summary block so the whole list view fits the
1615+
// terminal height; without this, a long summary pushes the header and the top
1616+
// of the job list off-screen on short terminals. usedLines is the number of
1617+
// lines already rendered above the summary.
1618+
func (m *simulateModel) clampedSummary(usedLines int) string {
1619+
content := m.renderSummary()
1620+
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
1621+
reserved := 3 // toast + hint + trailing blank line
1622+
if m.showLogs {
1623+
reserved += m.height/3 + 2
1624+
}
1625+
budget := m.height - usedLines - reserved
1626+
if budget < 3 {
1627+
budget = 3
1628+
}
1629+
m.summaryTruncated = len(lines) > budget
1630+
if !m.summaryTruncated {
1631+
return content
1632+
}
1633+
var b strings.Builder
1634+
for _, line := range lines[:budget-1] {
1635+
b.WriteString(line + "\n")
1636+
}
1637+
b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more summary lines · press S to expand", len(lines)-(budget-1))) + "\n")
1638+
return b.String()
1639+
}
1640+
1641+
// scrolledSummary renders the full summary in place of the job list, windowed
1642+
// by summaryScrollOff — the same pattern as scrolledDetail.
1643+
func (m *simulateModel) scrolledSummary() string {
1644+
content := m.renderSummary()
1645+
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
1646+
budget := m.height - 12
1647+
if budget < 5 {
1648+
budget = 5
1649+
}
1650+
if len(lines) <= budget {
1651+
m.summaryScrollOff = 0
1652+
return content
1653+
}
1654+
1655+
maxScroll := len(lines) - budget
1656+
if m.summaryScrollOff > maxScroll {
1657+
m.summaryScrollOff = maxScroll
1658+
}
1659+
if m.summaryScrollOff < 0 {
1660+
m.summaryScrollOff = 0
1661+
}
1662+
1663+
start := m.summaryScrollOff
1664+
end := start + budget
1665+
if end > len(lines) {
1666+
end = len(lines)
1667+
}
1668+
1669+
var b strings.Builder
1670+
if start > 0 {
1671+
b.WriteString(dimStyle.Render(fmt.Sprintf(" ↑ %d more lines above", start)))
1672+
b.WriteString("\n")
1673+
}
1674+
b.WriteString(strings.Join(lines[start:end], "\n"))
1675+
b.WriteString("\n")
1676+
if end < len(lines) {
1677+
b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more lines below", len(lines)-end)))
1678+
b.WriteString("\n")
1679+
}
1680+
return b.String()
1681+
}
1682+
15861683
func (m *simulateModel) renderChatTranscript(jobID string) string {
15871684
if m.run.Summary == nil || m.run.Summary.ChatHistory == nil {
15881685
return ""
@@ -1782,9 +1879,14 @@ func (m *simulateModel) renderHint() string {
17821879
}
17831880
case m.descriptionExpanded():
17841881
parts = append(parts, "↑↓ scroll · d collapse description")
1882+
case m.summaryExpanded:
1883+
parts = append(parts, "↑↓ scroll · S/ESC collapse summary")
17851884
default:
17861885
// the collapsed description block already carries "(press d to expand)"
17871886
nav := "↑↓ navigate · ENTER/→ detail"
1887+
if m.run != nil && m.run.Summary != nil && m.summaryTruncated {
1888+
nav += " · S summary"
1889+
}
17881890
if m.canExportScenarios() {
17891891
nav += " · s save scenarios"
17901892
}

cmd/lk/simulate_tui_layout_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
// summaryTestModel 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 summaryTestModel(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+
// The list view must fit the terminal height so the job list stays visible; a
65+
// long summary is clamped rather than pushing the top of the view off-screen.
66+
func TestSimulateViewFitsShortTerminal(t *testing.T) {
67+
m := summaryTestModel(156, 31)
68+
view := m.View()
69+
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
70+
require.LessOrEqual(t, len(lines), 31, "list view must fit a 31-row terminal")
71+
require.Contains(t, view, "Scenario 1", "first job must remain visible")
72+
require.Contains(t, view, "Scenario 10", "last job must remain visible")
73+
require.Contains(t, view, "more summary lines", "clamped summary must advertise expansion")
74+
}
75+
76+
// Expanding the summary shows it windowed and scrollable in place of the list.
77+
func TestSimulateSummaryExpandScroll(t *testing.T) {
78+
m := summaryTestModel(156, 31)
79+
m.summaryExpanded = true
80+
view := m.View()
81+
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
82+
require.LessOrEqual(t, len(lines), 31, "expanded summary view must fit the terminal")
83+
require.Contains(t, view, "more lines below")
84+
85+
m.summaryScrollOff = 1000 // clamped to max scroll on render
86+
view = m.View()
87+
require.Contains(t, view, "more lines above")
88+
require.NotContains(t, view, "Scenario 1", "job list is replaced while the summary is expanded")
89+
}
90+
91+
// A short summary renders in full, unclamped.
92+
func TestSimulateShortSummaryUnclamped(t *testing.T) {
93+
m := summaryTestModel(156, 40)
94+
m.run.Summary.GoingWell = "All good."
95+
m.run.Summary.ToImprove = ""
96+
m.run.Summary.Issues = nil
97+
view := m.View()
98+
require.NotContains(t, view, "more summary lines")
99+
require.Contains(t, view, "All good.")
100+
require.NotContains(t, view, "S summary", "hint must not advertise expansion when nothing is clamped")
101+
}
102+
103+
// The hint bar advertises S only when the summary was actually clamped.
104+
func TestSimulateSummaryHintOnlyWhenTruncated(t *testing.T) {
105+
m := summaryTestModel(156, 31)
106+
view := m.View()
107+
require.Contains(t, view, "S summary")
108+
}

0 commit comments

Comments
 (0)