diff --git a/CHANGELOG.md b/CHANGELOG.md index fda8504..59713cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,3 +60,12 @@ Initial implementation. `--dangerously-bypass-approvals-and-sandbox`, pi's full toolset — and surface it in the notes, the run journal and `doctor`. Without consent, permission requests still fail closed (exit 2) with guidance. +- Every remaining print path now respects the terminal width: the + full-permission and standalone-profile warnings, the run summary's + harness-error block (source indentation from harness error boxes is + stripped so re-wrapped lines start at the left edge) and artifact lines, + doctor details, init step logs, and the skills table (which drops its + description column below ~77 columns instead of overflowing). +- Interactive screens now gate on a real terminal check (ioctl) instead of + "is a character device": `traust-cli skills < /dev/null` used to open the + picker and hang forever, because /dev/null is a character device. diff --git a/cmd/doctor.go b/cmd/doctor.go index 8b82ba5..4deae13 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -5,11 +5,13 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/spf13/cobra" "github.com/traust-security/traust-cli/internal/bootstrap" "github.com/traust-security/traust-cli/internal/theme" + "github.com/traust-security/traust-cli/internal/tui" ) var doctorJSON bool @@ -50,7 +52,9 @@ func runDoctor(cmd *cobra.Command, args []string) { } fmt.Printf(" %s %s\n", mark, c.Name) if c.Detail != "" { - fmt.Println(styles.Muted.Render(" " + c.Detail)) + // Details can be multi-line and long (subprocess output): + // normalize the block and wrap it to the terminal width. + fmt.Println(styles.Muted.Render(indent(tui.Wrap(strings.TrimSpace(c.Detail), tui.TerminalWidth()-8), " "))) } } } diff --git a/cmd/init.go b/cmd/init.go index 4544d89..823cf38 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -6,6 +6,7 @@ import ( "os" "github.com/spf13/cobra" + "golang.org/x/term" "github.com/traust-security/traust-cli/internal/bootstrap" "github.com/traust-security/traust-cli/internal/harness" @@ -91,7 +92,7 @@ func runInit(cmd *cobra.Command, args []string) { } log := func(format string, a ...any) { - fmt.Println(styles.Accent.Render(fmt.Sprintf(format, a...))) + fmt.Println(tui.Wrap(styles.Accent.Render(fmt.Sprintf(format, a...)), tui.TerminalWidth())) } state, err := bootstrap.Init(ctx, bootstrap.InitOptions{ @@ -149,12 +150,9 @@ func shortSHA(sha string) string { } // isTTY reports whether the CLI can render interactive screens: both stdin -// and stdout must be character devices (piped output means plain mode). +// and stdout must be real terminals. The check is ioctl-based — /dev/null is +// a character device but not a terminal, and treating it as one hangs the +// picker waiting for input that will never arrive. func isTTY() bool { - in, err := os.Stdin.Stat() - if err != nil || in.Mode()&os.ModeCharDevice == 0 { - return false - } - out, err := os.Stdout.Stat() - return err == nil && out.Mode()&os.ModeCharDevice != 0 + return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) } diff --git a/cmd/run.go b/cmd/run.go index 2bb0f48..e2b66cc 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -132,12 +132,11 @@ func executeRun(state *bootstrap.State, skill *catalog.Skill, args []string) { // recorded in state. The harness's skills require unattended tool use. unsafeFullPermission := runFlags.unsafeFullPermission || state.FullPermission if unsafeFullPermission && !runFlags.unsafeFullPermission { - fmt.Fprintln(os.Stderr, theme.Default().Warn.Render("note: full-permission mode active (consented at init; re-run `traust-cli init` to change) — harness runs with all tools auto-approved")) + warnNote("full-permission mode active (consented at init; re-run `traust-cli init` to change) — harness runs with all tools auto-approved") } if skill.Standalone == nil { - styles := theme.Default() - fmt.Fprintln(os.Stderr, styles.Warn.Render("warning: "+fmt.Sprintf("%q is not in the standalone profile — it may expect campaign infrastructure (siblings, ledger, tracker)", skill.Name))) + warnNote(fmt.Sprintf("%q is not in the standalone profile — it may expect campaign infrastructure (siblings, ledger, tracker)", skill.Name)) } opts := runner.Options{ @@ -204,7 +203,7 @@ func printRunSummary(res *runner.RunResult) { if res.HarnessError != "" { fmt.Println() fmt.Println(styles.Err.Render("harness error:")) - fmt.Println(indent(res.HarnessError, " ")) + fmt.Println(indent(wrapBlock(res.HarnessError, tui.TerminalWidth()-4), " ")) } if len(res.Artifacts) > 0 { fmt.Println() @@ -217,7 +216,7 @@ func printRunSummary(res *runner.RunResult) { if a.Rendered != "" { extra = styles.Muted.Render(" (rendered → " + a.Rendered + ")") } - fmt.Printf(" %s %s%s\n", mark, a.Path, extra) + fmt.Println(tui.Wrap(mark+" "+a.Path+extra, tui.TerminalWidth())) } } if res.ResultText != "" { @@ -227,6 +226,21 @@ func printRunSummary(res *runner.RunResult) { } } +// wrapBlock normalizes a multi-line block for display: trailing whitespace +// is stripped, leading indentation from source formatting (e.g. a harness's +// styled error box) is dropped so re-wrapped lines start at the left edge, +// and every line wraps to width. +func wrapBlock(s string, width int) string { + if width < 10 { + width = 10 + } + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + for i, l := range lines { + lines[i] = strings.TrimSpace(l) + } + return tui.Wrap(strings.Join(lines, "\n"), width) +} + func indent(s, prefix string) string { lines := strings.Split(strings.TrimRight(s, "\n"), "\n") for i, l := range lines { @@ -235,6 +249,11 @@ func indent(s, prefix string) string { return strings.Join(lines, "\n") } +// warnNote prints a wrapped warning to stderr. +func warnNote(msg string) { + fmt.Fprintln(os.Stderr, tui.Wrap(theme.Default().Warn.Render("warning: "+msg), tui.TerminalWidth())) +} + // plainSink prints run updates as styled plain text (non-TTY or --no-monitor). type plainSink struct{} diff --git a/cmd/run_test.go b/cmd/run_test.go new file mode 100644 index 0000000..07c15e7 --- /dev/null +++ b/cmd/run_test.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +func TestWrapBlockStripsIndentationAndWraps(t *testing.T) { + lipgloss.SetColorProfile(termenv.TrueColor) + block := "exit status 3\n ERROR Agent processing failed: 401 Unauthorized invalid x-api-key \n" + out := wrapBlock(block, 40) + + for _, line := range strings.Split(out, "\n") { + if w := lipgloss.Width(line); w > 40 { + t.Errorf("line is %d cells wide (limit 40): %q", w, line) + } + // Harness error boxes arrive with leading decoration; the display + // form must start at the left edge so re-wrapped lines align. + if strings.HasPrefix(line, " ") { + t.Errorf("line retains leading whitespace: %q", line) + } + } + if !strings.Contains(out, "exit status 3") { + t.Errorf("content lost: %q", out) + } + if strings.Contains(out, " \n") || strings.HasSuffix(out, " ") { + t.Errorf("trailing whitespace not stripped: %q", out) + } +} + +func TestWrapBlockPreservesNewlines(t *testing.T) { + out := wrapBlock("short one\nshort two", 40) + if out != "short one\nshort two" { + t.Errorf("short lines should pass through: %q", out) + } +} diff --git a/cmd/skills.go b/cmd/skills.go index bbb8f57..26f39ee 100644 --- a/cmd/skills.go +++ b/cmd/skills.go @@ -99,23 +99,37 @@ func runSkills(cmd *cobra.Command, args []string) { } return skills[i].Name < skills[j].Name }) - fmt.Printf(" %-28s %-15s %-11s %s\n", styles.Label.Render("SKILL"), styles.Label.Render("STAGE"), styles.Label.Render("MIN MODEL"), styles.Label.Render("DESCRIPTION")) - for _, s := range skills { - minModel := "—" - if s.Standalone != nil && s.Standalone.MinModelClass != "" { - minModel = s.Standalone.MinModelClass + // Column layout adapts to the terminal. Below ~77 columns there is no + // room for a description column, so the table drops it rather than + // pushing rows past the right edge. + width := tui.TerminalWidth() + avail := width - 2 + if avail < 28+15+11+3+20 { + fmt.Printf(" %-28s %-15s %-11s\n", styles.Label.Render("SKILL"), styles.Label.Render("STAGE"), styles.Label.Render("MIN MODEL")) + for _, s := range skills { + fmt.Printf(" %-28s %-15s %-11s\n", + tui.Truncate(s.Name, 28, "…"), tui.Truncate(orDash(s.Stage), 15, "…"), minModelOf(s)) } - desc := s.Description - if len(desc) > 70 { - desc = desc[:69] + "…" + } else { + descWidth := avail - 28 - 15 - 11 - 3 + fmt.Printf(" %-28s %-15s %-11s %s\n", styles.Label.Render("SKILL"), styles.Label.Render("STAGE"), styles.Label.Render("MIN MODEL"), styles.Label.Render("DESCRIPTION")) + for _, s := range skills { + fmt.Printf(" %-28s %-15s %-11s %s\n", + tui.Truncate(s.Name, 28, "…"), tui.Truncate(orDash(s.Stage), 15, "…"), minModelOf(s), tui.Truncate(s.Description, descWidth, "…")) } - fmt.Printf(" %-28s %-15s %-11s %s\n", s.Name, orDash(s.Stage), minModel, desc) } fmt.Println() fmt.Println(styles.Muted.Render("run one: traust-cli run [args…] --harness ")) os.Exit(0) } +func minModelOf(s *catalog.Skill) string { + if s.Standalone != nil && s.Standalone.MinModelClass != "" { + return s.Standalone.MinModelClass + } + return "—" +} + func orDash(s string) string { if s == "" { return "—"