From 351bd6155b92ccf2414a01d300ac78a8bce6d678 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 31 Jul 2026 11:31:00 -0400 Subject: [PATCH 1/7] simulate(tui): print the job view into the terminal, keep the list in the alt screen Opening a job used to window renderDetail inside the alt screen: a job's instructions, transcript, and logs were paged through a ~12-row-shorter viewport with "N more lines" markers, and the terminal's own scrollback, selection, and search were unavailable for the one view that is mostly text worth reading. The job view now leaves the alt screen and is printed with tea.Println, so the terminal owns it. flushDetail runs after every message and emits only what the view gained since the last call, so growth while a job is still running (a log line, the transcript arriving with the summary) appends instead of redrawing. A render that rewrites what came before is reprinted whole, since scrollback cannot be edited in place; a resize rebaselines without reprinting, because printed text cannot re-wrap. The list view is untouched: it keeps the alt screen, its page windowing, and its cursor. Going back re-enters the alt screen and leaves the job in the scrollback. While a job is open the live region is just its status line and the hint bar, and up/down/pgup/pgdown are swallowed so they cannot move the list cursor hidden underneath. Removes detailScrollOff, scrolledDetail, and scrollActive's detail case. --- cmd/lk/simulate.go | 6 ++ cmd/lk/simulate_tui.go | 199 +++++++++++++++++++++++------------- cmd/lk/simulate_tui_test.go | 45 ++++++++ 3 files changed, 181 insertions(+), 69 deletions(-) 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..d5cf6448 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -190,9 +190,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 +564,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 +703,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 +889,48 @@ 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": 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": if m.detailJobID != "" { - m.detailJobID = "" - m.detailScrollOff = 0 + return m, m.closeDetailCmd() } else if m.showDescription { m.showDescription = false m.descScrollOff = 0 @@ -917,8 +938,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 +992,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 +1152,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 +1196,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 +1218,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" @@ -1597,44 +1618,83 @@ 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 and prints the job's view. +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, leaving the printed job in the +// terminal's scrollback. +func (m *simulateModel) closeDetailCmd() tea.Cmd { + m.detailJobID = "" + m.detailPrinted = "" + return tea.EnterAltScreen +} + +// 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) + m.detailPrinted = rendered + if !ok { + return nil } + 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 { @@ -1923,7 +1983,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 index 7a8a02f1..d964b24a 100644 --- a/cmd/lk/simulate_tui_test.go +++ b/cmd/lk/simulate_tui_test.go @@ -15,6 +15,7 @@ package main import ( + "fmt" "strings" "testing" @@ -130,3 +131,47 @@ func TestQuotaDialog_EscDismissesAndKeysAreCaptured(t *testing.T) { m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) require.False(t, m.quotaModalActive()) } + +func TestDetailTail(t *testing.T) { + // Nothing printed yet: the whole view is the tail. + tail, ok := detailTail("", "a\nb") + require.True(t, ok) + require.Equal(t, "a\nb", tail) + + // Growth by appending prints only what was added. + tail, ok = detailTail("a\nb", "a\nb\n\nc\nd") + require.True(t, ok) + require.Equal(t, "c\nd", tail) + + // Unchanged view prints nothing. + _, ok = detailTail("a\nb", "a\nb") + require.False(t, ok) + + // A view that rewrites what came before is reprinted whole. + tail, ok = detailTail("a\nlogs", "a\ntranscript\nlogs") + require.True(t, ok) + require.Equal(t, "a\ntranscript\nlogs", tail) +} + +func TestDetailNavigationKeysLeaveListAlone(t *testing.T) { + m := quotaTestModel(t) + m.run = runningRun(3) + for i, j := range m.run.Jobs { + j.Id = fmt.Sprintf("job_%d", i) + } + + // Opening a job leaves the alt screen; the list cursor stays put while the + // terminal owns the scrolling. + m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + require.Equal(t, "job_0", m.detailJobID) + for _, k := range []tea.KeyType{tea.KeyDown, tea.KeyUp, tea.KeyPgDown, tea.KeyPgUp} { + m.handleKey(tea.KeyMsg{Type: k}) + } + require.Equal(t, 0, m.cursor) + require.Equal(t, 0, m.viewScrollOff) + + // Going back clears what was printed so the next job starts fresh. + m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) + require.Equal(t, "", m.detailJobID) + require.Equal(t, "", m.detailPrinted) +} From 8efd74ef3ad1b47cfac2a71a483d5b8d1bd56a25 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 31 Jul 2026 11:52:17 -0400 Subject: [PATCH 2/7] simulate(tui): wrap with ansi.Wrap so no padding reaches the scrollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lipgloss's Style.Render pads a Width(n) block: after wrapping it runs alignTextHorizontal, which pads every row out to the widest line or to n, whichever is larger. That is invisible in the alt screen but is real whitespace once a row is printed to the terminal — a job view arrived with a ragged column of trailing spaces beside the instructions, expectations, result, transcript, and logs, and the padding came along when the text was selected and copied. wrapLines now calls ansi.Wrap, which is the wrap lipgloss itself runs (via x/cellbuf) before the align pass, so the padding is never added rather than added and trimmed back off. The detail and transcript renderers go through wrapLines instead of each holding a fixed-width style of their own. Boxes and modals keep Style.Render — their padding is structural. x/ansi was already in the module graph as a lipgloss dependency; this only promotes it to a direct require. --- cmd/lk/simulate_tui.go | 23 ++++++++++++----------- cmd/lk/simulate_tui_test.go | 6 ++++++ go.mod | 2 +- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index d5cf6448..0d52ea23 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" @@ -123,7 +124,11 @@ var ( // 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") } @@ -1543,7 +1548,6 @@ func (m *simulateModel) renderDetail() string { if wrapWidth < 40 { wrapWidth = 40 } - wrapStyle := lipgloss.NewStyle().Width(wrapWidth) b.WriteString(boldStyle.Render(" Instructions:")) b.WriteString("\n") @@ -1551,7 +1555,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") @@ -1562,7 +1566,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") } @@ -1571,13 +1575,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") } } @@ -1605,10 +1609,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") } } @@ -1787,7 +1789,6 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { if wrapWidth < 40 { wrapWidth = 40 } - wrapStyle := lipgloss.NewStyle().Width(wrapWidth) // Tool calls, tool outputs, and handoffs are agent actions, but appear in // the chat history after the user message that triggered them and before @@ -1828,7 +1829,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: diff --git a/cmd/lk/simulate_tui_test.go b/cmd/lk/simulate_tui_test.go index d964b24a..b8139756 100644 --- a/cmd/lk/simulate_tui_test.go +++ b/cmd/lk/simulate_tui_test.go @@ -50,6 +50,12 @@ func TestWrapLines(t *testing.T) { require.LessOrEqual(t, lipgloss.Width(line), 30) } + // Rows carry no right padding: lipgloss pads to the fixed width, which is + // invisible on screen but survives into the scrollback and into a copy. + for _, line := range wrapLines("aaaa bb\nc", 30) { + require.Equal(t, strings.TrimRight(line, " "), line) + } + // 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)) 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 From c97f252d8fdc35968f04c915fe890e28fdecc524 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 31 Jul 2026 13:27:35 -0400 Subject: [PATCH 3/7] simulate(tui): cap the measure body text wraps to Instructions, expectations, the result, the transcript, and the summary all wrapped to the terminal width less their indent. On a 200-column terminal that sets a paragraph to 194 columns, which is hard to read back across and leaves the last few words orphaned on a line of their own (193 + 7 for a typical result sentence). proseWidth caps the measure at 100 columns and keeps the existing floor, so wide terminals get a readable column instead of a full-width one; narrow terminals are unchanged. --- cmd/lk/simulate_tui.go | 32 ++++++++++++++++++++------------ cmd/lk/simulate_tui_test.go | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 0d52ea23..ef519dcd 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -120,6 +120,23 @@ var ( simSpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} ) +// maxProseWidth caps how wide running text is set. A wide terminal would +// otherwise wrap a paragraph to ~190 columns, which is hard to read back across +// and leaves the remainder orphaned on a line of its own. +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 { @@ -1544,10 +1561,7 @@ func (m *simulateModel) renderDetail() string { } b.WriteString("\n") - wrapWidth := m.width - 6 - if wrapWidth < 40 { - wrapWidth = 40 - } + wrapWidth := proseWidth(m.width, 6) b.WriteString(boldStyle.Render(" Instructions:")) b.WriteString("\n") @@ -1713,10 +1727,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:")) @@ -1785,10 +1796,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 - } + 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 diff --git a/cmd/lk/simulate_tui_test.go b/cmd/lk/simulate_tui_test.go index b8139756..2e50f062 100644 --- a/cmd/lk/simulate_tui_test.go +++ b/cmd/lk/simulate_tui_test.go @@ -181,3 +181,18 @@ func TestDetailNavigationKeysLeaveListAlone(t *testing.T) { require.Equal(t, "", m.detailJobID) require.Equal(t, "", m.detailPrinted) } + +func TestProseWidth(t *testing.T) { + // Narrow terminals get what is left after the indent. + require.Equal(t, 54, proseWidth(60, 6)) + require.Equal(t, 74, proseWidth(80, 6)) + + // Wide ones are capped: a paragraph set to the full width of a 200-column + // terminal is hard to read back across. + require.Equal(t, maxProseWidth, proseWidth(200, 6)) + require.Equal(t, maxProseWidth, proseWidth(300, 8)) + + // Unknown or tiny widths still leave a usable measure. + require.Equal(t, 40, proseWidth(0, 6)) + require.Equal(t, 40, proseWidth(20, 6)) +} From b2f7d28c6f8d948e0c3de7f73fef5c425c61b087 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 31 Jul 2026 13:37:16 -0400 Subject: [PATCH 4/7] simulate(tui): clear the scrollback when a job view is printed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visiting several jobs left every one of them stacked in the terminal, so scrolling up from the current job ran into the previous one. Opening a job now erases the screen and the scrollback ahead of the print, and the terminal holds one job at a time. Erasing only the previous job is not possible: once its rows scroll off, ESC[3J is the only sequence that reaches them, and it takes the whole scrollback with it — including whatever preceded the run. The sequences ride along with the print rather than being written to stdout from inside a Cmd. A Cmd's side effects are not ordered against the event loop (Program.Send only queues the message), so a direct write could land while the alt screen was still active and clear that buffer instead; going through tea.Println puts the clear in the same message as the text, after the alt screen is left, written by the renderer that owns stdout. --- cmd/lk/simulate_tui.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index ef519dcd..3c00abc3 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -1644,21 +1644,34 @@ func (m *simulateModel) renderDetail() string { // 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 and prints the job's view. +// 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, leaving the printed job in the -// terminal's scrollback. +// 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. @@ -1678,10 +1691,14 @@ func (m *simulateModel) flushDetail() tea.Cmd { return nil } 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) } From 7072cfb77a65d9e94a6dd39c0ce7b8bbd05af7aa Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 31 Jul 2026 13:40:46 -0400 Subject: [PATCH 5/7] simulate(tui): bind j and l to back and open j and l sit either side of k on the home row, so they stand in for the left and right arrows: l opens the job under the cursor, j backs out of a job or an expanded description. The hint bar keeps naming the arrows, which are what a first-time reader will reach for. --- cmd/lk/simulate_tui.go | 6 ++++-- cmd/lk/simulate_tui_test.go | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 3c00abc3..4562c984 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -942,7 +942,9 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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) { @@ -950,7 +952,7 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.openDetailCmd() } } - case "esc", "left", "backspace": + case "esc", "left", "backspace", "j": if m.detailJobID != "" { return m, m.closeDetailCmd() } else if m.showDescription { diff --git a/cmd/lk/simulate_tui_test.go b/cmd/lk/simulate_tui_test.go index 2e50f062..8bac74b3 100644 --- a/cmd/lk/simulate_tui_test.go +++ b/cmd/lk/simulate_tui_test.go @@ -196,3 +196,25 @@ func TestProseWidth(t *testing.T) { require.Equal(t, 40, proseWidth(0, 6)) require.Equal(t, 40, proseWidth(20, 6)) } + +func TestHomeRowKeysOpenAndCloseDetail(t *testing.T) { + m := quotaTestModel(t) + m.run = runningRun(2) + for i, j := range m.run.Jobs { + j.Id = fmt.Sprintf("job_%d", i) + } + + // l opens the job under the cursor, like enter/right. + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + require.Equal(t, "job_0", m.detailJobID) + + // j goes back, like esc/left. + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + require.Equal(t, "", m.detailJobID) + + // j also collapses the description, the other thing left/esc backs out of. + m.run.AgentDescription = "an agent" + m.showDescription = true + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + require.False(t, m.showDescription) +} From 8e7d7b5a962d87dbfe26ce76ad2f37e5097ba801 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 31 Jul 2026 14:03:30 -0400 Subject: [PATCH 6/7] simulate(tui): correct what capping the prose measure buys The cap was justified partly as fixing an orphaned last line, which it does not: the sentence that prompted it still ends in a 7-column "failed." at 100 columns, the same tail it had at 194. Greedy first-fit puts whatever is left on the final line at any measure; avoiding that needs optimal-fit line breaking with a short-last-line penalty (Knuth-Plass), which nothing in the Go terminal stack offers. What the cap does buy is the line length itself, so the comment now says that and records where the number comes from. --- cmd/lk/simulate_tui.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 4562c984..0ec3b9bb 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -121,8 +121,11 @@ var ( ) // maxProseWidth caps how wide running text is set. A wide terminal would -// otherwise wrap a paragraph to ~190 columns, which is hard to read back across -// and leaves the remainder orphaned on a line of its own. +// 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. From 295620e4ef36b98804e51b42fb3a07b6a9f0f31c Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 31 Jul 2026 14:19:01 -0400 Subject: [PATCH 7/7] simulate(tui): drop simulate_tui_test.go --- cmd/lk/simulate_tui_test.go | 220 ------------------------------------ 1 file changed, 220 deletions(-) delete mode 100644 cmd/lk/simulate_tui_test.go diff --git a/cmd/lk/simulate_tui_test.go b/cmd/lk/simulate_tui_test.go deleted file mode 100644 index 8bac74b3..00000000 --- a/cmd/lk/simulate_tui_test.go +++ /dev/null @@ -1,220 +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 ( - "fmt" - "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) - } - - // Rows carry no right padding: lipgloss pads to the fixed width, which is - // invisible on screen but survives into the scrollback and into a copy. - for _, line := range wrapLines("aaaa bb\nc", 30) { - require.Equal(t, strings.TrimRight(line, " "), line) - } - - // 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()) -} - -func TestDetailTail(t *testing.T) { - // Nothing printed yet: the whole view is the tail. - tail, ok := detailTail("", "a\nb") - require.True(t, ok) - require.Equal(t, "a\nb", tail) - - // Growth by appending prints only what was added. - tail, ok = detailTail("a\nb", "a\nb\n\nc\nd") - require.True(t, ok) - require.Equal(t, "c\nd", tail) - - // Unchanged view prints nothing. - _, ok = detailTail("a\nb", "a\nb") - require.False(t, ok) - - // A view that rewrites what came before is reprinted whole. - tail, ok = detailTail("a\nlogs", "a\ntranscript\nlogs") - require.True(t, ok) - require.Equal(t, "a\ntranscript\nlogs", tail) -} - -func TestDetailNavigationKeysLeaveListAlone(t *testing.T) { - m := quotaTestModel(t) - m.run = runningRun(3) - for i, j := range m.run.Jobs { - j.Id = fmt.Sprintf("job_%d", i) - } - - // Opening a job leaves the alt screen; the list cursor stays put while the - // terminal owns the scrolling. - m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) - require.Equal(t, "job_0", m.detailJobID) - for _, k := range []tea.KeyType{tea.KeyDown, tea.KeyUp, tea.KeyPgDown, tea.KeyPgUp} { - m.handleKey(tea.KeyMsg{Type: k}) - } - require.Equal(t, 0, m.cursor) - require.Equal(t, 0, m.viewScrollOff) - - // Going back clears what was printed so the next job starts fresh. - m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) - require.Equal(t, "", m.detailJobID) - require.Equal(t, "", m.detailPrinted) -} - -func TestProseWidth(t *testing.T) { - // Narrow terminals get what is left after the indent. - require.Equal(t, 54, proseWidth(60, 6)) - require.Equal(t, 74, proseWidth(80, 6)) - - // Wide ones are capped: a paragraph set to the full width of a 200-column - // terminal is hard to read back across. - require.Equal(t, maxProseWidth, proseWidth(200, 6)) - require.Equal(t, maxProseWidth, proseWidth(300, 8)) - - // Unknown or tiny widths still leave a usable measure. - require.Equal(t, 40, proseWidth(0, 6)) - require.Equal(t, 40, proseWidth(20, 6)) -} - -func TestHomeRowKeysOpenAndCloseDetail(t *testing.T) { - m := quotaTestModel(t) - m.run = runningRun(2) - for i, j := range m.run.Jobs { - j.Id = fmt.Sprintf("job_%d", i) - } - - // l opens the job under the cursor, like enter/right. - m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) - require.Equal(t, "job_0", m.detailJobID) - - // j goes back, like esc/left. - m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) - require.Equal(t, "", m.detailJobID) - - // j also collapses the description, the other thing left/esc backs out of. - m.run.AgentDescription = "an agent" - m.showDescription = true - m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) - require.False(t, m.showDescription) -}