diff --git a/fakedata/exec_wrap.star b/fakedata/exec_wrap.star new file mode 100644 index 00000000..e87bd31a --- /dev/null +++ b/fakedata/exec_wrap.star @@ -0,0 +1,177 @@ +"""exec_command wrapping scenarios for BITS_FAKE_BACKEND=1. Start with `BITS_FAKE_BACKEND=1 go run .`. + +Run everything from the Starlark console: + load("fakedata/exec_wrap.star", "all"); all() + +or one group / one case: + load("fakedata/exec_wrap.star", "realistic"); realistic() + load("fakedata/exec_wrap.star", "corners"); corners() + load("fakedata/exec_wrap.star", "hyphens"); hyphens() + +What to check: +- Collapsed: each command is one header line (plus at most 9 │ rows for + multi-line commands), cut with "…". +- Expanded (Ctrl+O, or click ▶): no "…" inside a command, nothing lost at + the right edge; the first line wraps under the command's first column, + later lines wrap behind │ with soft continuations indented by 2 more, + and source indentation is kept on every row. +- Resize down to ~20 columns and back while expanded: text rewraps into a + narrower column, never truncated, │ markers stay. + +Every command is read-only. go/git/python3/rg may be missing on a machine; the +call then fails, which still exercises the error header. In manual mode each +call waits on the approval panel, which shows the "awaiting approval" suffix. +""" + +def words(word, n): + return " ".join([word] * n) + +def run(cmd, **kwargs): + args = {"cmd": cmd} + args.update(kwargs) + return call("exec_command", args) + +# --------------------------------------------------------------------------- +# Realistic agent-style commands. + +def rg_search(): + # ripgrep with globs, an alternation regex and a pipe. + run("rg -n --hidden --no-heading -g '*.go' -g '!vendor/**' -g '!**/*_test.go' -e 'renderExecInvocation|hangWrap|lipgloss\\.Wrap|ansi\\.Truncate' internal/tui | sort -t: -k1,1 -k2,2n | head -n 40", timeout_ms = 20000) + +def go_chain(): + # A typical long && chain; takes a few seconds, so the running state shows. + run("cd internal/tui/chat && go vet ./... && go test -count=1 -run 'TestExecCommand|TestRender' ./... 2>&1 | tail -n 20 && cd - >/dev/null && git status --short && git diff --stat -- internal/tui/chat", timeout_ms = 120000) + +def git_history(): + # git log | awk | xargs git show, with a format string full of % and -. + run("git log --oneline --no-merges -n 5 -- internal/tui/chat | awk '{print $1}' | xargs -I{} git show --stat --format='--- %h %an (%ar): %s' {} | head -n 60") + +def find_sizes(): + # find -print0 | xargs -0 with a fallback; workdir shows in the header. + run("find . -type f -name '*.go' -not -path './vendor/*' -not -path './.git/*' -print0 | xargs -0 wc -l | sort -rn | head -n 15 || echo 'find failed' >&2", workdir = "internal") + +def shell_loop(): + # set -euo pipefail, a for loop and a conditional, all on one line. + run("set -euo pipefail; for pkg in internal/tui/chat internal/tui/components internal/tui/diffrender; do n=$(ls \"$pkg\"/*.go 2>/dev/null | wc -l | tr -d ' '); if [ \"$n\" -gt 0 ]; then printf '%-32s %3s go files\\n' \"$pkg\" \"$n\"; else echo \"skip $pkg\"; fi; done", shell = "bash") + +def python_inline(): + # python3 -c one-liner with nested quotes. + run("python3 -c \"import json, pathlib, collections; c = collections.Counter(p.suffix or '' for p in pathlib.Path('internal').rglob('*') if p.is_file()); print(json.dumps(dict(c.most_common(8)), indent=2, sort_keys=True))\"") + +def python_heredoc(): + # Indented heredoc lines that wrap: continuation indent is fixed, not code-aligned. + run("""python3 - <<'PY' +import pathlib, re + +pattern = re.compile(r"func (render[A-Z]\\w*|hangWrap|wrapSummarySpans)\\(") +hits = [] +for path in sorted(pathlib.Path("internal/tui/chat").glob("*.go")): + if path.name.endswith("_test.go"): + continue + for number, line in enumerate(path.read_text().splitlines(), start=1): + if (match := pattern.search(line)) is not None: + hits.append((path.as_posix(), number, match.group(1))) +for path, number, name in hits[:20]: + print(f"{path}:{number}: {name}") +print(f"{len(hits)} renderer functions found across {len({h[0] for h in hits})} files in internal/tui/chat") +PY""") + +def realistic(): + rg_search() + go_chain() + git_history() + find_sizes() + shell_loop() + python_inline() + python_heredoc() + +# --------------------------------------------------------------------------- +# Corner cases found while reviewing the wrap. + +def header_metadata(): + # Long command + long workdir + timeout: " in …" and " · timeout …" follow + # the wrapped command; the " · " separators may be orphaned at a row end. + run("echo " + words("metadata-follows-the-command", 5), workdir = "internal/tui/chat/../components/../chat", timeout_ms = 45000) + +def exit_suffix(): + # Non-zero exit: " · exit N" after a wrapped command. + run("ls " + " ".join(["does-not-exist-%d" % i for i in range(1, 12)])) + +def timed_out(): + # " · timed out" after a wrapped command. + run("sleep 3 && echo " + words("never-printed-because-the-command-times-out", 3), timeout_ms = 1000) + +def hard_break(): + # One token with no breakpoints: hard-wrapped mid-token. + run("echo " + "x" * 300) + +def long_path(): + # Paths break after "/" (shellBreakpoints). + run("ls -la /usr/local/share/" + "/".join(["very-long-directory-name-%d" % i for i in range(1, 8)]) + " 2>/dev/null || true") + +def hyphens(): + # Known ambiguity: a break after "-" inside a token looks like a space. + # Compare "--format=%h-%an-%s" and "two-weeks-ago" across row ends. + run("git log -n 3 --format=%h-%an-%s --since=two-weeks-ago --date=format-local:%Y-%m-%d-%H-%M --no-merges -- internal/tui/chat") + +def heredoc_long_line(): + # Many rows (compact middle-clamps) with one long line behind │. + rows = ["row %d" % i for i in range(1, 15)] + rows[5] = "row 6 " + words("heredoc-content-that-wraps", 6) + run("cat <<'EOF'\n" + "\n".join(rows) + "\nEOF") + +def tabs_and_blanks(): + # Tab-indented and blank lines inside a heredoc keep their layout. + run("cat <<'EOF'\nall:\n\tgo build ./... && go vet ./... && go test -count=1 -race ./internal/tui/... ./internal/agent/...\n\n\n\t\t@echo " + words("tab-indented-recipe-line", 4) + "\nEOF") + +def unicode(): + # Wide graphemes (CJK, emoji, combining marks) must wrap on cell width. + run("echo '" + words("日本語のコマンド", 6) + " 🚀🔥✨ " + words("café-naïve-résumé", 4) + "'") + +def shell_quoting(): + # Nested quotes, $(…), backticks and escaped characters wrapping mid-way. + run("printf '%s\\n' \"$(git rev-parse --abbrev-ref HEAD)\" `date -u +%Y-%m-%dT%H:%M:%SZ` 'single \"quoted\" text with spaces' \"double 'quoted' text with \\$HOME and \\\"escapes\\\"\" | sed -e 's/^/ > /' -e 's/$/ limit && c.disclosure == compactView { + if len(rows) > limit { omissionRow = min(2, limit-1) rows = middleClamp(rows, limit) } @@ -612,6 +624,39 @@ func renderExecInvocation(tool *agent.ToolBlock, p toolPresentation, suffix []su return header + "\n" + strings.Join(rows, "\n") } +// renderExecInvocationFull is the expanded invocation: every source line is +// shown in full, wrapped instead of truncated. The first line wraps under the +// command's first column, so expanding only adds rows below the compact +// header; later lines wrap behind the │ branch, with soft-wrapped +// continuations indented further to keep them distinct from real newlines. +// Source indentation is kept on every row. +func renderExecInvocationFull(tool *agent.ToolBlock, p toolPresentation, lines [][]textwrap.Segment, suffix []summarySpan, c renderContext) string { + state := statusOf(tool) + action := p.actionLabel(state) + lead := headerGlyph(state, c) + c.sty.ToolName.Render(action) + c.sty.ToolDetail.Render(" ") + leadWidth := ansi.StringWidth(lead) + + meta := append(append(p.execMeta(), suffix...), lifecycleSpans(state)...) + out := textwrap.Wrap(textwrap.Options{ + Width: c.width, + FirstPrefix: lead, + RestPrefix: strings.Repeat(" ", leadWidth), + Breakpoints: textwrap.Shell, + KeepIndent: true, + }, slices.Concat(lines[0], spanSegments(meta, c.sty))...) + branch := textwrap.Options{ + Width: c.width, + FirstPrefix: c.sty.ToolDetail.Render(" │ "), + RestPrefix: c.sty.ToolDetail.Render(" │ "), + Breakpoints: textwrap.Shell, + KeepIndent: true, + } + for _, row := range lines[1:] { + out = append(out, textwrap.Wrap(branch, row...)...) + } + return strings.Join(out, "\n") +} + func execSuffix(result spec.ExecCommandOutput) []summarySpan { var text string switch result.Status { @@ -676,11 +721,13 @@ func execRows(result spec.ExecCommandOutput, bodyWidth int, d disclosure) []stri return rows } +// wrappedRows wraps plain multiline output, keeping each line's indentation +// and breaking paths at slashes. func wrappedRows(s string, width int) []string { if strings.TrimSpace(s) == "" { return nil } - return strings.Split(wrap(s, max(1, width)), "\n") + return textwrap.Wrap(textwrap.Options{Width: width, Breakpoints: textwrap.Output, KeepIndent: true}, textwrap.Plain(s)) } func middleClamp(rows []string, limit int) []string { @@ -738,15 +785,24 @@ const statusGlyphWidth = 2 func renderToolHeader(tool *agent.ToolBlock, summary, suffix []summarySpan, c renderContext) string { state := statusOf(tool) - glyph, glyphStyle := statusGlyph(state, c.sty, c.frame) spans := make([]summarySpan, 0, len(summary)+len(suffix)+1) spans = append(spans, summary...) spans = append(spans, suffix...) + spans = append(spans, lifecycleSpans(state)...) + header := headerGlyph(state, c) + renderSpans(spans, c.sty) + return ansi.Truncate(header, max(1, c.width), "…") +} + +func headerGlyph(state agent.ToolStatus, c renderContext) string { + glyph, style := statusGlyph(state, c.sty, c.frame) + return style.UnsetBackground().Render(glyph + " ") +} + +func lifecycleSpans(state agent.ToolStatus) []summarySpan { if label := lifecycleSuffix(state); label != "" { - spans = append(spans, summarySpan{text: " · " + label, kind: spanMuted}) + return []summarySpan{{text: " · " + label, kind: spanMuted}} } - header := glyphStyle.UnsetBackground().Render(glyph+" ") + renderSpans(spans, c.sty) - return ansi.Truncate(header, max(1, c.width), "…") + return nil } func lifecycleSuffix(state agent.ToolStatus) string { @@ -796,93 +852,31 @@ func renderActivityHeader(state agent.ToolStatus, label, suffix string, c render func renderSpans(spans []summarySpan, sty Styles) string { var b strings.Builder for _, span := range spans { - switch span.kind { - case spanAction: - b.WriteString(sty.ToolName.Render(span.text)) - case spanArgument: - b.WriteString(sty.ToolArgument.Render(span.text)) - case spanError: - b.WriteString(sty.ToolError.Render(span.text)) - default: - b.WriteString(sty.ToolDetail.Render(span.text)) - } + b.WriteString(spanStyle(span.kind, sty).Render(span.text)) } return b.String() } -// renderSummaryClusters styles a complete row of one-grapheme spans, merging -// runs of the same kind, so every continuation starts with its own color -// instead of relying on terminal state from the prior row. -func renderSummaryClusters(clusters []summarySpan, sty Styles) string { - var spans []summarySpan - var run strings.Builder - var kind spanKind - for _, cluster := range clusters { - if run.Len() > 0 && kind != cluster.kind { - spans = append(spans, summarySpan{text: run.String(), kind: kind}) - run.Reset() - } - kind = cluster.kind - run.WriteString(cluster.text) - } - if run.Len() > 0 { - spans = append(spans, summarySpan{text: run.String(), kind: kind}) +// spanSegments converts spans to segments for textwrap. +func spanSegments(spans []summarySpan, sty Styles) []textwrap.Segment { + segs := make([]textwrap.Segment, len(spans)) + for i, span := range spans { + segs[i] = textwrap.Styled(span.text, spanStyle(span.kind, sty)) } - return renderSpans(spans, sty) + return segs } -// wrapSummarySpans keeps colors attached to text while laying out rows. -func wrapSummarySpans(spans []summarySpan, width int, sty Styles) []string { - var lines []string - var row, word, space []summarySpan - rowWidth, wordWidth, spaceWidth := 0, 0, 0 - flushSpace := func() { - row = append(row, space...) - rowWidth += spaceWidth - space = nil - spaceWidth = 0 - } - flushWord := func() { - if len(word) == 0 { - return - } - flushSpace() - row = append(row, word...) - rowWidth += wordWidth - word = nil - wordWidth = 0 - } - for _, span := range spans { - for text := span.text; text != ""; { - cluster, cellWidth := ansi.FirstGraphemeCluster(text, ansi.GraphemeWidth) - text = text[len(cluster):] - r, _ := utf8.DecodeRuneInString(cluster) - item := summarySpan{text: cluster, kind: span.kind} - switch { - case unicode.IsSpace(r) && r != '\u00a0': - flushWord() - space = append(space, item) - spaceWidth += cellWidth - case cluster == "-": - flushSpace() - flushWord() - row = append(row, item) - rowWidth += cellWidth - default: - word = append(word, item) - wordWidth += cellWidth - if rowWidth+spaceWidth+wordWidth > width && wordWidth <= width { - if len(row) > 0 { - lines = append(lines, renderSummaryClusters(row, sty)) - } - row, space = nil, nil - rowWidth, spaceWidth = 0, 0 - } - } - } +func spanStyle(kind spanKind, sty Styles) lipgloss.Style { + switch kind { + case spanAction: + return sty.ToolName + case spanArgument: + return sty.ToolArgument + case spanError: + return sty.ToolError + default: + return sty.ToolDetail } - flushWord() - return append(lines, renderSummaryClusters(row, sty)) } // inspectionLifecycle is the group's activity: running or awaiting approval @@ -1021,22 +1015,18 @@ func renderInspectionChild(spans, outcome []summarySpan, first bool, width int, return []string{ansi.Truncate(line, max(1, width), "…")} } indentWidth := ansi.StringWidth(prefix) + ansi.StringWidth(action.text) + 1 - bodyWidth := width - indentWidth - if bodyWidth < 1 { + if width-indentWidth < 1 { line := sty.ToolDetail.Render(prefix) + actionRendered + sty.ToolDetail.Render(" ") + renderSpans(rest, sty) return []string{ansi.Truncate(line, max(1, width), "…")} } - wrapped := wrapSummarySpans(rest, bodyWidth, sty) - lines := make([]string, 0, len(wrapped)) - for i, row := range wrapped { - lead := strings.Repeat(" ", indentWidth) - if i == 0 { - lead = prefix - row = actionRendered + sty.ToolDetail.Render(" ") + row - } - lines = append(lines, ansi.Truncate(sty.ToolDetail.Render(lead)+row, max(1, width), "…")) - } - return lines + return textwrap.Wrap(textwrap.Options{ + Width: width, + FirstPrefix: sty.ToolDetail.Render(prefix) + actionRendered + sty.ToolDetail.Render(" "), + RestPrefix: sty.ToolDetail.Render(strings.Repeat(" ", indentWidth)), + Breakpoints: textwrap.Prose, + TrimSpace: true, + Ellipsis: "…", + }, spanSegments(rest, sty)...) } // collapseWS joins whitespace runs. @@ -1054,20 +1044,20 @@ func renderQuestionsTool(tool *agent.ToolBlock, _ toolPresentation, c renderCont header := renderActivityHeader(state, label, "", c) input, err := spec.ParseQuestions(tool.Input) if err != nil { - return header + "\n" + c.sty.ToolError.Render(wrap(escape.Multiline(tool.Output), c.width)) + return header + "\n" + textwrap.WrapProseText(c.width, escape.Multiline(tool.Output), c.sty.ToolError) } var result spec.AskUserQuestionOutput if json.Unmarshal([]byte(tool.Output), &result) == nil && result.Success { - return header + "\n" + c.sty.ToolDetail.Render(wrap(escape.Multiline(result.Message), c.width)) + return header + "\n" + textwrap.WrapProseText(c.width, escape.Multiline(result.Message), c.sty.ToolDetail) } lines := []string{header} for _, question := range input.Questions { - lines = append(lines, c.sty.ToolDetail.Render(wrap(escape.Multiline("Q: "+question.Question), c.width))) + lines = append(lines, textwrap.WrapProseText(c.width, escape.Multiline("Q: "+question.Question), c.sty.ToolDetail)) } if result.Message != "" { - lines = append(lines, c.sty.ToolDetail.Render(wrap(escape.Multiline(result.Message), c.width))) + lines = append(lines, textwrap.WrapProseText(c.width, escape.Multiline(result.Message), c.sty.ToolDetail)) } else if tool.Output != "" { - lines = append(lines, c.sty.ToolDetail.Render(wrap(escape.Multiline(tool.Output), c.width))) + lines = append(lines, textwrap.WrapProseText(c.width, escape.Multiline(tool.Output), c.sty.ToolDetail)) } return strings.Join(lines, "\n") } diff --git a/internal/tui/components/questionnaire.go b/internal/tui/components/questionnaire.go index d4e4e066..68ae1e32 100644 --- a/internal/tui/components/questionnaire.go +++ b/internal/tui/components/questionnaire.go @@ -11,6 +11,7 @@ import ( "github.com/datadog-labs/bits-cli/internal/tui/escape" "github.com/datadog-labs/bits-cli/internal/tui/styles" + "github.com/datadog-labs/bits-cli/internal/tui/textwrap" ) type Question struct { @@ -293,10 +294,14 @@ func (q *Questionnaire) rows(width int) ([]questionRow, int) { q.text.SetStyles(q.styles.TextInput) rows := []questionRow{} add := func(text string, style lipgloss.Style, choice, page int, marker string) { - text = ansi.Hardwrap(ansi.Wordwrap(escape.Multiline(text), width-2, "-"), width-2, true) - for _, line := range strings.Split(text, "\n") { - rows = append(rows, questionRow{text: marker + style.Render(line), choice: choice, page: page}) - marker = q.styles.Selector.Marker + for _, row := range textwrap.Wrap(textwrap.Options{ + Width: width, + FirstPrefix: marker, + RestPrefix: q.styles.Selector.Marker, + Breakpoints: textwrap.Prose, + TrimSpace: true, + }, textwrap.Styled(escape.Multiline(text), style)) { + rows = append(rows, questionRow{text: row, choice: choice, page: page}) } } blank := func() { rows = append(rows, questionRow{choice: -1, page: -1}) } diff --git a/internal/tui/diffrender/diffrender.go b/internal/tui/diffrender/diffrender.go index e96be21f..78e27850 100644 --- a/internal/tui/diffrender/diffrender.go +++ b/internal/tui/diffrender/diffrender.go @@ -11,6 +11,7 @@ import ( "github.com/datadog-labs/bits-cli/internal/filediff" "github.com/datadog-labs/bits-cli/internal/tui/styles" + "github.com/datadog-labs/bits-cli/internal/tui/textwrap" ) // hunkSeparator separates rendered hunks. @@ -30,6 +31,9 @@ type Options struct { MaxLines int // Tail keeps the newest lines. Tail bool + // Wrap wraps long lines under the gutter instead of cutting them. + // MaxLines still counts source lines. + Wrap bool } // Render returns styled diff text without a trailing newline. @@ -85,10 +89,22 @@ func renderLine(line filediff.DiffLine, digits int, opts Options) string { if marker != " " { separator = " " + marker + " " } - gutter := lineStyle.Foreground(opts.Style.Gutter.GetForeground()).Render(diffLeftMargin + gutterNumber(lineNumber, digits) + separator) - available := max(1, opts.Width-ansi.StringWidth(gutter)) - content := ansi.Truncate(HighlightLine(opts.Path, line.Content, opts.Style.SyntaxDark, lineStyle), available, "…") - return gutter + content + gutterStyle := lineStyle.Foreground(opts.Style.Gutter.GetForeground()) + gutter := gutterStyle.Render(diffLeftMargin + gutterNumber(lineNumber, digits) + separator) + content := highlightLine(opts.Path, line.Content, opts.Style.SyntaxDark, lineStyle) + if !opts.Wrap { + available := max(1, opts.Width-ansi.StringWidth(gutter)) + return gutter + ansi.Truncate(textwrap.Render(content...), available, "…") + } + // Continuations repeat the marker without the number, in the line style + // so added and deleted backgrounds stay continuous. + return strings.Join(textwrap.Wrap(textwrap.Options{ + Width: opts.Width, + FirstPrefix: gutter, + RestPrefix: gutterStyle.Render(diffLeftMargin + strings.Repeat(" ", digits) + separator), + Breakpoints: textwrap.Code, + KeepIndent: true, + }, content...), "\n") } func gutterNumber(n, digits int) string { diff --git a/internal/tui/diffrender/diffrender_test.go b/internal/tui/diffrender/diffrender_test.go index 546f76f0..fe386ad3 100644 --- a/internal/tui/diffrender/diffrender_test.go +++ b/internal/tui/diffrender/diffrender_test.go @@ -10,6 +10,7 @@ import ( "github.com/datadog-labs/bits-cli/internal/filediff" "github.com/datadog-labs/bits-cli/internal/tui/styles" + "github.com/datadog-labs/bits-cli/internal/tui/textwrap" ) func TestRenderShowsAddedAndDeletedContent(t *testing.T) { @@ -166,3 +167,76 @@ func TestWindowUnboundedRendersEverything(t *testing.T) { t.Fatalf("unbounded render should include first and last changes:\n%s", out) } } + +func TestRenderWrapKeepsEveryCharacterUnderTheGutter(t *testing.T) { + long := "\tx := call(alpha, beta, gamma, delta, epsilon)" + diff := filediff.Build("a/f.go", "b/f.go", "package p\n", "package p\n"+long+"\n") + out := Render(diff, Options{Path: "f.go", Width: 24, Wrap: true, Style: styles.Diff{ + Add: lipgloss.NewStyle().Background(lipgloss.Color("#123456")), + Context: lipgloss.NewStyle(), + }}) + rows := strings.Split(out, "\n") + if len(rows) < 3 { + t.Fatalf("expected the added line to wrap, got:\n%s", ansi.Strip(out)) + } + var text strings.Builder + for i, row := range rows { + if w := ansi.StringWidth(row); w > 24 { + t.Fatalf("row %d is %d cells, over 24: %q", i, w, ansi.Strip(row)) + } + plain := ansi.Strip(row) + if i == 0 { + continue // context line + } + gutter := " 2 + " + if i > 1 { + gutter = " + " + } + if !strings.HasPrefix(plain, gutter) { + t.Fatalf("row %d = %q, want gutter %q", i, plain, gutter) + } + if i > 1 && !strings.HasPrefix(plain[len(gutter):], " ") { + t.Fatalf("continuation %q lost the source indentation", plain) + } + if i > 1 && !strings.HasPrefix(row, "\x1b[48;2;18;52;86m") { + t.Fatalf("continuation %q lost the add background", row) + } + text.WriteString(plain[len(gutter):]) + } + if got, want := strings.Join(strings.Fields(text.String()), ""), strings.Join(strings.Fields(long), ""); got != want { + t.Fatalf("wrapped text = %q, want %q", got, want) + } +} + +func TestHighlightReturnsOneRowPerSourceLine(t *testing.T) { + for _, text := range []string{"", "echo a", "cat < want { rows = rows[:want] } for len(rows) < want { - rows = append(rows, "") + rows = append(rows, nil) } return rows } -func highlight(path, text string, dark bool, rowStyle lipgloss.Style, multiline bool) string { +// highlightLine styles one source line on its own. +func highlightLine(path, text string, dark bool, base lipgloss.Style) []textwrap.Segment { + return highlight(path, text, dark, base, false)[0] +} + +// highlight returns the styled tokens of text, split into rows at newlines +// when multiline is set. +func highlight(path, text string, dark bool, base lipgloss.Style, multiline bool) [][]textwrap.Segment { lexer := matchLexer(path) if lexer == nil { lexer = lexers.Fallback } + theme := chromaStyle(dark) iterator, err := lexer.Tokenise(nil, text) if err != nil { - return fallbackHighlight(text, rowStyle, multiline) - } - var b strings.Builder - if err := formatter(rowStyle, multiline).Format(&b, chromaStyle(dark), iterator); err != nil { - return fallbackHighlight(text, rowStyle, multiline) + // Unstyled by the theme, but still escaped and split. + iterator, theme = chroma.Literator(chroma.Token{Type: chroma.Text, Value: text}), nil } - return b.String() -} - -func fallbackHighlight(text string, rowStyle lipgloss.Style, multiline bool) string { - if !multiline { - return rowStyle.Render(processValue(text)) - } - rows := strings.Split(escape.Multiline(text), "\n") - for i := range rows { - rows[i] = rowStyle.Render(rows[i]) + rows := [][]textwrap.Segment{nil} + for token := iterator(); token != chroma.EOF; token = iterator() { + style := base + if theme != nil { + if colour := theme.Get(token.Type).Colour; colour.IsSet() { + style = base.Foreground(lipgloss.Color(colour.String())) + } + } + avoid := token.Type.InSubCategory(chroma.LiteralString) || + token.Type.InCategory(chroma.Comment) && !token.Type.InSubCategory(chroma.CommentPreproc) + values := []string{processValue(token.Value)} + if multiline { + values = strings.Split(escape.Multiline(token.Value), "\n") + } + for i, value := range values { + if i > 0 { + rows = append(rows, nil) + } + if value != "" { + rows[len(rows)-1] = append(rows[len(rows)-1], textwrap.Segment{Text: value, Style: style, AvoidBreak: avoid}) + } + } } - return strings.Join(rows, "\n") + return rows } func chromaStyle(dark bool) *chroma.Style { @@ -90,39 +103,6 @@ func chromaStyle(dark bool) *chroma.Style { return chromastyles.Get("github") } -// formatter styles syntax tokens, optionally preserving source line breaks. -func formatter(rowStyle lipgloss.Style, multiline bool) chroma.Formatter { - return chroma.FormatterFunc(func(w io.Writer, style *chroma.Style, iterator chroma.Iterator) error { - for token := iterator(); token != chroma.EOF; token = iterator() { - entry := style.Get(token.Type) - values := []string{processValue(token.Value)} - if multiline { - values = strings.Split(escape.Multiline(token.Value), "\n") - } - for i, value := range values { - if i > 0 { - if _, err := io.WriteString(w, "\n"); err != nil { - return err - } - } - if value == "" { - continue - } - if entry.Colour.IsSet() { - if _, err := io.WriteString(w, rowStyle.Foreground(lipgloss.Color(entry.Colour.String())).Render(value)); err != nil { - return err - } - continue - } - if _, err := io.WriteString(w, rowStyle.Render(value)); err != nil { - return err - } - } - } - return nil - }) -} - // processValue makes token text safe for one diff row. func processValue(value string) string { value = strings.TrimSuffix(value, "\n") diff --git a/internal/tui/login/model.go b/internal/tui/login/model.go index 64a45518..f3cf9e9e 100644 --- a/internal/tui/login/model.go +++ b/internal/tui/login/model.go @@ -15,7 +15,9 @@ import ( "github.com/datadog-labs/bits-cli/internal/auth" "github.com/datadog-labs/bits-cli/internal/site" "github.com/datadog-labs/bits-cli/internal/tui/components" + "github.com/datadog-labs/bits-cli/internal/tui/escape" "github.com/datadog-labs/bits-cli/internal/tui/styles" + "github.com/datadog-labs/bits-cli/internal/tui/textwrap" ) const ( @@ -430,7 +432,7 @@ func (m *Model) bodyView(width, _ int) string { ansi.Truncate(m.custom.View(), width, "…"), } if m.loginErr != nil { - parts = append(parts, "", feedback.Error.Render(ansi.Hardwrap(m.loginErr.Error(), width, false))) + parts = append(parts, "", textwrap.WrapProseText(width, escape.Multiline(m.loginErr.Error()), feedback.Error)) } return strings.Join(parts, "\n") case phaseWaiting: @@ -451,10 +453,10 @@ func (m *Model) bodyView(width, _ int) string { case phaseError: message := "Login did not complete." if m.loginErr != nil { - message = m.loginErr.Error() + message = escape.Multiline(m.loginErr.Error()) } return strings.Join([]string{ - feedback.Error.Render(ansi.Hardwrap(message, width, false)), + textwrap.WrapProseText(width, message, feedback.Error), text.Secondary.Render(strings.TrimPrefix(m.activeSite, "https://")), }, "\n") case phaseComplete: diff --git a/internal/tui/permissions.go b/internal/tui/permissions.go index 7d663d93..81099d14 100644 --- a/internal/tui/permissions.go +++ b/internal/tui/permissions.go @@ -11,6 +11,7 @@ import ( "github.com/datadog-labs/bits-cli/internal/agent" "github.com/datadog-labs/bits-cli/internal/tui/chat" "github.com/datadog-labs/bits-cli/internal/tui/components" + "github.com/datadog-labs/bits-cli/internal/tui/textwrap" ) var permissionModes = [...]agent.PermissionsMode{agent.ModeManual, agent.ModeSkipPermissions} @@ -155,7 +156,7 @@ func (m *Model) permissionsBody(width, _ int) string { var rows []string if m.permissionConfirm { - for _, line := range wordwrap(fullAccessConfirmation, width) { + for _, line := range textwrap.WrapProse(width, fullAccessConfirmation, nil) { rows = append(rows, row(style.MenuDetail, line)) } rows = append(rows, blank, choice("Yes, enable full access", m.permissionCursor == 0), blank, choice("Cancel", m.permissionCursor == 1)) @@ -186,19 +187,15 @@ func (m *Model) permissionOptionRows(label, detail string, width int, selected b if selected { labelStyle, detailStyle, marker = m.styles.Selector.Selected, m.styles.Selector.Selected, "› " } - left := labelStyle.Width(permissionLabelWidth) - var rows []string - for i, part := range wordwrap(detail, width-permissionLabelWidth-2) { - if i == 0 { - part = left.Render(marker+label) + " " + detailStyle.Render(part) - } else { - part = strings.Repeat(" ", permissionLabelWidth+2) + detailStyle.Render(part) - } - rows = append(rows, part+strings.Repeat(" ", max(0, width-ansi.StringWidth(part)))) + rows := textwrap.Wrap(textwrap.Options{ + Width: width, + FirstPrefix: labelStyle.Width(permissionLabelWidth).Render(marker+label) + " ", + RestPrefix: strings.Repeat(" ", permissionLabelWidth+2), + Breakpoints: textwrap.Prose, + TrimSpace: true, + }, textwrap.Styled(detail, detailStyle)) + for i, row := range rows { + rows[i] = row + strings.Repeat(" ", max(0, width-ansi.StringWidth(row))) } return rows } - -func wordwrap(value string, width int) []string { - return strings.Split(ansi.Wordwrap(value, max(1, width), ""), "\n") -} diff --git a/internal/tui/status/model.go b/internal/tui/status/model.go index e44071f8..0ca8fc8f 100644 --- a/internal/tui/status/model.go +++ b/internal/tui/status/model.go @@ -8,12 +8,12 @@ import ( "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" "github.com/datadog-labs/bits-cli/internal/assistant" "github.com/datadog-labs/bits-cli/internal/tui/components" "github.com/datadog-labs/bits-cli/internal/tui/escape" "github.com/datadog-labs/bits-cli/internal/tui/styles" + "github.com/datadog-labs/bits-cli/internal/tui/textwrap" workspacepkg "github.com/datadog-labs/bits-cli/internal/workspace" ) @@ -207,11 +207,13 @@ func (m Model) renderSection(title string, rows []statusRow) string { if strings.HasPrefix(value, "unavailable") || value == "not applicable" || value == "not a Git repository" || value == "collecting…" { style = m.theme.Text.Secondary } - wrapped := strings.Split(ansi.Wordwrap(value, max(1, m.bodyWidth-labelWidth), "-"), "\n") - lines = append(lines, label+style.Render(wrapped[0])) - for _, continuation := range wrapped[1:] { - lines = append(lines, strings.Repeat(" ", labelWidth)+style.Render(continuation)) - } + lines = append(lines, textwrap.Wrap(textwrap.Options{ + Width: m.bodyWidth, + FirstPrefix: label, + RestPrefix: strings.Repeat(" ", labelWidth), + Breakpoints: textwrap.Prose, + TrimSpace: true, + }, textwrap.Styled(value, style))...) } return strings.Join(lines, "\n") } diff --git a/internal/tui/textwrap/testdata/fuzz/FuzzWrap/0cee833a2922b2bd b/internal/tui/textwrap/testdata/fuzz/FuzzWrap/0cee833a2922b2bd new file mode 100644 index 00000000..61e2ac15 --- /dev/null +++ b/internal/tui/textwrap/testdata/fuzz/FuzzWrap/0cee833a2922b2bd @@ -0,0 +1,4 @@ +go test fuzz v1 +string("000 ؙ") +int(8) +bool(true) diff --git a/internal/tui/textwrap/testdata/fuzz/FuzzWrap/83eb003c9ef3e150 b/internal/tui/textwrap/testdata/fuzz/FuzzWrap/83eb003c9ef3e150 new file mode 100644 index 00000000..84ae03d5 --- /dev/null +++ b/internal/tui/textwrap/testdata/fuzz/FuzzWrap/83eb003c9ef3e150 @@ -0,0 +1,4 @@ +go test fuzz v1 +string("") +int(1) +bool(true) diff --git a/internal/tui/textwrap/testdata/fuzz/FuzzWrap/b1d8d046f4bdf60a b/internal/tui/textwrap/testdata/fuzz/FuzzWrap/b1d8d046f4bdf60a new file mode 100644 index 00000000..c3d5a3d9 --- /dev/null +++ b/internal/tui/textwrap/testdata/fuzz/FuzzWrap/b1d8d046f4bdf60a @@ -0,0 +1,4 @@ +go test fuzz v1 +string(" ✨") +int(8) +bool(true) diff --git a/internal/tui/textwrap/textwrap.go b/internal/tui/textwrap/textwrap.go new file mode 100644 index 00000000..7b1dadee --- /dev/null +++ b/internal/tui/textwrap/textwrap.go @@ -0,0 +1,445 @@ +// Package textwrap lays styled text out in rows of a fixed cell width. +// +// Text is wrapped as styled segments rather than as rendered ANSI strings, so +// styles never have to be recovered from escape sequences mid-row. For every +// row [Wrap] returns: +// +// - it is at most [Options.Width] cells wide, prefix included. A prefix +// leaving no room for text is cut to leave one cell. The only exception +// is a single grapheme wider than the room left after the prefix; +// - no text is lost: only whitespace that would fill rows alone is +// dropped, unless [Options.TrimSpace] drops it at every soft row break +// and line end or [Options.Ellipsis] asks to cut words wider than a row; +// - each segment renders with its own style on every row it reaches, so no +// row depends on terminal state left by the previous one. +// +// Width is measured in terminal cells per grapheme cluster. Tabs measure zero +// cells: expand them before wrapping. +package textwrap + +import ( + "slices" + "strings" + "unicode" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" +) + +// Style renders text in a style. lipgloss.Style satisfies it. +type Style interface { + Render(strs ...string) string +} + +// Segment is a run of text sharing one style. A nil Style renders the text +// unstyled. A "\n" in Text ends the source line. +type Segment struct { + Text string + Style Style + // AvoidBreak keeps Text on one row if it fits on one. + AvoidBreak bool +} + +// Plain returns an unstyled segment. +func Plain(text string) Segment { return Segment{Text: text} } + +// Styled returns a segment rendered with style. +func Styled(text string, style Style) Segment { return Segment{Text: text, Style: style} } + +// Options controls a layout. +type Options struct { + // Width is the cell width of every row, prefix included. Values below 1 + // are treated as 1. + Width int + // FirstPrefix starts the first row and RestPrefix every other row. Both + // are rendered as given; their cell widths are measured. + FirstPrefix, RestPrefix string + // Breakpoints lists characters after which a row may break, in addition + // to whitespace, e.g. [Prose] or [Shell]. + Breakpoints string + // KeepIndent repeats each source line's leading spaces on every row of + // that line, capped at half the room, instead of treating them as a + // word break. The indent keeps the style of the spaces it stands for. + KeepIndent bool + // TrimSpace drops whitespace at soft row breaks and ending a source line, + // for prose where it only separates words. Otherwise it wraps like text. + TrimSpace bool + // Ellipsis, if set, cuts a word wider than a whole row to one row ending + // in Ellipsis instead of breaking it across rows. Use it where a compact + // summary matters more than the full text. + Ellipsis string +} + +// Breakpoint sets for [Options.Breakpoints]. +const ( + Prose = "-" + Output = "-/" // command output: hyphens and paths + Shell = "/|;" // "&" is left out: splitting "&&" helps no one + Code = ",;/" +) + +// Wrap lays segs out in rows according to o and renders them. +func Wrap(o Options, segs ...Segment) []string { + o.Width = max(1, o.Width) + o.FirstPrefix = fitPrefix(o.FirstPrefix, o.Width) + o.RestPrefix = fitPrefix(o.RestPrefix, o.Width) + l := layout{ + firstRoom: max(1, o.Width-ansi.StringWidth(o.FirstPrefix)), + restRoom: max(1, o.Width-ansi.StringWidth(o.RestPrefix)), + ellipsis: o.Ellipsis, + } + for _, line := range splitLines(toCells(segs, o.Breakpoints)) { + l.line(line, o.KeepIndent, !o.TrimSpace) + } + out := make([]string, len(l.rows)) + for i, r := range l.rows { + prefix := o.RestPrefix + if i == 0 { + prefix = o.FirstPrefix + } + indent := strings.Repeat(" ", r.indent) + if r.indent > 0 && segs[r.indentSeg].Style != nil { + indent = segs[r.indentSeg].Style.Render(indent) + } + out[i] = prefix + indent + renderCells(r.cells, segs) + } + return out +} + +// WrapProse word-wraps text to width rows in style, which may be nil, with +// [Prose] breakpoints and [Options.TrimSpace]. +func WrapProse(width int, text string, style Style) []string { + return Wrap(Options{Width: width, Breakpoints: Prose, TrimSpace: true}, Segment{Text: text, Style: style}) +} + +// WrapProseText is [WrapProse] with the rows joined by newlines. +func WrapProseText(width int, text string, style Style) string { + return strings.Join(WrapProse(width, text, style), "\n") +} + +// fitPrefix cuts a prefix that leaves no room for text in a row of width. +func fitPrefix(prefix string, width int) string { + if ansi.StringWidth(prefix) < width { + return prefix + } + return ansi.Truncate(prefix, width-1, "") +} + +// Render renders segs on one row, each with its style, without wrapping. +func Render(segs ...Segment) string { + var b strings.Builder + for _, seg := range segs { + if seg.Style != nil { + b.WriteString(seg.Style.Render(seg.Text)) + } else { + b.WriteString(seg.Text) + } + } + return b.String() +} + +type cellKind uint8 + +const ( + cellText cellKind = iota + cellSpace // breakable whitespace, dropped at a soft break + cellBreak // text after which a row may break + cellNewline +) + +// cell is one grapheme cluster and the segment it came from. +type cell struct { + text string + width int + seg int + kind cellKind + avoid bool // Segment.AvoidBreak +} + +func toCells(segs []Segment, breakpoints string) []cell { + var cells []cell + for i, seg := range segs { + for text := seg.Text; text != ""; { + cluster, width := ansi.FirstGraphemeCluster(text, ansi.GraphemeWidth) + text = text[len(cluster):] + cells = append(cells, cell{text: cluster, width: width, seg: i, kind: classify(cluster, breakpoints), avoid: seg.AvoidBreak}) + } + } + return cells +} + +func classify(cluster, breakpoints string) cellKind { + r, size := utf8.DecodeRuneInString(cluster) + switch { + case cluster == "\n" || cluster == "\r\n": + return cellNewline + case size == len(cluster) && unicode.IsSpace(r) && r != '\u00a0': + // A space carrying combining marks is text, not a break. + return cellSpace + case breakpoints != "" && strings.Contains(breakpoints, cluster): + return cellBreak + default: + return cellText + } +} + +func splitLines(cells []cell) [][]cell { + lines := make([][]cell, 0, 1) + start := 0 + for i, c := range cells { + if c.kind == cellNewline { + lines = append(lines, cells[start:i]) + start = i + 1 + } + } + return append(lines, cells[start:]) +} + +type row struct { + indent int + indentSeg int // segment whose style the indent takes + cells []cell + width int +} + +type layout struct { + firstRoom, restRoom int + ellipsis string + indentSeg int // of the current line + rows []row +} + +// room is the text width available on the current row. +func (l *layout) room() int { + return max(1, l.prefixRoom()-l.rows[len(l.rows)-1].indent) +} + +// prefixRoom is the width after the current row's prefix, indent included. +func (l *layout) prefixRoom() int { + if len(l.rows) == 1 { + return l.firstRoom + } + return l.restRoom +} + +func (l *layout) newRow(indent int) { + l.rows = append(l.rows, row{indent: indent, indentSeg: l.indentSeg}) +} + +func (l *layout) add(cs []cell) { + r := &l.rows[len(l.rows)-1] + for _, c := range cs { + r.cells = append(r.cells, c) + r.width += c.width + } +} + +func (l *layout) line(cells []cell, keepIndent, keepSpace bool) { + indent := 0 + if keepIndent { + indent = min(leadingSpaces(cells), min(l.firstRoom, l.restRoom)/2) + if indent > 0 { + l.indentSeg = cells[0].seg + } + cells = cells[indent:] + } + l.newRow(indent) + + // Leading whitespace is content until the line's first word lands; after + // that, whitespace before a word is kept, unless the word wraps and + // keepSpace is unset. + atLineStart := true + var pending []cell + pendingWidth := 0 + + for len(cells) > 0 { + // Whitespace waits until we know where the next word goes. + if cells[0].kind == cellSpace { + pending = append(pending, cells[0]) + pendingWidth += cells[0].width + cells = cells[1:] + continue + } + + word, wordWidth := l.nextWord(cells, indent) + cells = cells[len(word):] + + if l.fits(pendingWidth + wordWidth) { + l.add(pending) + l.add(word) + } else { + if atLineStart || keepSpace { + l.addSpace(pending, indent, true) + } + // Dropped whitespace still breaks the row; kept whitespace may + // leave room for the word after it. + r := &l.rows[len(l.rows)-1] + breakRow := r.width > 0 && (!keepSpace || !l.fits(wordWidth)) + if breakRow && onlySpace(r.cells) { + // Whitespace alone on a row shows nothing: the word replaces it. + r.cells, r.width, breakRow = r.cells[:0], 0, false + } + if breakRow { + l.newRow(indent) + } else if !l.fits(wordWidth) && wordWidth <= l.restRoom-indent { + l.newRow(indent) // an empty first row narrower than the rest + } + + if l.ellipsis != "" && wordWidth > l.room() { + l.add(l.cut(word)) + } else { + l.hardAdd(word, indent) + } + } + + pending, pendingWidth, atLineStart = nil, 0, false + } + + if atLineStart || keepSpace { + // A line of only whitespace keeps it, as far as it fits. + l.addSpace(pending, indent, false) + } +} + +// addSpace adds whitespace like hardAdd, but drops the rows it would fill +// alone, which show nothing: only the last one is kept, when a word follows +// to share it. +func (l *layout) addSpace(cells []cell, indent int, wordFollows bool) { + first := len(l.rows) + l.hardAdd(cells, indent) + last := len(l.rows) + if wordFollows && last > first { + last-- + } + l.rows = slices.Delete(l.rows, first, last) +} + +// onlySpace reports whether cells hold whitespace alone. +func onlySpace(cells []cell) bool { + for _, c := range cells { + if c.kind != cellSpace { + return false + } + } + return true +} + +// leadingSpaces counts the plain spaces starting cells. +func leadingSpaces(cells []cell) int { + n := 0 + for n < len(cells) && cells[n].kind == cellSpace && cells[n].text == " " { + n++ + } + return n +} + +// fits reports whether width more cells fit on the current row. +func (l *layout) fits(width int) bool { + return l.rows[len(l.rows)-1].width+width <= l.room() +} + +// nextWord returns the word starting cells and its width. AvoidBreak text +// is a word of its own, unless it is too long for any row it can land on: +// the current row while empty, or a later one. Then it breaks like plain +// text. +func (l *layout) nextWord(cells []cell, indent int) ([]cell, int) { + limit := l.restRoom - indent + if l.rows[len(l.rows)-1].width == 0 { + limit = max(limit, l.room()) + } + limit = max(1, limit) + // Text touching an AvoidBreak run stays with it if the whole fits, so a + // shell word like --x="a b" does not read as two. + if n, width := wordEnd(cells, true, false); width <= limit { + return cells[:n], width + } + n, width := wordEnd(cells, true, true) + if width <= limit { + return cells[:n], width + } + + for i := range cells[:n] { + cells[i].avoid = false + } + // Rescan the overlong run alone, so the text after it stays protected. + n, width = wordEnd(cells[:n], false, false) + return cells[:n], width +} + +// wordEnd returns the length and width of the word starting cells: text up +// to whitespace, or through the first breakpoint. With avoid, AvoidBreak +// runs hold their whitespace and breakpoints; with split too, they are words +// of their own. +func wordEnd(cells []cell, avoid, split bool) (n, width int) { + for ; n < len(cells); n++ { + c := cells[n] + held := avoid && c.avoid + + if avoid && split && n > 0 && c.avoid != cells[n-1].avoid { + break // an AvoidBreak run starts or ends + } + if c.kind == cellSpace && !held { + break + } + + width += c.width + if c.kind == cellBreak && !held { + return n + 1, width + } + } + return n, width +} + +// cut shortens word to fit the current row, ending in the ellipsis styled +// like the last grapheme kept. +func (l *layout) cut(word []cell) []cell { + room := l.room() - ansi.StringWidth(l.ellipsis) + n, width := 0, 0 + for n < len(word)-1 && width+word[n].width <= room { + width += word[n].width + n++ + } + last := word[max(0, n-1)] + return append(word[:n:n], cell{text: l.ellipsis, width: ansi.StringWidth(l.ellipsis), seg: last.seg, kind: cellText}) +} + +// hardAdd places cells grapheme by grapheme, opening rows as needed. +func (l *layout) hardAdd(cells []cell, indent int) { + for _, c := range cells { + if r := l.rows[len(l.rows)-1]; r.width > 0 && r.width+c.width > l.room() { + l.newRow(indent) + } + if r := &l.rows[len(l.rows)-1]; r.width == 0 && c.width > l.room() { + // A grapheme wider than the room left by the indent gives up + // indentation rather than overflowing the row. + r.indent = max(0, min(r.indent, l.prefixRoom()-c.width)) + } + l.add([]cell{c}) + } +} + +// renderCells renders runs of cells from the same segment with its style. +func renderCells(cells []cell, segs []Segment) string { + var b, run strings.Builder + flush := func(seg int) { + if run.Len() == 0 { + return + } + if style := segs[seg].Style; style != nil { + b.WriteString(style.Render(run.String())) + } else { + b.WriteString(run.String()) + } + run.Reset() + } + for i, c := range cells { + if i > 0 && cells[i-1].seg != c.seg { + flush(cells[i-1].seg) + } + run.WriteString(c.text) + } + if len(cells) > 0 { + flush(cells[len(cells)-1].seg) + } + return b.String() +} diff --git a/internal/tui/textwrap/textwrap_test.go b/internal/tui/textwrap/textwrap_test.go new file mode 100644 index 00000000..a8d44850 --- /dev/null +++ b/internal/tui/textwrap/textwrap_test.go @@ -0,0 +1,406 @@ +package textwrap + +import ( + "reflect" + "slices" + "strings" + "testing" + "unicode" + "unicode/utf8" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +func plainRows(rows []string) []string { + out := make([]string, len(rows)) + for i, row := range rows { + out[i] = ansi.Strip(row) + } + return out +} + +func TestWrap(t *testing.T) { + tests := []struct { + name string + text string + o Options + want []string + }{ + { + name: "fits", + text: "go test ./...", + o: Options{Width: 20}, + want: []string{"go test ./..."}, + }, + { + name: "words keep the space at a soft break", + text: "alpha beta gamma delta", + o: Options{Width: 11}, + want: []string{"alpha beta ", "gamma delta"}, + }, + { + name: "TrimSpace drops the space at a soft break", + text: "alpha beta gamma delta", + o: Options{Width: 11, TrimSpace: true}, + want: []string{"alpha beta", "gamma delta"}, + }, + { + name: "prefixes count toward the width", + text: "alpha beta gamma", + o: Options{Width: 12, FirstPrefix: "• run ", RestPrefix: " ", TrimSpace: true}, + want: []string{"• run alpha", " beta", " gamma"}, + }, + { + name: "a prefix leaving no room is cut", + text: "ab", + o: Options{Width: 4, FirstPrefix: "• run ", RestPrefix: " "}, + want: []string{"• ra", " b"}, + }, + { + name: "first and rest rooms differ", + text: "aa bb cc dd ee", + o: Options{Width: 9, FirstPrefix: "> ", RestPrefix: ">>>>> ", TrimSpace: true}, + want: []string{"> aa bb", ">>>>> cc", ">>>>> dd", ">>>>> ee"}, + }, + { + name: "breakpoints allow breaks inside a token", + text: "internal/tui/chat/tool.go", + o: Options{Width: 14, Breakpoints: "/"}, + want: []string{"internal/tui/", "chat/tool.go"}, + }, + { + name: "without breakpoints a long token hard-wraps", + text: "echo abcdefghijkl", + o: Options{Width: 5, TrimSpace: true}, + want: []string{"echo", "abcde", "fghij", "kl"}, + }, + { + name: "newlines start rows", + text: "one\n\ntwo", + o: Options{Width: 10}, + want: []string{"one", "", "two"}, + }, + { + name: "indentation repeats on every row", + text: " print('abcdefghij')", + o: Options{Width: 16, KeepIndent: true}, + want: []string{" print('abcde", " fghij')"}, + }, + { + name: "without KeepIndent leading spaces are content", + text: " ab cd", + o: Options{Width: 5, TrimSpace: true}, + want: []string{" ab", "cd"}, + }, + { + name: "non-breaking spaces bind words", + text: "a b\u00a0c", + o: Options{Width: 3, TrimSpace: true}, + want: []string{"a", "b\u00a0c"}, + }, + { + name: "wide graphemes measure two cells", + text: "日本語 abc", + o: Options{Width: 4, TrimSpace: true}, + want: []string{"日本", "語", "abc"}, + }, + { + name: "whitespace-only line keeps its spaces", + text: " ", + o: Options{Width: 10}, + want: []string{" "}, + }, + { + name: "trailing whitespace is trimmed on demand", + text: "foo \nbar ", + o: Options{Width: 5, TrimSpace: true}, + want: []string{"foo", "bar"}, + }, + { + name: "trailing whitespace is kept", + text: "foo \nbar ", + o: Options{Width: 5}, + want: []string{"foo ", "bar "}, + }, + { + name: "whitespace at a soft break is kept", + text: "abc def ", + o: Options{Width: 6}, + want: []string{"abc ", " def "}, + }, + { + name: "whitespace is not left alone before a word that moves on", + text: "a" + strings.Repeat(" ", 15) + "bbbbbbbbb\n aaaaaaaaa bb", + o: Options{Width: 10}, + want: []string{"a ", "bbbbbbbbb", "aaaaaaaaa ", "bb"}, + }, + { + name: "leading whitespace is not left alone with TrimSpace", + text: " aaaaaaaaa bb", + o: Options{Width: 10, TrimSpace: true}, + want: []string{"aaaaaaaaa", "bb"}, + }, + { + name: "whitespace never fills a row alone", + text: "ab" + strings.Repeat(" ", 25) + "cd\n" + strings.Repeat(" ", 35), + o: Options{Width: 10}, + want: []string{"ab ", " cd", " "}, + }, + { + name: "empty text is one empty row", + text: "", + o: Options{Width: 10, FirstPrefix: "> "}, + want: []string{"> "}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := plainRows(Wrap(test.o, Plain(test.text))) + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("Wrap(%q) =\n%q\nwant\n%q", test.text, got, test.want) + } + }) + } +} + +func TestWrapOverflowingBreakpointsStayInWidth(t *testing.T) { + // ansi.Wrap lets "&&" overflow the row here; Wrap must not. + rows := plainRows(Wrap(Options{Width: 14, Breakpoints: "/|;&"}, Plain("go test ./... && go vet ./..."))) + for _, row := range rows { + if ansi.StringWidth(row) > 14 { + t.Fatalf("row %q exceeds 14 cells in %q", row, rows) + } + } + if got := strings.Join(rows, " "); !strings.Contains(got, "&&") { + t.Fatalf("operators lost: %q", rows) + } +} + +func TestWrapEllipsisCutsOverlongWords(t *testing.T) { + for _, test := range []struct { + name string + text string + width int + want []string + }{ + {"fits", "ab cd", 5, []string{"ab cd"}}, + {"own row", "x abcdefgh y", 5, []string{"x", "abcd…", "y"}}, + {"first word", "abcdefgh", 4, []string{"abc…"}}, + {"wide grapheme", "界界界 a", 5, []string{"界界…", "a"}}, + } { + t.Run(test.name, func(t *testing.T) { + got := plainRows(Wrap(Options{Width: test.width, Ellipsis: "…", TrimSpace: true}, Plain(test.text))) + if !slices.Equal(got, test.want) { + t.Fatalf("rows = %q, want %q", got, test.want) + } + }) + } +} + +func TestWrapAvoidBreak(t *testing.T) { + avoid := func(text string) Segment { return Segment{Text: text, AvoidBreak: true} } + for _, test := range []struct { + name string + segs []Segment + width int + want []string + }{ + {"moves whole", []Segment{Plain("f("), avoid(`"a b c"`), Plain(", x)")}, 8, []string{"f(", `"a b c",`, "x)"}}, + {"adjacent text wraps separately", []Segment{Plain("f("), avoid(`"123456"`), Plain(")")}, 8, []string{"f(", `"123456"`, ")"}}, + {"breakpoints suspended", []Segment{Plain("rg -e "), avoid("'a|b|c'")}, 10, []string{"rg -e", "'a|b|c'"}}, + {"too long breaks as usual", []Segment{Plain("f("), avoid(`"alpha beta gamma"`)}, 10, []string{`f("alpha`, "beta", `gamma"`}}, + {"glued to adjacent text", []Segment{Plain("echo --x="), avoid(`"a b"`)}, 12, []string{"echo", `--x="a b"`}}, + {"split from adjacent text if too long", []Segment{Plain("echo --x="), avoid(`"a b"`)}, 8, []string{"echo", "--x=", `"a b"`}}, + {"kept after an overlong word", []Segment{Plain("abcdefghijklmnop("), avoid(`"a b"`)}, 10, []string{"abcdefghij", "klmnop(", `"a b"`}}, + {"plain unaffected", []Segment{Plain(`f("a b c", x)`)}, 8, []string{`f("a b`, `c", x)`}}, + } { + t.Run(test.name, func(t *testing.T) { + got := plainRows(Wrap(Options{Width: test.width, Breakpoints: "|", TrimSpace: true}, test.segs...)) + if !slices.Equal(got, test.want) { + t.Fatalf("rows = %q, want %q", got, test.want) + } + }) + } +} + +// TestWrapAvoidBreakChecksTheRowItLandsOn: a protected run is measured +// against the row it lands on, whose room may differ from later rows'. +func TestWrapAvoidBreakChecksTheRowItLandsOn(t *testing.T) { + quoted := Segment{Text: `"abcdefg"`, AvoidBreak: true} + for _, test := range []struct { + name string + o Options + want []string + }{ + {"narrower first row", Options{Width: 10, FirstPrefix: " "}, []string{" ", `"abcdefg"`}}, + {"wider first row", Options{Width: 12, RestPrefix: " "}, []string{`"abcdefg"`}}, + } { + t.Run(test.name, func(t *testing.T) { + if got := plainRows(Wrap(test.o, quoted)); !slices.Equal(got, test.want) { + t.Fatalf("rows = %q, want %q", got, test.want) + } + }) + } +} + +func TestWrapCapsIndentation(t *testing.T) { + rows := plainRows(Wrap(Options{Width: 10, KeepIndent: true}, Plain(" deep words"))) + for _, row := range rows { + if indent := len(row) - len(strings.TrimLeft(row, " ")); indent > 5 && strings.TrimSpace(row) != "" { + t.Fatalf("indent %d exceeds half the room in %q", indent, rows) + } + } + checkProperties(t, []Segment{Plain(" deep words")}, Options{Width: 10, KeepIndent: true}) +} + +func TestWrapReappliesStylesOnEveryRow(t *testing.T) { + red := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + blue := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4")) + segs := []Segment{Styled("red words that wrap", red), Plain(" plain "), Styled("bold blue tail", blue)} + rows := Wrap(Options{Width: 8, TrimSpace: true}, segs...) + if len(rows) < 3 { + t.Fatalf("expected several rows, got %q", rows) + } + for _, row := range rows { + text := ansi.Strip(row) + styled := strings.Contains(text, "red") || strings.Contains(text, "wrap") || strings.Contains(text, "blue") || strings.Contains(text, "tail") + if styled && !strings.HasPrefix(row, "\x1b[") { + t.Fatalf("styled row %q does not open its own style", row) + } + if strings.Count(row, "\x1b[") > 0 && !strings.HasSuffix(row, ansi.ResetStyle) { + t.Fatalf("row %q leaks its style into the next", row) + } + } +} + +func TestWrapRendersSegmentsWithTheirStyles(t *testing.T) { + rows := Wrap(Options{Width: 20}, Styled("run", tag("A")), Plain(" "), Styled("ls", tag("B"))) + if want := []string{"run ls"}; !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %q, want %q", rows, want) + } + rows = Wrap(Options{Width: 6, RestPrefix: " ", TrimSpace: true}, Styled("alpha beta", tag("A"))) + if want := []string{"alpha", " beta"}; !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %q, want %q", rows, want) + } +} + +func TestWrapStylesKeptIndent(t *testing.T) { + rows := Wrap(Options{Width: 10, KeepIndent: true, TrimSpace: true}, Styled(" alpha beta", tag("A"))) + want := []string{" alpha", " beta"} + if !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %q, want %q", rows, want) + } +} + +func TestRender(t *testing.T) { + if got, want := Render(Styled("a b", tag("A")), Plain(" "), Styled("c", tag("B"))), "a b c"; got != want { + t.Fatalf("Render = %q, want %q", got, want) + } +} + +// tag is a test style that makes segment boundaries visible. +type tag string + +func (s tag) Render(strs ...string) string { + return "<" + string(s) + ">" + strings.Join(strs, " ") + "" +} + +// checkProperties asserts the package guarantees for one layout. +func checkProperties(t *testing.T, segs []Segment, o Options) { + t.Helper() + rows := Wrap(o, segs...) + width := max(1, o.Width) + first, rest := fitPrefix(o.FirstPrefix, width), fitPrefix(o.RestPrefix, width) + prefixWidth := max(ansi.StringWidth(first), ansi.StringWidth(rest)) + widest := 0 + for _, seg := range segs { + for text := seg.Text; text != ""; { + cluster, w := ansi.FirstGraphemeCluster(text, ansi.GraphemeWidth) + text = text[len(cluster):] + widest = max(widest, w) + } + } + for _, row := range rows { + if w := ansi.StringWidth(row); w > width && prefixWidth+widest <= width { + t.Fatalf("row %q is %d cells, over width %d; rows:\n%q", ansi.Strip(row), w, width, plainRows(rows)) + } + } + var in strings.Builder + for _, seg := range segs { + in.WriteString(seg.Text) + } + var out strings.Builder + for i, row := range plainRows(rows) { + prefix := ansi.Strip(rest) + if i == 0 { + prefix = ansi.Strip(first) + } + if !strings.HasPrefix(row, prefix) { + t.Fatalf("row %q lacks its prefix %q", row, prefix) + } + out.WriteString(strings.TrimPrefix(row, prefix)) + } + if got, want := nonSpace(out.String()), nonSpace(in.String()); got != want { + t.Fatalf("text not preserved:\n got %q\nwant %q", got, want) + } +} + +func nonSpace(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) && r != '\u00a0' { + return -1 + } + return r + }, s) +} + +func FuzzWrap(f *testing.F) { + seeds := []string{ + "go test ./... && go vet ./...", + "rg -n -g '*.go' -e 'a|b|c' internal/tui | sort -t: -k1,1 | head -n 40", + "python3 - <<'PY'\nif x:\n print('abcdefghij')\nPY", + "x -- y --long-flag=a-b-c-d-e-f", + "日本語のコマンド 🚀🔥✨ café-naïve", + "echo " + strings.Repeat("x", 120), + " nested spaces inside\n\n \n", + "a\u00a0b c\u00a0d", + } + for _, seed := range seeds { + for _, width := range []int{1, 3, 8, 14, 20, 33, 80} { + f.Add(seed, width, true) + f.Add(seed, width, false) + } + } + f.Fuzz(func(t *testing.T, text string, width int, keepIndent bool) { + if width < 1 || width > 200 || !isPrintable(text) { + t.Skip() + } + checkProperties(t, []Segment{Plain(text)}, Options{Width: width, KeepIndent: keepIndent, Breakpoints: "/|;-"}) + checkProperties(t, []Segment{Styled(text, lipgloss.NewStyle().Foreground(lipgloss.Color("1")))}, Options{Width: width, FirstPrefix: "✓ ran ", RestPrefix: " │ ", KeepIndent: keepIndent}) + half := len(text) / 2 + for half > 0 && half < len(text) && !utf8.RuneStart(text[half]) { + half-- + } + checkProperties(t, []Segment{Plain(text[:half]), {Text: text[half:], AvoidBreak: true}}, Options{Width: width, KeepIndent: keepIndent, Breakpoints: "/|;-"}) + for _, row := range Wrap(Options{Width: width, KeepIndent: keepIndent, Ellipsis: "…"}, Plain(text)) { + if w := ansi.StringWidth(row); w > width && width > 1 { + t.Fatalf("ellipsis row %q is %d cells, over width %d", row, w, width) + } + } + }) +} + +// isPrintable rejects control characters other than newlines; callers +// escape those before rendering. +func isPrintable(s string) bool { + for _, r := range s { + if r == '\n' { + continue + } + if r == utf8.RuneError || unicode.IsControl(r) || r == '\t' { + return false + } + } + return true +} diff --git a/internal/tui/welcome.go b/internal/tui/welcome.go index 8259f769..3124211c 100644 --- a/internal/tui/welcome.go +++ b/internal/tui/welcome.go @@ -12,6 +12,7 @@ import ( "github.com/datadog-labs/bits-cli/internal/tui/chat" "github.com/datadog-labs/bits-cli/internal/tui/escape" "github.com/datadog-labs/bits-cli/internal/tui/splash" + "github.com/datadog-labs/bits-cli/internal/tui/textwrap" "github.com/datadog-labs/bits-cli/internal/update" ) @@ -74,11 +75,8 @@ func (m *Model) splashPanelBody() string { if facts := m.welcomeFacts(width - welcomeLogoWidth - welcomeGap); facts != "" { return lipgloss.JoinHorizontal(lipgloss.Center, m.welcomeLogo(), strings.Repeat(" ", welcomeGap), facts) } - notice := wordwrap(aiNotice, width) - for i, line := range notice { - notice[i] = m.styles.Text.Secondary.Render(line) - } - return lipgloss.JoinVertical(lipgloss.Left, m.welcomeLogo(), "", strings.Join(notice, "\n")) + notice := textwrap.WrapProseText(width, aiNotice, m.styles.Text.Secondary) + return lipgloss.JoinVertical(lipgloss.Left, m.welcomeLogo(), "", notice) } func (m *Model) welcomeFacts(width int) string { @@ -110,9 +108,7 @@ func (m *Model) welcomeFacts(width int) string { lines = append(lines, m.styles.Text.Secondary.Render(value)) } lines = append(lines, "") - for _, line := range wordwrap(aiNotice, width) { - lines = append(lines, m.styles.Text.Secondary.Render(line)) - } + lines = append(lines, textwrap.WrapProse(width, aiNotice, m.styles.Text.Secondary)...) return strings.Join(lines, "\n") }