Skip to content
Closed
Show file tree
Hide file tree
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
106 changes: 104 additions & 2 deletions cmd/lk/simulate_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -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"
}
Expand Down
108 changes: 108 additions & 0 deletions cmd/lk/simulate_tui_layout_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading