@@ -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+
15861683func (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 }
0 commit comments