From 1a9191d904e4f841d91408e8b0beb1ff04399468 Mon Sep 17 00:00:00 2001 From: Gurney Buchanan Date: Tue, 20 Oct 2026 15:25:52 -0400 Subject: [PATCH] Make line wrapping adapt to the terminal width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Width was handled in three broken ways: monitor transcript lines were truncated to whatever the width was when they arrived (and never re-flowed on resize), the report browser computed its table columns and markdown word wrap once from a hardcoded 100-column startup fallback, and plain output clipped at a fixed 200 characters regardless of the terminal. Transcript lines now wrap ANSI-aware instead of truncating, and every retained line re-wraps when the terminal resizes, so the transcript stays complete and readable at any width. The monitor header and status bar clip to one line at the current width (the budget bar yields cells first), the report browser rebuilds its columns and re-renders its markdown on resize while preserving the cursor, and plain output plus the run summary wrap to the measured terminal width. Regression tests pin the behaviors: re-wrap on resize, one-line header invariant from 40 to 200 columns, adaptive report columns, ANSI-preserving wrap/truncate. Verified live in a 50-column PTY: transcript, status bar and summary all fit. 💘 Generated with Crush Assisted-by: Crush:rits/zai-org/glm-5-3 --- CHANGELOG.md | 6 ++ cmd/root.go | 10 ++- cmd/run.go | 18 ++--- go.mod | 4 +- internal/tui/monitor.go | 81 ++++++++++++++++------ internal/tui/report.go | 137 +++++++++++++++++++++++++++---------- internal/tui/width.go | 38 ++++++++++ internal/tui/width_test.go | 130 +++++++++++++++++++++++++++++++++++ 8 files changed, 348 insertions(+), 76 deletions(-) create mode 100644 internal/tui/width.go create mode 100644 internal/tui/width_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 364f89d..51d3f67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,3 +37,9 @@ Initial implementation. run dies mid-flight. - The run summary (artifacts, journal path, harness error, final message) prints after the interactive monitor exits, not only in plain mode. +- Line wrapping adapts to the terminal width everywhere. Monitor transcript + lines wrap (ANSI-aware) instead of truncating, and re-flow on resize; + the header and status bar clip to fit one line at any width; the report + browser rebuilds its table columns and re-renders its markdown at the + current width; plain (non-TTY) output and summary lines wrap to the + terminal width instead of a fixed 200 characters. diff --git a/cmd/root.go b/cmd/root.go index d969058..b43a513 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/traust-security/traust-cli/internal/theme" + "github.com/traust-security/traust-cli/internal/tui" ) // version is set at build time via -ldflags. @@ -59,10 +60,15 @@ func section(title string) { fmt.Println(theme.Default().Header.Render(title)) } -// kv prints a label/value line. +// kv prints a label/value line, wrapping the value to the terminal width. func kv(label, value string) { styles := theme.Default() - fmt.Printf(" %s %s\n", styles.Label.Render(pad(label)), styles.Value.Render(value)) + const lead = 18 // " " + 14-wide label + " " + width := tui.TerminalWidth() - lead + if width < 20 { + width = 20 + } + fmt.Printf(" %s %s\n", styles.Label.Render(pad(label)), tui.Wrap(value, width)) } func pad(s string) string { diff --git a/cmd/run.go b/cmd/run.go index faaeef4..769bdf4 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -216,7 +216,7 @@ func printRunSummary(res *runner.RunResult) { if res.ResultText != "" { fmt.Println() fmt.Println(styles.Muted.Render("final message:")) - fmt.Println(indent(res.ResultText, " ")) + fmt.Println(indent(tui.Wrap(res.ResultText, tui.TerminalWidth()-4), " ")) } } @@ -240,35 +240,27 @@ func (plainSink) Progress(msg string) { if strings.Contains(msg, "failed") || strings.Contains(msg, "refused") { style = styles.Err } - fmt.Println(style.Render("› " + msg)) + fmt.Println(tui.Wrap(style.Render("› "+msg), tui.TerminalWidth())) } func (plainSink) Event(ev harness.Event) { styles := theme.Default() switch ev.Kind { case harness.EventText: - fmt.Println(truncatePlain(ev.Text)) + fmt.Println(tui.Wrap(ev.Text, tui.TerminalWidth())) case harness.EventToolCall: fmt.Println(styles.Accent.Render("→ " + ev.Tool)) case harness.EventResult: // Result text is printed in the summary. case harness.EventError: - fmt.Println(styles.Err.Render("✗ " + ev.Text)) + fmt.Println(tui.Wrap(styles.Err.Render("✗ "+ev.Text), tui.TerminalWidth())) } } func (plainSink) Raw(line string) { if strings.TrimSpace(line) != "" { - fmt.Println(theme.Default().Muted.Render(" " + truncatePlain(line))) + fmt.Println(tui.Wrap(theme.Default().Muted.Render(" "+line), tui.TerminalWidth())) } } func (plainSink) Done() {} - -func truncatePlain(s string) string { - s = strings.ReplaceAll(strings.ReplaceAll(s, "\r", ""), "\n", " ") - if len(s) > 200 { - return s[:199] + "…" - } - return s -} diff --git a/go.mod b/go.mod index 050cde0..21958a9 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,10 @@ require ( github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.11.6 github.com/spf13/cobra v1.10.2 github.com/traust-security/traust-sdk/go v0.22.1-0.20261014220305-ef1e2d991492 + golang.org/x/term v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -20,7 +22,6 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -52,6 +53,5 @@ require ( github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.36.0 // indirect golang.org/x/text v0.39.0 // indirect ) diff --git a/internal/tui/monitor.go b/internal/tui/monitor.go index bc1eb1a..4963fea 100644 --- a/internal/tui/monitor.go +++ b/internal/tui/monitor.go @@ -138,7 +138,7 @@ func (m monitorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case itemEvent: m.applyEvent(msg.ev) case itemRaw: - m.pushLine(m.styles.Muted.Render(" " + truncateLine(msg.text, m.width-4))) + m.pushLine(m.styles.Muted.Render(" " + msg.text)) case itemProgress: style := m.styles.Accent if strings.HasPrefix(msg.text, "warning:") || strings.HasPrefix(msg.text, "note:") { @@ -167,6 +167,7 @@ func (m monitorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height m.viewport.Width = msg.Width - 2 m.viewport.Height = msg.Height - 8 + m.rebuild() return m, nil case tea.KeyMsg: switch msg.String() { @@ -205,7 +206,7 @@ func (m monitorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *monitorModel) applyEvent(ev harness.Event) { switch ev.Kind { case harness.EventText: - m.pushLine(truncateLine(ev.Text, m.width-4)) + m.pushLine(ev.Text) case harness.EventToolCall: m.pushLine(m.styles.Accent.Render("→ " + ev.Tool)) case harness.EventUsage: @@ -214,23 +215,45 @@ func (m *monitorModel) applyEvent(ev harness.Event) { m.tokensIn, m.tokensOut = ev.TokensIn, ev.TokensOut m.cost = ev.CostUSD if ev.Text != "" { - m.pushLine(m.styles.OK.Render("✓ " + truncateLine(ev.Text, m.width-4))) + m.pushLine(m.styles.OK.Render("✓ " + ev.Text)) } case harness.EventError: m.failed = true - m.pushLine(m.styles.Err.Render("✗ " + truncateLine(ev.Text, m.width-4))) + m.pushLine(m.styles.Err.Render("✗ " + ev.Text)) case harness.EventNeedsInput: m.failed = true m.pushLine(m.styles.Err.Render("✗ the skill asked for interactive input — run it interactively in your harness")) } } +// pushLine stores a styled line UNWRAPPED and rebuilds the viewport at the +// current width. Lines are wrapped, never truncated — the transcript stays +// complete at any terminal size. func (m *monitorModel) pushLine(line string) { - m.lines = append(m.lines, line) + line = strings.ReplaceAll(line, "\r", "") + for _, l := range strings.Split(line, "\n") { + m.lines = append(m.lines, l) + } if len(m.lines) > 1000 { m.lines = m.lines[len(m.lines)-1000:] } - m.viewport.SetContent(strings.Join(m.lines, "\n")) + m.rebuild() +} + +// rebuild re-wraps every retained line at the current viewport width. This +// is what makes a terminal resize actually take effect: previously pushed +// lines re-flow instead of staying clipped to whatever the width was when +// they arrived. +func (m *monitorModel) rebuild() { + width := m.viewport.Width + if width < 4 { + width = 4 + } + wrapped := make([]string, 0, len(m.lines)) + for _, l := range m.lines { + wrapped = append(wrapped, Wrap(l, width)) + } + m.viewport.SetContent(strings.Join(wrapped, "\n")) m.viewport.GotoBottom() } @@ -249,9 +272,11 @@ func (m monitorModel) View() string { status = m.styles.Warn.Render("aborting…") } title := m.styles.App.Render(" traust ") - b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, - title, - m.styles.Header.Render(fmt.Sprintf(" %s · %s · %s · %s · %s", m.header.Skill, m.header.Harness, ellipsize(m.header.Model, 40), elapsed, status)))) + head := m.styles.Header.Render(fmt.Sprintf(" %s · %s · %s · %s · %s", m.header.Skill, m.header.Harness, ellipsize(m.header.Model, 40), elapsed, status)) + // The header must fit one line at any width: clip after assembly rather + // than letting the terminal hard-wrap it into the transcript frame. + head = Truncate(head, maxInt(m.width-lipgloss.Width(title), 10), "…") + b.WriteString(title + head) b.WriteString("\n\n") body := m.viewport.View() @@ -274,10 +299,13 @@ func (m monitorModel) View() string { } var bar string if m.header.BudgetCap > 0 { - bar = m.budgetBar(m.tokensOut, m.header.BudgetCap) + // The bar yields its cells to the text on narrow terminals. + barWidth := clampInt((m.width-lipgloss.Width(usage))/3, 8, 24) + bar = m.budgetBar(m.tokensOut, m.header.BudgetCap, barWidth) usage = usage + " " + bar } - b.WriteString(m.styles.StatusBar.Render(usage)) + // The status bar style adds one cell of padding on each side. + b.WriteString(m.styles.StatusBar.Render(Truncate(usage, maxInt(m.width-2, 20), ""))) b.WriteString("\n") footer := "ctrl+c×2 abort" @@ -295,8 +323,10 @@ func (m monitorModel) View() string { return b.String() } -func (m monitorModel) budgetBar(out, cap int64) string { - width := 24 +func (m monitorModel) budgetBar(out, cap int64, width int) string { + if width < 4 { + width = 4 + } ratio := 0.0 if cap > 0 { ratio = float64(out) / float64(cap) @@ -367,14 +397,6 @@ func RunMonitor(header MonitorHeader, cancel func(), runFn func(sink runner.Sink } } -func truncateLine(s string, n int) string { - s = strings.ReplaceAll(strings.ReplaceAll(s, "\r", ""), "\n", " ") - if len(s) > n { - return s[:n-1] + "…" - } - return s -} - func ellipsize(s string, n int) string { if s == "" { return "(default)" @@ -385,6 +407,23 @@ func ellipsize(s string, n int) string { return s } +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + func human(n int64) string { switch { case n >= 1_000_000: diff --git a/internal/tui/report.go b/internal/tui/report.go index 1346429..7f3e9e2 100644 --- a/internal/tui/report.go +++ b/internal/tui/report.go @@ -16,52 +16,111 @@ import ( // reportModel browses a report's findings: a severity-colored table with a // glamour-rendered detail pane. Read-only. +// +// Everything width-dependent (table columns, markdown word wrap) is derived +// from the CURRENT width and rebuilt on every WindowSizeMsg, so resizing the +// terminal reflows the layout instead of clipping to the startup size. type reportModel struct { - styles theme.Styles - table table.Model - viewport viewport.Model - report *views.Report - mode string // "table" | "detail" - renderer *glamour.TermRenderer - width, height int + styles theme.Styles + table table.Model + viewport viewport.Model + report *views.Report + // sorted holds the severity-ordered findings; rows are derived from it + // at the CURRENT width in buildTable, so a resize re-truncates them. + sorted []views.Finding + mode string // "table" | "detail" + detailMD string + renderer *glamour.TermRenderer + width int + height int + // tableBuilt distinguishes "no table yet" from a zero-value table + // (table.Model is a struct, not a pointer). + tableBuilt bool } var severityOrder = map[string]int{"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4, "none": 5, "unrated": 6} func newReportModel(rep *views.Report, width, height int) reportModel { styles := theme.Default() - cols := []table.Column{ - {Title: "SEV", Width: 9}, - {Title: "ID", Width: 30}, - {Title: "TITLE", Width: width - 45}, - } - rows := make([]table.Row, 0, len(rep.Findings)) + m := reportModel{styles: styles, report: rep, mode: "table", width: width, height: height} + sorted := make([]views.Finding, len(rep.Findings)) copy(sorted, rep.Findings) - // Sort by severity, stable. for i := 1; i < len(sorted); i++ { for j := i; j > 0 && severityOrder[sevKey(sorted[j])] < severityOrder[sevKey(sorted[j-1])]; j-- { sorted[j], sorted[j-1] = sorted[j-1], sorted[j] } } - for _, f := range sorted { + m.sorted = sorted + + m.viewport = viewport.New(width-4, height-10) + m.buildTable() + m.buildRenderer() + return m +} + +// buildTable (re)creates the findings table with column widths derived from +// the current terminal width, re-truncating the rows to match and preserving +// the cursor. +func (m *reportModel) buildTable() { + sevW := 9 + idW := clampInt(m.width/4, 10, 30) + titleW := maxInt(m.width-6-sevW-idW, 12) + cols := []table.Column{ + {Title: "SEV", Width: sevW}, + {Title: "ID", Width: idW}, + {Title: "TITLE", Width: titleW}, + } + rows := make([]table.Row, 0, len(m.sorted)) + for _, f := range m.sorted { id := f.ID if id == "" { id = "—" } - if len(id) > 28 { - id = id[:27] + "…" - } - rows = append(rows, table.Row([]string{strings.ToUpper(f.Severity), id, f.Title})) + rows = append(rows, table.Row([]string{ + strings.ToUpper(f.Severity), + clip(id, idW-1), + clip(f.Title, titleW-1), + })) } - t := table.New(table.WithColumns(cols), table.WithRows(rows), table.WithHeight(minInt(height-8, 20)), table.WithWidth(width-4)) + t := table.New( + table.WithColumns(cols), + table.WithRows(rows), + table.WithHeight(clampInt(m.height-10, 5, 20)), + table.WithWidth(maxInt(m.width-4, 20)), + ) t.SetStyles(table.Styles{ - Header: styles.Header.BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(theme.Palette.Muted), + Header: m.styles.Header.BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).BorderForeground(theme.Palette.Muted), Selected: lipgloss.NewStyle().Bold(true).Foreground(theme.Palette.Text).Background(theme.Palette.Surface), }) - vp := viewport.New(width-4, height-10) - renderer, _ := glamour.NewTermRenderer(glamour.WithWordWrap(width-8), glamour.WithStandardStyle("dark")) - return reportModel{styles: styles, table: t, viewport: vp, report: rep, mode: "table", renderer: renderer, width: width, height: height} + if m.tableBuilt { + t.SetCursor(m.table.Cursor()) + } + m.table = t + m.tableBuilt = true +} + +// clip truncates a plain string to n cells. +func clip(s string, n int) string { + if n < 1 { + n = 1 + } + if len(s) > n { + return s[:n-1] + "…" + } + return s +} + +// buildRenderer (re)creates the glamour renderer at the current width and +// re-renders the open detail pane. +func (m *reportModel) buildRenderer() { + r, err := glamour.NewTermRenderer(glamour.WithWordWrap(maxInt(m.width-8, 20)), glamour.WithStandardStyle("dark")) + if err == nil { + m.renderer = r + } + if m.mode == "detail" { + m.renderDetail() + } } func sevKey(f views.Finding) string { @@ -77,8 +136,10 @@ func (m reportModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height - m.viewport.Width = msg.Width - 4 - m.viewport.Height = msg.Height - 10 + m.viewport.Width = maxInt(msg.Width-4, 20) + m.viewport.Height = maxInt(msg.Height-10, 3) + m.buildTable() + m.buildRenderer() return m, nil case tea.KeyMsg: switch msg.String() { @@ -110,10 +171,17 @@ func (m *reportModel) showDetail(idx int) { return } f := m.report.Findings[idx] - md := fmt.Sprintf("# %s\n\n**Severity:** %s · **ID:** %s\n\n%s\n", f.Title, f.Severity, f.ID, f.Description) - rendered := md + m.detailMD = fmt.Sprintf("# %s\n\n**Severity:** %s · **ID:** %s\n\n%s\n", f.Title, f.Severity, f.ID, f.Description) + m.renderDetail() +} + +func (m *reportModel) renderDetail() { + if m.detailMD == "" { + return + } + rendered := m.detailMD if m.renderer != nil { - if out, err := m.renderer.Render(md); err == nil { + if out, err := m.renderer.Render(m.detailMD); err == nil { rendered = out } } @@ -131,9 +199,9 @@ func (m reportModel) View() string { } } title := m.styles.App.Render(" traust ") + m.styles.Header.Render(" "+shortPath(m.report.Path)) - b.WriteString("\n" + title + "\n") + b.WriteString("\n" + Truncate(title, m.width, "…") + "\n") if len(parts) > 0 { - b.WriteString(strings.Join(parts, " ") + "\n") + b.WriteString(Truncate(strings.Join(parts, " "), m.width, "") + "\n") } b.WriteString("\n") @@ -166,10 +234,3 @@ func shortPath(p string) string { } return p } - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/tui/width.go b/internal/tui/width.go new file mode 100644 index 0000000..041db4d --- /dev/null +++ b/internal/tui/width.go @@ -0,0 +1,38 @@ +package tui + +import ( + "os" + + "github.com/charmbracelet/x/ansi" + "golang.org/x/term" +) + +// DefaultPlainWidth is the fallback line width for non-TTY output. +const DefaultPlainWidth = 120 + +// TerminalWidth returns the terminal width of stdout, or a sane default +// when stdout is not a terminal. +func TerminalWidth() int { + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { + return w + } + return DefaultPlainWidth +} + +// Wrap wraps a (possibly ANSI-styled) string hard at width, preserving +// escape sequences across the wrap. +func Wrap(s string, width int) string { + if width < 4 { + width = 4 + } + return ansi.Wrap(s, width, "") +} + +// Truncate clips a (possibly ANSI-styled) string to width, appending tail +// when something was cut. +func Truncate(s string, width int, tail string) string { + if width < 1 { + width = 1 + } + return ansi.Truncate(s, width, tail) +} diff --git a/internal/tui/width_test.go b/internal/tui/width_test.go new file mode 100644 index 0000000..cbff094 --- /dev/null +++ b/internal/tui/width_test.go @@ -0,0 +1,130 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + + "github.com/traust-security/traust-cli/internal/views" +) + +func TestWrapRespectsWidth(t *testing.T) { + cases := []struct { + width int + text string + }{ + {20, strings.Repeat("x", 100)}, + {40, "short line"}, + {30, "word " + strings.Repeat("longword", 10)}, + } + for _, c := range cases { + wrapped := Wrap(c.text, c.width) + for _, line := range strings.Split(wrapped, "\n") { + if w := lipgloss.Width(line); w > c.width { + t.Errorf("wrap(width=%d) produced a %d-cell line: %q", c.width, w, line) + } + } + } +} + +func TestWrapPreservesANSI(t *testing.T) { + // The test process has no TTY: force a color profile so Render emits + // real escapes. + lipgloss.SetColorProfile(termenv.TrueColor) + styled := lipgloss.NewStyle().Foreground(lipgloss.Color("#EE0000")).Render(strings.Repeat("red ", 20)) + wrapped := Wrap(styled, 12) + // A hard wrapper does not re-emit styles on continuation lines; the + // requirement is that every line fits and the escapes survive the wrap + // without corrupting the width calculation. + if !strings.Contains(wrapped, "\x1b[") { + t.Error("wrap stripped all ANSI styling") + } + for _, line := range strings.Split(wrapped, "\n") { + if w := lipgloss.Width(line); w > 12 { + t.Errorf("wrapped line is %d cells wide (limit 12): %q", w, line) + } + } +} + +func TestTruncate(t *testing.T) { + lipgloss.SetColorProfile(termenv.TrueColor) + styled := lipgloss.NewStyle().Bold(true).Render(strings.Repeat("x", 50)) + got := Truncate(styled, 10, "…") + if w := lipgloss.Width(got); w > 10 { + t.Errorf("truncate produced %d cells", w) + } + // The reset escape trails the tail, so check containment not suffix. + if !strings.Contains(got, "…") { + t.Errorf("truncate should keep the tail: %q", got) + } + if !strings.Contains(got, "\x1b[") { + t.Errorf("truncate should preserve styling: %q", got) + } +} + +// TestMonitorRewrapsOnResize is the regression test for the width bug: +// lines pushed at one width must re-flow when the terminal resizes. +func TestMonitorRewrapsOnResize(t *testing.T) { + m := newMonitor(MonitorHeader{Skill: "s", Harness: "claude", Model: "m", BudgetCap: 0}, nil, 100, 30) + m.pushLine(strings.Repeat("x", 100)) // viewport width 98 → 2 display lines + + if got := m.viewport.TotalLineCount(); got != 2 { + t.Fatalf("at width 100 the line should occupy 2 display lines, got %d", got) + } + + // Narrow the terminal to 50 (viewport 48): the same stored line must + // re-wrap to 3. + updated, _ := m.Update(tea.WindowSizeMsg{Width: 50, Height: 30}) + m2, ok := updated.(monitorModel) + if !ok { + t.Fatalf("update returned %T", updated) + } + if got := m2.viewport.TotalLineCount(); got != 3 { + t.Fatalf("at width 50 the line should re-wrap to 3 display lines, got %d", got) + } +} + +// TestMonitorHeaderFitsNarrowTerminals guards the one-line header invariant. +func TestMonitorHeaderFitsNarrowTerminals(t *testing.T) { + for _, width := range []int{40, 60, 80, 200} { + m := newMonitor(MonitorHeader{Skill: "secure-code-audit", Harness: "claude", Model: "rits/zai-org/glm-5-3", BudgetCap: 1000000}, nil, width, 30) + m.tokensIn, m.tokensOut = 123456, 654321 + out := m.View() + for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { + if w := lipgloss.Width(line); w > width { + t.Errorf("width %d: view has a %d-cell line: %q", width, w, line) + } + } + } +} + +// TestReportTableRebuildsOnResize guards the report browser's adaptive +// columns. +func TestReportTableRebuildsOnResize(t *testing.T) { + rep := &views.Report{ + Path: "/tmp/x/repo-security-audit.json", + Findings: []views.Finding{ + {ID: "F-001", Severity: "high", Title: strings.Repeat("long title ", 15)}, + {ID: "F-002", Severity: "critical", Title: "short"}, + }, + } + m := newReportModel(rep, 120, 40) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 50, Height: 24}) + m2, ok := updated.(reportModel) + if !ok { + t.Fatalf("update returned %T", updated) + } + out := m2.View() + for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { + if w := lipgloss.Width(line); w > 50 { + t.Errorf("width 50: view has a %d-cell line: %q", w, line) + } + } + // The cursor must survive the rebuild. + if m2.table.Cursor() != 0 { + t.Errorf("cursor = %d after rebuild", m2.table.Cursor()) + } +}