diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 5da0a61b..51fae425 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -562,6 +562,12 @@ func isTerminalRunStatus(status livekit.SimulationRun_Status) bool { status == livekit.SimulationRun_STATUS_CANCELLED } +// isTerminalJobStatus reports whether a job will never change again. +func isTerminalJobStatus(status livekit.SimulationRun_Job_Status) bool { + return status == livekit.SimulationRun_Job_STATUS_COMPLETED || + status == livekit.SimulationRun_Job_STATUS_FAILED +} + // dashboardBaseURL returns the cloud dashboard URL, derived from the API // server URL so that --server-url (e.g. staging) is respected without a // separate flag. The cloud API and dashboard hosts differ only by "-api": diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index ad4a2f61..0ec3b9bb 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -28,6 +28,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/livekit/livekit-cli/v2/pkg/util" "github.com/livekit/protocol/livekit" @@ -119,11 +120,35 @@ var ( simSpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} ) +// maxProseWidth caps how wide running text is set. A wide terminal would +// otherwise wrap a paragraph to ~190 columns, well past the 45-75 the eye +// tracks comfortably between saccades and the 80 WCAG 1.4.8 asks for. The value +// is clap's default cap; the prose-only renderers sit lower (glamour and go/doc +// at 80, mandoc at 78, git shortlog at 76) and the ones carrying code or tables +// sit higher (gh and glow at 120). +const maxProseWidth = 100 + +// proseWidth is the measure body text wraps to, given the indent it sits at. +func proseWidth(termWidth, indent int) int { + width := termWidth - indent + if width > maxProseWidth { + width = maxProseWidth + } + if width < 40 { + width = 40 + } + return width +} + // wrapLines splits text into rows no wider than width; unknown or tiny // widths leave the lines unwrapped. func wrapLines(text string, width int) []string { if width >= 20 { - text = lipgloss.NewStyle().Width(width).Render(text) + // ansi.Wrap is the wrap lipgloss itself runs before it aligns. Going + // straight to it skips the align pass, which pads every row out to width: + // invisible on screen, but a row printed into the scrollback keeps the + // padding, and it comes along when the text is selected and copied. + text = ansi.Wrap(text, width, "") } return strings.Split(text, "\n") } @@ -190,9 +215,15 @@ type simulateModel struct { spinnerIdx int - cursor int - detailJobID string - detailScrollOff int + cursor int + detailJobID string + // The open job's view is printed into the terminal's own scrollback instead + // of being windowed in the live region; detailPrinted is what has already + // been emitted for it, so a re-render only ever appends its new tail. + // detailWidth is the width that text was wrapped at: scrollback cannot be + // re-wrapped, so a resize rebaselines instead of reprinting. + detailPrinted string + detailWidth int showLogs bool logScrollOff int logPinned bool @@ -558,6 +589,17 @@ func (m *simulateModel) waitSubprocess() tea.Cmd { } func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + model, cmd := m.update(msg) + // Every state change can extend the open job's view (a log line, the + // transcript arriving with the summary), and the extension belongs in the + // scrollback right after what is already there. + if flush := m.flushDetail(); flush != nil { + cmd = tea.Batch(cmd, flush) + } + return model, cmd +} + +func (m *simulateModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width @@ -686,13 +728,10 @@ const pageScroll = 20 // scrollActive scrolls the focused pane by delta lines (positive toward the // bottom); false if nothing is focused so the caller falls back to the list. +// The detail view is absent here: it lives in the terminal's scrollback, which +// the terminal itself scrolls. func (m *simulateModel) scrollActive(delta int, includeLogs bool) bool { switch { - case m.detailJobID != "": - m.detailScrollOff += delta - if m.detailScrollOff < 0 { - m.detailScrollOff = 0 - } case m.descriptionExpanded(): m.descScrollOff += delta if m.descScrollOff < 0 { @@ -875,41 +914,50 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { text, ok := m.copyScenario(m.detailJobID) return m, m.showToast(text, ok) } - case "up", "down": - // One unified motion (the mouse wheel maps here in alt-screen): a - // detail/description pane scrolls its own text; otherwise the arrows - // move the cursor through the job list and spill into page scrolling at - // each end so the summary/logs and header come into view. - delta := -1 - if key == "down" { - delta = 1 - } - if !m.scrollActive(delta, false) { - m.navVertical(delta) - } - case "pgup": - if !m.scrollActive(-pageScroll, true) { - m.viewScrollOff -= pageScroll - if m.viewScrollOff < 0 { - m.viewScrollOff = 0 - } + case "up", "down", "pgup", "pgdown": + // The open job's view is scrolled by the terminal, so these keys must + // not disturb the list underneath it. + if m.detailJobID != "" { + return m, nil } - case "pgdown": - if !m.scrollActive(pageScroll, true) { - m.viewScrollOff += pageScroll // clamped on render + switch key { + case "up", "down": + // One unified motion (the mouse wheel maps here in alt-screen): an + // expanded description scrolls its own text; otherwise the arrows move + // the cursor through the job list and spill into page scrolling at each + // end so the summary/logs and header come into view. + delta := -1 + if key == "down" { + delta = 1 + } + if !m.scrollActive(delta, false) { + m.navVertical(delta) + } + case "pgup": + if !m.scrollActive(-pageScroll, true) { + m.viewScrollOff -= pageScroll + if m.viewScrollOff < 0 { + m.viewScrollOff = 0 + } + } + case "pgdown": + if !m.scrollActive(pageScroll, true) { + m.viewScrollOff += pageScroll // clamped on render + } } - case "enter", "right": + // j and l sit either side of k on the home row, so they double for the + // left/right arrows without reaching for them. + case "enter", "right", "l": if m.detailJobID == "" { jobs := m.filteredJobs() if m.cursor >= 0 && m.cursor < len(jobs) { m.detailJobID = jobs[m.cursor].job.Id - m.detailScrollOff = 0 + return m, m.openDetailCmd() } } - case "esc", "left", "backspace": + case "esc", "left", "backspace", "j": if m.detailJobID != "" { - m.detailJobID = "" - m.detailScrollOff = 0 + return m, m.closeDetailCmd() } else if m.showDescription { m.showDescription = false m.descScrollOff = 0 @@ -917,8 +965,7 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q": switch { case m.detailJobID != "": - m.detailJobID = "" - m.detailScrollOff = 0 + return m, m.closeDetailCmd() case m.showDescription: m.showDescription = false m.descScrollOff = 0 @@ -972,6 +1019,9 @@ func (m *simulateModel) View() string { if !m.setupDone || m.run == nil || m.run.Status == livekit.SimulationRun_STATUS_GENERATING { return m.viewSetup() } + if m.detailJobID != "" { + return m.viewDetailLive() + } switch m.run.Status { case livekit.SimulationRun_STATUS_FAILED: if len(m.run.Jobs) == 0 { @@ -1129,7 +1179,7 @@ func (m *simulateModel) viewRunning() string { } b.WriteString("\n\n") - if m.detailJobID == "" && m.hasDescription() { + if m.hasDescription() { b.WriteString(boldStyle.Render(" Agent Description") + "\n") if m.showDescription { // bounded window so expanding never pushes the list off-screen @@ -1173,9 +1223,7 @@ func (m *simulateModel) viewRunning() string { b.WriteString("\n") - if m.detailJobID != "" { - b.WriteString(m.scrolledDetail()) - } else if m.matrix.active { + if m.matrix.active { b.WriteString(m.matrix.render(m.buildMatrixRows())) } else { // the job list renders in full; pageWindow scrolls the whole view. @@ -1197,11 +1245,11 @@ func (m *simulateModel) viewRunning() string { } b.WriteString("\n") - if m.showLogs && m.detailJobID == "" { + if m.showLogs { b.WriteString(m.renderLogs("")) } content := b.String() - if m.detailJobID == "" && !m.matrix.active { + if !m.matrix.active { content = m.pageWindow(content) } return content + m.renderToast() + m.renderHint() + "\n" @@ -1518,11 +1566,7 @@ func (m *simulateModel) renderDetail() string { } b.WriteString("\n") - wrapWidth := m.width - 6 - if wrapWidth < 40 { - wrapWidth = 40 - } - wrapStyle := lipgloss.NewStyle().Width(wrapWidth) + wrapWidth := proseWidth(m.width, 6) b.WriteString(boldStyle.Render(" Instructions:")) b.WriteString("\n") @@ -1530,7 +1574,7 @@ func (m *simulateModel) renderDetail() string { if instr == "" { instr = "—" } - for line := range strings.SplitSeq(wrapStyle.Render(instr), "\n") { + for _, line := range wrapLines(instr, wrapWidth) { b.WriteString(" " + line + "\n") } b.WriteString("\n") @@ -1541,7 +1585,7 @@ func (m *simulateModel) renderDetail() string { if expect == "" { expect = "—" } - for line := range strings.SplitSeq(wrapStyle.Render(expect), "\n") { + for _, line := range wrapLines(expect, wrapWidth) { b.WriteString(dimStyle.Render(" "+line) + "\n") } @@ -1550,13 +1594,13 @@ func (m *simulateModel) renderDetail() string { if job.Status == livekit.SimulationRun_Job_STATUS_COMPLETED { b.WriteString(greenStyle().Bold(true).Render(" Result:")) b.WriteString("\n") - for line := range strings.SplitSeq(wrapStyle.Render(job.Error), "\n") { + for _, line := range wrapLines(job.Error, wrapWidth) { b.WriteString(greenStyle().Render(" "+line) + "\n") } } else { b.WriteString(redStyle().Bold(true).Render(" Error:")) b.WriteString("\n") - for line := range strings.SplitSeq(wrapStyle.Render(job.Error), "\n") { + for _, line := range wrapLines(job.Error, wrapWidth) { b.WriteString(redStyle().Render(" "+line) + "\n") } } @@ -1584,10 +1628,8 @@ func (m *simulateModel) renderDetail() string { if maxWidth < 20 { maxWidth = 20 } - wrapLogStyle := lipgloss.NewStyle().Width(maxWidth) for _, line := range rawLines { - wrapped := wrapLogStyle.Render(line) - for wl := range strings.SplitSeq(wrapped, "\n") { + for _, wl := range wrapLines(line, maxWidth) { b.WriteString(" " + wl + "\n") } } @@ -1597,44 +1639,100 @@ func (m *simulateModel) renderDetail() string { return b.String() } -func (m *simulateModel) scrolledDetail() string { - content := m.renderDetail() - lines := strings.Split(content, "\n") - budget := m.height - 12 - if budget < 5 { - budget = 5 +// --- Job detail (native scrollback) --- +// +// The detail view is printed into the terminal below the alt screen rather than +// windowed inside it, so the whole job — instructions, transcript, logs — is +// there at once and the terminal scrolls, selects, and searches it. The list +// view is untouched: it keeps the alt screen and its own windowing. +// +// tea.Println is silently dropped while the alt screen is active (bubbletea +// standard_renderer.go), so leaving it must be sequenced before any print. + +// openDetailCmd leaves the alt screen, clears the scrollback, and prints the +// job's view, so the terminal holds one job at a time instead of every job +// visited this run. Erasing only the previous job is not possible: once its +// rows have scrolled off, ESC[3J is the only way to reach them, and it takes +// the whole scrollback with it. +func (m *simulateModel) openDetailCmd() tea.Cmd { + m.detailPrinted = "" + m.detailWidth = m.width + return tea.Sequence(tea.ExitAltScreen, m.flushDetail()) +} + +// closeDetailCmd returns to the list view. The printed job stays in the +// scrollback until the next one replaces it. +func (m *simulateModel) closeDetailCmd() tea.Cmd { + m.detailJobID = "" + m.detailPrinted = "" + return tea.EnterAltScreen +} + +// clearScrollback empties the screen and the scrollback behind it. It rides +// along with the first print of a job rather than being written to stdout +// directly: a write inside a Cmd is not ordered against the event loop, so it +// could land while the alt screen is still active and clear that instead, and +// the renderer owns stdout while the program is running. bubbletea's +// ClearScreen is no use here — it leaves the scrollback, which is the part that +// has to go. +const clearScrollback = ansi.CursorHomePosition + ansi.EraseEntireScreen + ansi.EraseEntireDisplay + +// flushDetail returns a command printing whatever the open job's view has +// gained since the last call, or nil when it has gained nothing. Callers do not +// need to know whether anything changed. +func (m *simulateModel) flushDetail() tea.Cmd { + if m.detailJobID == "" { + return nil } - if len(lines) <= budget { - m.detailScrollOff = 0 - return content + rendered := strings.TrimRight(m.renderDetail(), "\n") + // renderDetail clears detailJobID when the job is gone from the run + if m.detailJobID == "" || rendered == "" { + return nil } - - maxScroll := len(lines) - budget - if m.detailScrollOff > maxScroll { - m.detailScrollOff = maxScroll + if m.width != m.detailWidth { + // every line re-wraps, so nothing lines up with what was printed + m.detailPrinted = rendered + m.detailWidth = m.width + return nil } - if m.detailScrollOff < 0 { - m.detailScrollOff = 0 + tail, ok := detailTail(m.detailPrinted, rendered) + first := m.detailPrinted == "" + m.detailPrinted = rendered + if !ok { + return nil + } + if first { + tail = clearScrollback + tail } + return tea.Println(tail) +} - start := m.detailScrollOff - end := start + budget - if end > len(lines) { - end = len(lines) +// detailTail is what remains of rendered once printed has been accounted for, +// and whether anything remains at all. Growth is normally an append (a log +// line, the transcript arriving with the summary), which prints as the new tail +// alone; a render that instead rewrites what came before is reprinted whole, +// since scrollback cannot be edited in place. +func detailTail(printed, rendered string) (string, bool) { + if printed == "" { + return rendered, true } + if !strings.HasPrefix(rendered, printed) { + return rendered, true + } + tail := strings.Trim(rendered[len(printed):], "\n") + return tail, tail != "" +} +// viewDetailLive is the live region under the printed job view: the status of +// the job, which is the only part of it that is still moving. +func (m *simulateModel) viewDetailLive() string { 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))) + if job := m.findJob(m.detailJobID); job != nil && !isTerminalJobStatus(job.Status) { + fmt.Fprintf(&b, "\n %s %s %s\n", jobIcon(job), dimStyle.Render(jobLabel(job)), m.spinner()) + } else { b.WriteString("\n") } - return b.String() + return b.String() + m.renderToast() + m.renderHint() + "\n" } func (m *simulateModel) renderSummary() string { @@ -1651,10 +1749,7 @@ func (m *simulateModel) renderSummary() string { redStyle().Render(fmt.Sprintf("%d failed", summary.Failed)), ) - wrapWidth := m.width - 6 - if wrapWidth < 40 { - wrapWidth = 40 - } + wrapWidth := proseWidth(m.width, 6) if summary.GoingWell != "" { b.WriteString(greenStyle().Bold(true).Render(" Going well:")) @@ -1723,11 +1818,7 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { b.WriteString(boldStyle.Render(" Transcript:")) b.WriteString("\n") - wrapWidth := m.width - 8 - if wrapWidth < 40 { - wrapWidth = 40 - } - wrapStyle := lipgloss.NewStyle().Width(wrapWidth) + wrapWidth := proseWidth(m.width, 8) // Tool calls, tool outputs, and handoffs are agent actions, but appear in // the chat history after the user message that triggered them and before @@ -1768,7 +1859,7 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { } } toolOpenedAgentBlock = false - for line := range strings.SplitSeq(wrapStyle.Render(text), "\n") { + for _, line := range wrapLines(text, wrapWidth) { b.WriteString(" " + line + "\n") } case *agent.ChatContext_ChatItem_FunctionCall: @@ -1923,7 +2014,8 @@ func (m *simulateModel) renderHint() string { var parts []string switch { case m.detailJobID != "": - parts = append(parts, "↑↓ scroll · c copy scenario · ←/ESC back") + // the job view is in the terminal's scrollback, which scrolls itself + parts = append(parts, "c copy scenario · ←/ESC back to list") if m.hasLogs() { if m.showLogs { parts = append(parts, "Ctrl+L hide logs") diff --git a/cmd/lk/simulate_tui_test.go b/cmd/lk/simulate_tui_test.go deleted file mode 100644 index 7a8a02f1..00000000 --- a/cmd/lk/simulate_tui_test.go +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2026 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 ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/livekit/protocol/livekit" - "github.com/stretchr/testify/require" -) - -func TestWriteWrappedLines(t *testing.T) { - style := lipgloss.NewStyle() - - // With a known width, every rendered row fits the terminal. - var b strings.Builder - writeWrappedLines(&b, style, " ", strings.Repeat("x", 100)+"\nshort", 40) - lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n") - require.Greater(t, len(lines), 2, "long line should wrap into multiple rows") - for _, line := range lines { - require.LessOrEqual(t, lipgloss.Width(line), 40) - } - - // Width unknown: lines pass through unwrapped, and short lines are not - // padded out to the widest line. - b.Reset() - writeWrappedLines(&b, style, " ", "aaaa\nbb", 0) - require.Equal(t, " aaaa\n bb\n", b.String()) -} - -func TestWrapLines(t *testing.T) { - // Known width: every row fits. - for _, line := range wrapLines(strings.Repeat("x", 90), 30) { - require.LessOrEqual(t, lipgloss.Width(line), 30) - } - - // Unknown/tiny width: lines pass through untouched. - require.Equal(t, []string{"aaaa", "bb"}, wrapLines("aaaa\nbb", 0)) - require.Equal(t, []string{strings.Repeat("x", 90)}, wrapLines(strings.Repeat("x", 90), 10)) -} - -func quotaTestModel(t *testing.T) *simulateModel { - t.Helper() - m := newSimulateModel(&simulateConfig{concurrency: 0}) - m.setupDone = true - m.agent = &AgentProcess{ - roomLogs: map[string][]string{}, - latestRoomByPx: map[string]string{}, - } - return m -} - -func runningRun(nRunning int) *livekit.SimulationRun { - run := &livekit.SimulationRun{Status: livekit.SimulationRun_STATUS_RUNNING} - for i := 0; i < nRunning; i++ { - run.Jobs = append(run.Jobs, &livekit.SimulationRun_Job{ - Status: livekit.SimulationRun_Job_STATUS_RUNNING, - }) - } - return run -} - -func TestQuotaDialog_ShowsOnceAndDismisses(t *testing.T) { - m := quotaTestModel(t) - - // Poll 1: 10 jobs running, no quota errors yet — no dialog. - m.Update(simulationRunMsg{run: runningRun(10)}) - require.Nil(t, m.quotaWarning) - require.Equal(t, 10, m.peakRunning) - - // The agent starts logging 429s; the next poll raises the dialog. - m.agent.appendLog(quotaLineTpm) - m.Update(simulationRunMsg{run: runningRun(10)}) - require.NotNil(t, m.quotaWarning) - require.True(t, m.quotaModalActive()) - - // The dialog replaces the hint bar, names the quota, suggests half the - // observed peak (10/2=5), and carries the dismiss button. - hint := m.renderHint() - require.Contains(t, hint, "Inference quota exceeded") - require.Contains(t, hint, "tokens-per-minute") - require.Contains(t, hint, "--concurrency 5") - require.Contains(t, hint, "Dismiss") - - // Enter dismisses; the dialog never comes back this run. - m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) - require.False(t, m.quotaModalActive()) - require.True(t, m.quotaDismissed) - m.Update(simulationRunMsg{run: runningRun(10)}) - require.False(t, m.quotaModalActive()) - require.NotContains(t, m.renderHint(), "Inference quota exceeded") -} - -func TestQuotaDialog_ExplicitConcurrencyHalved(t *testing.T) { - m := quotaTestModel(t) - m.config.concurrency = 7 - m.agent.appendLog(quotaLineRpm) - m.Update(simulationRunMsg{run: runningRun(7)}) - require.True(t, m.quotaModalActive()) - require.Contains(t, m.renderHint(), "--concurrency 3") // 7/2 floors to 3 - require.Contains(t, m.renderHint(), "requests-per-minute") -} - -func TestQuotaDialog_EscDismissesAndKeysAreCaptured(t *testing.T) { - m := quotaTestModel(t) - m.agent.appendLog(quotaLineTpm) - m.Update(simulationRunMsg{run: runningRun(2)}) - require.True(t, m.quotaModalActive()) - - // Keys other than dismiss/quit are swallowed while the dialog is up. - m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) - require.Equal(t, 0, m.cursor) - require.True(t, m.quotaModalActive()) - - m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) - require.False(t, m.quotaModalActive()) -} diff --git a/go.mod b/go.mod index c9f88f8f..3d5e11da 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.7 github.com/frostbyte73/core v0.1.1 github.com/fsnotify/fsnotify v1.10.1 github.com/go-logr/logr v1.4.3 @@ -91,7 +92,6 @@ require ( github.com/chainguard-dev/git-urls v1.0.2 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect