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()) + } +}