Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 8 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 5 additions & 13 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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), " "))
}
}

Expand All @@ -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
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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
Expand Down Expand Up @@ -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
)
81 changes: 60 additions & 21 deletions internal/tui/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:") {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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:
Expand All @@ -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()
}

Expand All @@ -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()
Expand All @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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)"
Expand All @@ -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:
Expand Down
Loading
Loading