From a7f57682f42ccf65def23347256299820153d2e5 Mon Sep 17 00:00:00 2001 From: Pierre Guilleminot Date: Fri, 16 Oct 2026 22:29:55 +0200 Subject: [PATCH 1/9] feat(tui): wrap exec_command invocations when expanded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expanded view word-wraps every command line instead of truncating it, keeping syntax colors across rows. The first line hangs under the command's first column; later lines wrap behind the │ branch. Adds fakedata/exec_wrap.star for manual QA. --- fakedata/exec_wrap.star | 165 ++++++++++++++++++++++++++++++++++++++ internal/tui/chat/tool.go | 84 ++++++++++++++++--- 2 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 fakedata/exec_wrap.star diff --git a/fakedata/exec_wrap.star b/fakedata/exec_wrap.star new file mode 100644 index 00000000..17ebaa35 --- /dev/null +++ b/fakedata/exec_wrap.star @@ -0,0 +1,165 @@ +"""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. +- 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/$/ Date: Fri, 16 Oct 2026 23:26:05 +0200 Subject: [PATCH 2/9] fix(tui): keep indentation and never cut text when wrapping exec commands Leading indentation is repeated on every wrapped row, the first row wraps to its own width, and rows the word wrapper leaves too wide are hard-wrapped instead of truncated. Drop & from the shell breakpoints. Adds indentation() and operators() to fakedata/exec_wrap.star. --- fakedata/exec_wrap.star | 14 ++++- internal/tui/chat/tool.go | 104 +++++++++++++++++++++++++++++--------- 2 files changed, 94 insertions(+), 24 deletions(-) diff --git a/fakedata/exec_wrap.star b/fakedata/exec_wrap.star index 17ebaa35..e87bd31a 100644 --- a/fakedata/exec_wrap.star +++ b/fakedata/exec_wrap.star @@ -13,7 +13,8 @@ What to check: 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. + 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. @@ -131,6 +132,15 @@ 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/$/ Date: Fri, 16 Oct 2026 23:38:29 +0200 Subject: [PATCH 3/9] refactor(tui): add textwrap package for styled segment wrapping Wrap styled segments to a cell width with prefixes, breakpoints, kept indentation and optional ellipsis. Exec invocations and inspection rows now use it instead of ad-hoc ANSI wrappers. --- internal/tui/chat/tool.go | 218 +++--------- internal/tui/textwrap/ansi.go | 73 ++++ .../testdata/fuzz/FuzzWrap/0cee833a2922b2bd | 4 + .../testdata/fuzz/FuzzWrap/b1d8d046f4bdf60a | 4 + internal/tui/textwrap/textwrap.go | 299 +++++++++++++++++ internal/tui/textwrap/textwrap_test.go | 314 ++++++++++++++++++ 6 files changed, 742 insertions(+), 170 deletions(-) create mode 100644 internal/tui/textwrap/ansi.go create mode 100644 internal/tui/textwrap/testdata/fuzz/FuzzWrap/0cee833a2922b2bd create mode 100644 internal/tui/textwrap/testdata/fuzz/FuzzWrap/b1d8d046f4bdf60a create mode 100644 internal/tui/textwrap/textwrap.go create mode 100644 internal/tui/textwrap/textwrap_test.go diff --git a/internal/tui/chat/tool.go b/internal/tui/chat/tool.go index 802b20e2..9be520be 100644 --- a/internal/tui/chat/tool.go +++ b/internal/tui/chat/tool.go @@ -6,8 +6,6 @@ import ( "fmt" "strings" "time" - "unicode" - "unicode/utf8" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -20,6 +18,7 @@ import ( "github.com/datadog-labs/bits-cli/internal/tui/diffrender" "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 ( @@ -633,94 +632,39 @@ func renderExecInvocationFull(tool *agent.ToolBlock, p toolPresentation, lines [ leadWidth := statusGlyphWidth + ansi.StringWidth(action) + 1 meta := append(append(p.execMeta(), suffix...), lifecycleSpans(state)...) - out := hangWrap(lead, strings.Repeat(" ", leadWidth), leadWidth, leadWidth, lines[0]+renderSpans(meta, c.sty), c.width) - - const branchText, contText = " │ ", " │ " - branch, cont := c.sty.ToolDetail.Render(branchText), c.sty.ToolDetail.Render(contText) + out := textwrap.Wrap(append(textwrap.FromANSI(lines[0]), spanSegments(meta, c.sty)...), textwrap.Options{ + Width: c.width, + FirstPrefix: lead, + RestPrefix: strings.Repeat(" ", leadWidth), + Breakpoints: shellBreakpoints, + KeepIndent: true, + }) + branch := textwrap.Options{ + Width: c.width, + FirstPrefix: c.sty.ToolDetail.Render(" │ "), + RestPrefix: c.sty.ToolDetail.Render(" │ "), + Breakpoints: shellBreakpoints, + KeepIndent: true, + } for _, row := range lines[1:] { - out = append(out, hangWrap(branch, cont, ansi.StringWidth(branchText), ansi.StringWidth(contText), row, c.width)...) + out = append(out, textwrap.Wrap(textwrap.FromANSI(row), branch)...) } - return strings.Join(out, "\n") + return strings.Join(truncateRows(out, c.width), "\n") } // shellBreakpoints are extra word-wrap opportunities in shell source. "&" is -// left out: ansi.Wrap lets a run of breakpoints overflow the width, and -// splitting "&&" helps no one. +// left out: splitting "&&" helps no one. const shellBreakpoints = "/|;" -// hangWrap lays body out after lead without losing any of it: -// - the first row wraps to the width left after lead (leadWidth cells), the -// others to the width left after cont (contWidth cells); -// - body's leading indentation is repeated on every row instead of being -// eaten as a word break, capped at half the room so text still fits; -// - whatever the word wrapper leaves too wide is hard-wrapped, not cut. -// -// Styles are re-applied on every row. Only a width too narrow to hold a -// prefix at all truncates. -func hangWrap(lead, cont string, leadWidth, contWidth int, body string, width int) []string { - width = max(1, width) - indent := min(leadingSpaces(body), max(0, width-max(leadWidth, contWidth))/2) - body = ansi.TruncateLeft(body, indent, "") - pad := strings.Repeat(" ", indent) - firstWidth := max(1, width-leadWidth-indent) - restWidth := max(1, width-contWidth-indent) - - var rows []string - if first, rest, ok := splitFirstRow(body, firstWidth); ok { - rows = append(rows, lead+pad+first) - if rest != "" { - for _, row := range wrapLossless(rest, restWidth) { - rows = append(rows, cont+pad+row) - } - } - } else { - // Unexpected wrapper output: fall back to one width that fits every row. - for i, row := range wrapLossless(body, min(firstWidth, restWidth)) { - prefix := cont - if i == 0 { - prefix = lead - } - rows = append(rows, prefix+pad+row) - } - } +// truncateRows cuts rows wider than width, which wrapping only leaves when a +// prefix alone does not fit. +func truncateRows(rows []string, width int) []string { for i := range rows { - rows[i] = ansi.Truncate(rows[i], width, "…") + rows[i] = ansi.Truncate(rows[i], max(1, width), "…") } return rows } -// wrapLossless word-wraps s to width and hard-wraps anything still wider: -// ansi.Wrap lets trailing breakpoints such as "-" overflow. lipgloss.Wrap then -// only re-applies styles on each row. -func wrapLossless(s string, width int) []string { - s = ansi.Hardwrap(ansi.Wrap(s, width, shellBreakpoints), width, true) - return strings.Split(lipgloss.Wrap(s, width, ""), "\n") -} - -// splitFirstRow wraps s to width and returns its first row plus the styled -// remainder, so the remainder can wrap to another width. The whitespace the -// row break consumed is dropped. ok is false if the first row is not a plain -// prefix of s, which the wrappers should never produce. -func splitFirstRow(s string, width int) (first, rest string, ok bool) { - rows := wrapLossless(s, width) - if len(rows) == 1 { - return rows[0], "", true - } - plain := ansi.Strip(s) - head := strings.TrimRight(ansi.Strip(rows[0]), " ") - if head == "" || !strings.HasPrefix(plain, head) { - return "", "", false - } - skip := ansi.StringWidth(head) + leadingSpaces(plain[len(head):]) - return rows[0], ansi.TruncateLeft(s, skip, ""), true -} - -// leadingSpaces counts the spaces s starts with, ignoring escape sequences. -func leadingSpaces(s string) int { - plain := ansi.Strip(s) - return len(plain) - len(strings.TrimLeft(plain, " ")) -} - func execSuffix(result spec.ExecCommandOutput) []summarySpan { var text string switch result.Status { @@ -914,93 +858,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 @@ -1139,22 +1021,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 + rows := textwrap.Wrap(spanSegments(rest, sty), textwrap.Options{ + Width: width, + FirstPrefix: sty.ToolDetail.Render(prefix) + actionRendered + sty.ToolDetail.Render(" "), + RestPrefix: sty.ToolDetail.Render(strings.Repeat(" ", indentWidth)), + Breakpoints: "-", + Ellipsis: "…", + }) + return truncateRows(rows, width) } // collapseWS joins whitespace runs. diff --git a/internal/tui/textwrap/ansi.go b/internal/tui/textwrap/ansi.go new file mode 100644 index 00000000..1e7ac75f --- /dev/null +++ b/internal/tui/textwrap/ansi.go @@ -0,0 +1,73 @@ +package textwrap + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// FromANSI splits text already styled with SGR escape sequences, such as +// syntax-highlighter output, into segments that each carry the styling in +// effect for their text. Escape sequences other than SGR (cursor movement, +// hyperlinks, …) are dropped: they carry no width and cannot survive being +// split across rows. +func FromANSI(s string) []Segment { + var segs []Segment + var text strings.Builder + pen := "" // SGR sequences in effect since the last reset + flush := func() { + if text.Len() == 0 { + return + } + seg := Segment{Text: text.String()} + if pen != "" { + seg.Style = sgrStyle(pen) + } + segs = append(segs, seg) + text.Reset() + } + + var state byte + for s != "" { + seq, _, n, next := ansi.DecodeSequence(s, state, nil) + state = next + s = s[n:] + switch { + case !isEscape(seq): + text.WriteString(seq) + case isSGR(seq): + flush() + params := seq[2 : len(seq)-1] + switch { + case params == "" || params == "0": + pen = "" + case strings.HasPrefix(params, "0;"): + pen = seq + default: + pen += seq + } + } + } + flush() + return segs +} + +func isEscape(seq string) bool { + return seq != "" && (seq[0] == ansi.ESC || seq[0] == ansi.CSI || seq[0] == ansi.OSC || seq[0] == ansi.DCS || seq[0] == ansi.APC) +} + +// isSGR reports whether seq is a 7-bit CSI … m sequence without +// intermediates. +func isSGR(seq string) bool { + if len(seq) < 3 || !strings.HasPrefix(seq, "\x1b[") || seq[len(seq)-1] != 'm' { + return false + } + return strings.Trim(seq[2:len(seq)-1], "0123456789;:") == "" +} + +// sgrStyle replays a recorded SGR pen around text. +type sgrStyle string + +func (s sgrStyle) Render(strs ...string) string { + return string(s) + strings.Join(strs, " ") + ansi.ResetStyle +} 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/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..6343f29f --- /dev/null +++ b/internal/tui/textwrap/textwrap.go @@ -0,0 +1,299 @@ +// 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. The only +// exceptions are a prefix wider than the width and a single grapheme +// wider than the room left after the prefix; +// - no printable text is lost: only whitespace at a soft row break or at +// the end of a line is dropped, unless [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 ( + "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 +} + +// 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. "-" for prose or "/|;" for 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. + KeepIndent 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 +} + +// Wrap lays segs out in rows according to o and renders them. +func Wrap(segs []Segment, o Options) []string { + o.Width = max(1, 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) + } + out := make([]string, len(l.rows)) + for i, r := range l.rows { + prefix := o.RestPrefix + if i == 0 { + prefix = o.FirstPrefix + } + out[i] = prefix + strings.Repeat(" ", r.indent) + renderCells(r.cells, segs) + } + return out +} + +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 +} + +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)}) + } + } + 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 + continue + } + } + return append(lines, cells[start:]) +} + +type row struct { + indent int + cells []cell + width int +} + +type layout struct { + firstRoom, restRoom int + ellipsis string + 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}) } + +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 bool) { + indent := 0 + if keepIndent { + lead := 0 + for lead < len(cells) && cells[lead].kind == cellSpace && cells[lead].text == " " { + lead++ + } + indent = min(lead, min(l.firstRoom, l.restRoom)/2) + cells = cells[indent:] + } + l.newRow(indent) + + // Leading whitespace is content until the line's first word lands; after + // that, whitespace before a word is dropped if the word wraps. + atLineStart := true + var pending []cell + pendingWidth := 0 + for len(cells) > 0 { + if cells[0].kind == cellSpace { + pending = append(pending, cells[0]) + pendingWidth += cells[0].width + cells = cells[1:] + continue + } + n, wordWidth := nextWord(cells) + word := cells[:n] + cells = cells[n:] + + r := &l.rows[len(l.rows)-1] + if r.width+pendingWidth+wordWidth <= l.room() { + l.add(pending) + l.add(word) + } else { + if atLineStart { + l.hardAdd(pending, indent) // leading whitespace beyond the indent cap + } + if l.rows[len(l.rows)-1].width > 0 { + l.newRow(indent) + } + if l.ellipsis != "" && wordWidth > l.room() { + l.add(l.cut(word)) + } else { + l.hardAdd(word, indent) + } + } + pending, pendingWidth, atLineStart = nil, 0, false + } + if atLineStart { + // A line of only whitespace keeps it, as far as it fits. + l.hardAdd(pending, indent) + } +} + +// nextWord returns the length and width of the word starting cells: text up +// to whitespace, or through the first breakpoint. +func nextWord(cells []cell) (n, width int) { + for n < len(cells) && cells[n].kind != cellSpace { + width += cells[n].width + n++ + if cells[n-1].kind == cellBreak { + break + } + } + 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..bc8f4c3e --- /dev/null +++ b/internal/tui/textwrap/textwrap_test.go @@ -0,0 +1,314 @@ +package textwrap + +import ( + "reflect" + "slices" + "strings" + "testing" + "unicode" + "unicode/utf8" + + "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 drop the space at a soft break", + text: "alpha beta gamma delta", + o: Options{Width: 11}, + want: []string{"alpha beta", "gamma delta"}, + }, + { + name: "prefixes count toward the width", + text: "alpha beta gamma", + o: Options{Width: 12, FirstPrefix: "• run ", RestPrefix: " "}, + want: []string{"• run alpha", " beta", " gamma"}, + }, + { + name: "first and rest rooms differ", + text: "aa bb cc dd ee", + o: Options{Width: 9, FirstPrefix: "> ", RestPrefix: ">>>>> "}, + 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}, + 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}, + want: []string{" ab", "cd"}, + }, + { + name: "non-breaking spaces bind words", + text: "a b\u00a0c", + o: Options{Width: 3}, + want: []string{"a", "b\u00a0c"}, + }, + { + name: "wide graphemes measure two cells", + text: "日本語 abc", + o: Options{Width: 4}, + want: []string{"日本", "語", "abc"}, + }, + { + name: "whitespace-only line keeps its spaces", + text: " ", + o: Options{Width: 10}, + want: []string{" "}, + }, + { + 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([]Segment{Plain(test.text)}, test.o)) + 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([]Segment{Plain("go test ./... && go vet ./...")}, Options{Width: 14, Breakpoints: "/|;&"})) + 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([]Segment{Plain(test.text)}, Options{Width: test.width, Ellipsis: "…"})) + if !slices.Equal(got, test.want) { + t.Fatalf("rows = %q, want %q", got, test.want) + } + }) + } +} + +func TestWrapCapsIndentation(t *testing.T) { + rows := plainRows(Wrap([]Segment{Plain(" deep words")}, Options{Width: 10, KeepIndent: true})) + 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) { + segs := FromANSI("\x1b[31mred words that wrap\x1b[m plain \x1b[1;34mbold blue tail\x1b[m") + rows := Wrap(segs, Options{Width: 8}) + 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([]Segment{Styled("run", tag("A")), Plain(" "), Styled("ls", tag("B"))}, Options{Width: 20}) + if want := []string{"run ls"}; !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %q, want %q", rows, want) + } + rows = Wrap([]Segment{Styled("alpha beta", tag("A"))}, Options{Width: 6, RestPrefix: " "}) + if want := []string{"alpha", " beta"}; !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %q, want %q", rows, want) + } +} + +func TestFromANSI(t *testing.T) { + segs := FromANSI("\x1b[31mred\x1b[m \x1b[1m\x1b[34mbold blue\x1b[0m\x1b]8;;https://x\x07link\x1b]8;;\x07 \x1b[0;32mgreen\x1b[m") + type got struct { + text, style string + } + var have []got + for _, seg := range segs { + style := "" + if seg.Style != nil { + style = string(seg.Style.(sgrStyle)) + } + have = append(have, got{seg.Text, style}) + } + want := []got{ + {"red", "\x1b[31m"}, + {" ", ""}, + {"bold blue", "\x1b[1m\x1b[34m"}, + {"link ", ""}, + {"green", "\x1b[0;32m"}, + } + if !reflect.DeepEqual(have, want) { + t.Fatalf("FromANSI segments =\n%q\nwant\n%q", have, 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(segs, o) + width := max(1, o.Width) + prefixWidth := max(ansi.StringWidth(o.FirstPrefix), ansi.StringWidth(o.RestPrefix)) + 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(o.RestPrefix) + if i == 0 { + prefix = ansi.Strip(o.FirstPrefix) + } + 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, FromANSI("\x1b[31m"+text+"\x1b[m"), Options{Width: width, FirstPrefix: "✓ ran ", RestPrefix: " │ ", KeepIndent: keepIndent}) + for _, row := range Wrap([]Segment{Plain(text)}, Options{Width: width, KeepIndent: keepIndent, Ellipsis: "…"}) { + 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 +} From b6b0b80bcb17fb8524ae13c0d444e0cc0d36b53f Mon Sep 17 00:00:00 2001 From: Pierre Guilleminot Date: Fri, 16 Oct 2026 23:53:02 +0200 Subject: [PATCH 4/9] feat(tui): highlight to segments and wrap expanded diffs diffrender.Highlight returns styled segments instead of ANSI strings, and Options.Wrap wraps long diff lines under the gutter. Expanded file changes use it; compact rendering is byte-identical. --- internal/tui/chat/tool.go | 27 +++--- internal/tui/diffrender/diffrender.go | 27 +++++- internal/tui/diffrender/diffrender_test.go | 57 ++++++++++++ internal/tui/diffrender/highlight.go | 102 ++++++++------------- internal/tui/textwrap/ansi.go | 73 --------------- internal/tui/textwrap/textwrap.go | 36 ++++++-- internal/tui/textwrap/textwrap_test.go | 38 +++----- 7 files changed, 181 insertions(+), 179 deletions(-) delete mode 100644 internal/tui/textwrap/ansi.go diff --git a/internal/tui/chat/tool.go b/internal/tui/chat/tool.go index 9be520be..0d474734 100644 --- a/internal/tui/chat/tool.go +++ b/internal/tui/chat/tool.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "slices" "strings" "time" @@ -448,7 +449,7 @@ func renderChangeTool(tool *agent.ToolBlock, p toolPresentation, c renderContext // TODO: bound very large expanded diffs if rendering cost shows up. limit = 0 } - body := diffrender.Render(*diff, diffrender.Options{Path: path, Width: c.width, Style: c.sty.Diff, MaxLines: limit, Tail: true}) + body := diffrender.Render(*diff, diffrender.Options{Path: path, Width: c.width, Style: c.sty.Diff, MaxLines: limit, Tail: true, Wrap: c.disclosure == fullView}) if body != "" { lines = append(lines, body) } @@ -550,9 +551,9 @@ func renderExecTool(tool *agent.ToolBlock, p toolPresentation, c renderContext) func renderExecApproval(tool *agent.ToolBlock, p toolPresentation, width int, sty Styles) string { command := strings.TrimRight(p.argument, "\n") - rows := highlightShellCommand(command, sty) - for i, row := range rows { - rows[i] = ansi.Hardwrap(row, width, true) + var rows []string + for _, line := range highlightShellCommand(command, sty) { + rows = append(rows, ansi.Hardwrap(textwrap.Render(line), width, true)) } detail := "" @@ -565,8 +566,9 @@ func renderExecApproval(tool *agent.ToolBlock, p toolPresentation, width int, st return strings.Join(rows, "\n") } -func highlightShellCommand(command string, sty Styles) []string { - return diffrender.HighlightLines("command.sh", command, sty.Diff.SyntaxDark, sty.ToolArgument) +// highlightShellCommand returns the styled segments of each command line. +func highlightShellCommand(command string, sty Styles) [][]textwrap.Segment { + return diffrender.Highlight("command.sh", command, sty.Diff.SyntaxDark, sty.ToolArgument) } func execResultIsTerminal(status spec.ExecTerminalReason) bool { @@ -594,13 +596,16 @@ func renderExecInvocation(tool *agent.ToolBlock, p toolPresentation, suffix []su if c.disclosure == fullView { return renderExecInvocationFull(tool, p, lines, suffix, c) } - p.argument = lines[0] + p.argument = textwrap.Render(lines[0]) header := renderToolHeader(tool, p.summary(tool), suffix, c) if len(lines) == 1 { return header } - rows := lines[1:] + rows := make([]string, 0, len(lines)-1) + for _, line := range lines[1:] { + rows = append(rows, textwrap.Render(line)) + } limit := execCommandMaxLines - 1 omissionRow := -1 if len(rows) > limit && c.disclosure == compactView { @@ -625,14 +630,14 @@ func renderExecInvocation(tool *agent.ToolBlock, p toolPresentation, suffix []su // 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 []string, suffix []summarySpan, c renderContext) string { +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 := statusGlyphWidth + ansi.StringWidth(action) + 1 meta := append(append(p.execMeta(), suffix...), lifecycleSpans(state)...) - out := textwrap.Wrap(append(textwrap.FromANSI(lines[0]), spanSegments(meta, c.sty)...), textwrap.Options{ + out := textwrap.Wrap(slices.Concat(lines[0], spanSegments(meta, c.sty)), textwrap.Options{ Width: c.width, FirstPrefix: lead, RestPrefix: strings.Repeat(" ", leadWidth), @@ -647,7 +652,7 @@ func renderExecInvocationFull(tool *agent.ToolBlock, p toolPresentation, lines [ KeepIndent: true, } for _, row := range lines[1:] { - out = append(out, textwrap.Wrap(textwrap.FromANSI(row), branch)...) + out = append(out, textwrap.Wrap(row, branch)...) } return strings.Join(truncateRows(out, c.width), "\n") } diff --git a/internal/tui/diffrender/diffrender.go b/internal/tui/diffrender/diffrender.go index e96be21f..204a54bc 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,12 +89,27 @@ 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(content, textwrap.Options{ + Width: opts.Width, + FirstPrefix: gutter, + RestPrefix: gutterStyle.Render(diffLeftMargin + strings.Repeat(" ", digits) + separator), + Breakpoints: codeBreakpoints, + KeepIndent: true, + }), "\n") } +// codeBreakpoints are wrap opportunities in source besides whitespace. +const codeBreakpoints = ",;/" + func gutterNumber(n, digits int) string { if n > 0 { return fmt.Sprintf("%*d", digits, n) diff --git a/internal/tui/diffrender/diffrender_test.go b/internal/tui/diffrender/diffrender_test.go index 546f76f0..c8943ca8 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,59 @@ 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())) + } + } + 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.Styled(value, style)) + } + } } - return strings.Join(rows, "\n") + return rows } func chromaStyle(dark bool) *chroma.Style { @@ -90,39 +101,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/textwrap/ansi.go b/internal/tui/textwrap/ansi.go deleted file mode 100644 index 1e7ac75f..00000000 --- a/internal/tui/textwrap/ansi.go +++ /dev/null @@ -1,73 +0,0 @@ -package textwrap - -import ( - "strings" - - "github.com/charmbracelet/x/ansi" -) - -// FromANSI splits text already styled with SGR escape sequences, such as -// syntax-highlighter output, into segments that each carry the styling in -// effect for their text. Escape sequences other than SGR (cursor movement, -// hyperlinks, …) are dropped: they carry no width and cannot survive being -// split across rows. -func FromANSI(s string) []Segment { - var segs []Segment - var text strings.Builder - pen := "" // SGR sequences in effect since the last reset - flush := func() { - if text.Len() == 0 { - return - } - seg := Segment{Text: text.String()} - if pen != "" { - seg.Style = sgrStyle(pen) - } - segs = append(segs, seg) - text.Reset() - } - - var state byte - for s != "" { - seq, _, n, next := ansi.DecodeSequence(s, state, nil) - state = next - s = s[n:] - switch { - case !isEscape(seq): - text.WriteString(seq) - case isSGR(seq): - flush() - params := seq[2 : len(seq)-1] - switch { - case params == "" || params == "0": - pen = "" - case strings.HasPrefix(params, "0;"): - pen = seq - default: - pen += seq - } - } - } - flush() - return segs -} - -func isEscape(seq string) bool { - return seq != "" && (seq[0] == ansi.ESC || seq[0] == ansi.CSI || seq[0] == ansi.OSC || seq[0] == ansi.DCS || seq[0] == ansi.APC) -} - -// isSGR reports whether seq is a 7-bit CSI … m sequence without -// intermediates. -func isSGR(seq string) bool { - if len(seq) < 3 || !strings.HasPrefix(seq, "\x1b[") || seq[len(seq)-1] != 'm' { - return false - } - return strings.Trim(seq[2:len(seq)-1], "0123456789;:") == "" -} - -// sgrStyle replays a recorded SGR pen around text. -type sgrStyle string - -func (s sgrStyle) Render(strs ...string) string { - return string(s) + strings.Join(strs, " ") + ansi.ResetStyle -} diff --git a/internal/tui/textwrap/textwrap.go b/internal/tui/textwrap/textwrap.go index 6343f29f..15ea5d68 100644 --- a/internal/tui/textwrap/textwrap.go +++ b/internal/tui/textwrap/textwrap.go @@ -56,7 +56,7 @@ type Options struct { 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. + // word break. The indent keeps the style of the spaces it stands for. KeepIndent 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 @@ -81,11 +81,28 @@ func Wrap(segs []Segment, o Options) []string { if i == 0 { prefix = o.FirstPrefix } - out[i] = prefix + strings.Repeat(" ", r.indent) + renderCells(r.cells, segs) + 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 } +// 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 ( @@ -144,14 +161,16 @@ func splitLines(cells []cell) [][]cell { } type row struct { - indent int - cells []cell - width int + 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 } @@ -168,7 +187,9 @@ func (l *layout) prefixRoom() int { return l.restRoom } -func (l *layout) newRow(indent int) { l.rows = append(l.rows, row{indent: indent}) } +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] @@ -186,6 +207,9 @@ func (l *layout) line(cells []cell, keepIndent bool) { lead++ } indent = min(lead, min(l.firstRoom, l.restRoom)/2) + if indent > 0 { + l.indentSeg = cells[0].seg + } cells = cells[indent:] } l.newRow(indent) diff --git a/internal/tui/textwrap/textwrap_test.go b/internal/tui/textwrap/textwrap_test.go index bc8f4c3e..2425d29c 100644 --- a/internal/tui/textwrap/textwrap_test.go +++ b/internal/tui/textwrap/textwrap_test.go @@ -8,6 +8,7 @@ import ( "unicode" "unicode/utf8" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) @@ -160,7 +161,9 @@ func TestWrapCapsIndentation(t *testing.T) { } func TestWrapReappliesStylesOnEveryRow(t *testing.T) { - segs := FromANSI("\x1b[31mred words that wrap\x1b[m plain \x1b[1;34mbold blue tail\x1b[m") + 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(segs, Options{Width: 8}) if len(rows) < 3 { t.Fatalf("expected several rows, got %q", rows) @@ -188,28 +191,17 @@ func TestWrapRendersSegmentsWithTheirStyles(t *testing.T) { } } -func TestFromANSI(t *testing.T) { - segs := FromANSI("\x1b[31mred\x1b[m \x1b[1m\x1b[34mbold blue\x1b[0m\x1b]8;;https://x\x07link\x1b]8;;\x07 \x1b[0;32mgreen\x1b[m") - type got struct { - text, style string - } - var have []got - for _, seg := range segs { - style := "" - if seg.Style != nil { - style = string(seg.Style.(sgrStyle)) - } - have = append(have, got{seg.Text, style}) - } - want := []got{ - {"red", "\x1b[31m"}, - {" ", ""}, - {"bold blue", "\x1b[1m\x1b[34m"}, - {"link ", ""}, - {"green", "\x1b[0;32m"}, +func TestWrapStylesKeptIndent(t *testing.T) { + rows := Wrap([]Segment{Styled(" alpha beta", tag("A"))}, Options{Width: 10, KeepIndent: true}) + want := []string{" alpha", " beta"} + if !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %q, want %q", rows, want) } - if !reflect.DeepEqual(have, want) { - t.Fatalf("FromANSI segments =\n%q\nwant\n%q", have, want) +} + +func TestRender(t *testing.T) { + if got, want := Render([]Segment{Styled("a b", tag("A")), Plain(" "), Styled("c", tag("B"))}), "a b c"; got != want { + t.Fatalf("Render = %q, want %q", got, want) } } @@ -290,7 +282,7 @@ func FuzzWrap(f *testing.F) { t.Skip() } checkProperties(t, []Segment{Plain(text)}, Options{Width: width, KeepIndent: keepIndent, Breakpoints: "/|;-"}) - checkProperties(t, FromANSI("\x1b[31m"+text+"\x1b[m"), Options{Width: width, FirstPrefix: "✓ ran ", RestPrefix: " │ ", KeepIndent: keepIndent}) + checkProperties(t, []Segment{Styled(text, lipgloss.NewStyle().Foreground(lipgloss.Color("1")))}, Options{Width: width, FirstPrefix: "✓ ran ", RestPrefix: " │ ", KeepIndent: keepIndent}) for _, row := range Wrap([]Segment{Plain(text)}, Options{Width: width, KeepIndent: keepIndent, Ellipsis: "…"}) { if w := ansi.StringWidth(row); w > width && width > 1 { t.Fatalf("ellipsis row %q is %d cells, over width %d", row, w, width) From f68d0930ffde444204dbace3ac51a44c20daaad8 Mon Sep 17 00:00:00 2001 From: Pierre Guilleminot Date: Sat, 17 Oct 2026 00:18:29 +0200 Subject: [PATCH 5/9] feat(tui): avoid breaking strings and comments when wrapping --- internal/tui/diffrender/diffrender_test.go | 9 +++ internal/tui/diffrender/highlight.go | 5 +- .../testdata/fuzz/FuzzWrap/83eb003c9ef3e150 | 4 + internal/tui/textwrap/textwrap.go | 77 +++++++++++++++---- internal/tui/textwrap/textwrap_test.go | 28 +++++++ 5 files changed, 104 insertions(+), 19 deletions(-) create mode 100644 internal/tui/textwrap/testdata/fuzz/FuzzWrap/83eb003c9ef3e150 diff --git a/internal/tui/diffrender/diffrender_test.go b/internal/tui/diffrender/diffrender_test.go index c8943ca8..515b93e4 100644 --- a/internal/tui/diffrender/diffrender_test.go +++ b/internal/tui/diffrender/diffrender_test.go @@ -223,3 +223,12 @@ func TestHighlightReturnsOneRowPerSourceLine(t *testing.T) { } } } + +func TestRenderWrapAvoidsSplittingStringLiterals(t *testing.T) { + line := ` return fmt.Errorf("render %s", p)` + diff := filediff.Build("a/f.go", "b/f.go", "package p\n", "package p\n"+line+"\n") + out := ansi.Strip(Render(diff, Options{Path: "f.go", Width: 34, Wrap: true})) + if !strings.Contains(out, `"render %s"`) { + t.Fatalf("string literal was split:\n%s", out) + } +} diff --git a/internal/tui/diffrender/highlight.go b/internal/tui/diffrender/highlight.go index 4f06dd68..a5cce873 100644 --- a/internal/tui/diffrender/highlight.go +++ b/internal/tui/diffrender/highlight.go @@ -39,7 +39,7 @@ func matchLexer(name string) chroma.Lexer { // Highlight styles text as one lexer document, so constructs spanning lines // such as heredocs keep their state, and returns one segment slice per source // line. Text is escaped for the terminal and tabs are expanded, so the result -// can go straight to textwrap. +// can go straight to textwrap. Strings and comments avoid breaks. func Highlight(path, text string, dark bool, base lipgloss.Style) [][]textwrap.Segment { rows := highlight(path, text, dark, base, true) want := strings.Count(text, "\n") + 1 @@ -78,6 +78,7 @@ func highlight(path, text string, dark bool, base lipgloss.Style, multiline bool style = base.Foreground(lipgloss.Color(colour.String())) } } + avoid := token.Type.InSubCategory(chroma.LiteralString) || token.Type.InCategory(chroma.Comment) values := []string{processValue(token.Value)} if multiline { values = strings.Split(escape.Multiline(token.Value), "\n") @@ -87,7 +88,7 @@ func highlight(path, text string, dark bool, base lipgloss.Style, multiline bool rows = append(rows, nil) } if value != "" { - rows[len(rows)-1] = append(rows[len(rows)-1], textwrap.Styled(value, style)) + rows[len(rows)-1] = append(rows[len(rows)-1], textwrap.Segment{Text: value, Style: style, AvoidBreak: avoid}) } } } 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/textwrap.go b/internal/tui/textwrap/textwrap.go index 15ea5d68..95f249df 100644 --- a/internal/tui/textwrap/textwrap.go +++ b/internal/tui/textwrap/textwrap.go @@ -35,6 +35,8 @@ type Style interface { 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. @@ -118,6 +120,7 @@ type cell struct { width int seg int kind cellKind + avoid bool // Segment.AvoidBreak } func toCells(segs []Segment, breakpoints string) []cell { @@ -126,7 +129,7 @@ func toCells(segs []Segment, breakpoints string) []cell { 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)}) + cells = append(cells, cell{text: cluster, width: width, seg: i, kind: classify(cluster, breakpoints), avoid: seg.AvoidBreak}) } } return cells @@ -202,11 +205,7 @@ func (l *layout) add(cs []cell) { func (l *layout) line(cells []cell, keepIndent bool) { indent := 0 if keepIndent { - lead := 0 - for lead < len(cells) && cells[lead].kind == cellSpace && cells[lead].text == " " { - lead++ - } - indent = min(lead, min(l.firstRoom, l.restRoom)/2) + indent = min(leadingSpaces(cells), min(l.firstRoom, l.restRoom)/2) if indent > 0 { l.indentSeg = cells[0].seg } @@ -219,19 +218,20 @@ func (l *layout) line(cells []cell, keepIndent bool) { 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 } - n, wordWidth := nextWord(cells) - word := cells[:n] - cells = cells[n:] - r := &l.rows[len(l.rows)-1] - if r.width+pendingWidth+wordWidth <= l.room() { + word, wordWidth := l.nextWord(cells, indent) + cells = cells[len(word):] + + if l.fits(pendingWidth + wordWidth) { l.add(pending) l.add(word) } else { @@ -241,29 +241,72 @@ func (l *layout) line(cells []cell, keepIndent bool) { if l.rows[len(l.rows)-1].width > 0 { l.newRow(indent) } + if l.ellipsis != "" && wordWidth > l.room() { l.add(l.cut(word)) } else { l.hardAdd(word, indent) } } + pending, pendingWidth, atLineStart = nil, 0, false } + if atLineStart { // A line of only whitespace keeps it, as far as it fits. l.hardAdd(pending, indent) } } -// nextWord returns the length and width of the word starting cells: text up -// to whitespace, or through the first breakpoint. -func nextWord(cells []cell) (n, width int) { - for n < len(cells) && cells[n].kind != cellSpace { - width += cells[n].width +// 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++ - if cells[n-1].kind == cellBreak { + } + 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: then it breaks +// like plain text. +func (l *layout) nextWord(cells []cell, indent int) ([]cell, int) { + n, width := wordEnd(cells, true) + if width <= max(1, l.restRoom-indent) { + return cells[:n], width + } + + for i := range cells[:n] { + cells[i].avoid = false + } + n, width = wordEnd(cells, 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 are words of their own, whitespace and breakpoints included. +func wordEnd(cells []cell, avoid bool) (n, width int) { + for ; n < len(cells); n++ { + c := cells[n] + held := avoid && c.avoid + + if avoid && 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 } diff --git a/internal/tui/textwrap/textwrap_test.go b/internal/tui/textwrap/textwrap_test.go index 2425d29c..a0b65b38 100644 --- a/internal/tui/textwrap/textwrap_test.go +++ b/internal/tui/textwrap/textwrap_test.go @@ -150,6 +150,29 @@ func TestWrapEllipsisCutsOverlongWords(t *testing.T) { } } +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"`}}, + {"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(test.segs, Options{Width: test.width, Breakpoints: "|"})) + if !slices.Equal(got, test.want) { + t.Fatalf("rows = %q, want %q", got, test.want) + } + }) + } +} + func TestWrapCapsIndentation(t *testing.T) { rows := plainRows(Wrap([]Segment{Plain(" deep words")}, Options{Width: 10, KeepIndent: true})) for _, row := range rows { @@ -283,6 +306,11 @@ func FuzzWrap(f *testing.F) { } 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([]Segment{Plain(text)}, Options{Width: width, KeepIndent: keepIndent, Ellipsis: "…"}) { if w := ansi.StringWidth(row); w > width && width > 1 { t.Fatalf("ellipsis row %q is %d cells, over width %d", row, w, width) From 90b5d630d7ef0e614fabf3ce9d3f45138f500a81 Mon Sep 17 00:00:00 2001 From: Pierre Guilleminot Date: Sat, 17 Oct 2026 00:30:11 +0200 Subject: [PATCH 6/9] refactor(tui): simplify textwrap API and wrap all text with it --- internal/tui/approval.go | 14 +++++-- internal/tui/chat/list.go | 3 +- internal/tui/chat/render.go | 12 ++---- internal/tui/chat/tool.go | 44 ++++++++-------------- internal/tui/components/questionnaire.go | 12 ++++-- internal/tui/diffrender/diffrender.go | 11 ++---- internal/tui/diffrender/diffrender_test.go | 2 +- internal/tui/login/model.go | 5 ++- internal/tui/permissions.go | 24 +++++------- internal/tui/status/model.go | 13 ++++--- internal/tui/textwrap/textwrap.go | 36 +++++++++++++++--- internal/tui/textwrap/textwrap_test.go | 37 ++++++++++-------- internal/tui/welcome.go | 5 ++- 13 files changed, 120 insertions(+), 98 deletions(-) diff --git a/internal/tui/approval.go b/internal/tui/approval.go index 8f465c06..9bd63c9a 100644 --- a/internal/tui/approval.go +++ b/internal/tui/approval.go @@ -14,6 +14,7 @@ import ( "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 ( @@ -184,7 +185,7 @@ func (a *approvalPrompt) Layout(slot components.Slot) (string, bool) { Title: queue, Dismiss: "ESC x", BodyHeader: func(width int) string { - return sty.Text.Render(ansi.Wordwrap(title, width, "-")) + return prose(title, sty.Text, width) }, ScrollOffset: a.window.Offset, ScrollHint: "pgup/pgdown scroll", @@ -192,7 +193,7 @@ func (a *approvalPrompt) Layout(slot components.Slot) (string, bool) { if rendered, ok := chat.RenderToolApproval(block.Tool, width, a.chat); ok { return rendered } - return sty.Detail.Render(ansi.Wordwrap(escape.Inline(detail), width, "-")) + return prose(escape.Inline(detail), sty.Detail, width) }, BodyFooter: a.actions, BodyFooterGap: 1, @@ -200,11 +201,11 @@ func (a *approvalPrompt) Layout(slot components.Slot) (string, bool) { CompactBody: func(width int) string { // Wrapped, not truncated: a request that does not fit whole falls // back to the one-line form, which cannot be answered. - lines := []string{sty.Text.Render(ansi.Wordwrap(title, width, "-"))} + lines := []string{prose(title, sty.Text, width)} if rendered, ok := chat.RenderToolApproval(block.Tool, width, a.chat); ok { lines = append(lines, rendered) } else if detail != "" { - lines = append(lines, sty.Detail.Render(ansi.Wordwrap(escape.Inline(detail), width, "-"))) + lines = append(lines, prose(escape.Inline(detail), sty.Detail, width)) } // No blank line before actions: it already ends in the // disclaimer-or-blank row, keeping this form's height unchanged. @@ -310,3 +311,8 @@ func lowestDroppable(visible []int) (int, bool) { } return best, best != -1 } + +// prose word-wraps text to width in style. +func prose(text string, style lipgloss.Style, width int) string { + return strings.Join(textwrap.WrapProse(width, text, style), "\n") +} diff --git a/internal/tui/chat/list.go b/internal/tui/chat/list.go index 600954a2..63e0075c 100644 --- a/internal/tui/chat/list.go +++ b/internal/tui/chat/list.go @@ -13,6 +13,7 @@ import ( "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/textwrap" ) // List is a lazily-rendered, vertically-stacked view of transcript items with an @@ -904,7 +905,7 @@ func renderNotice(n Notice, width int, sty Styles) string { } // Escape untrusted text before wrapping it into selectable document rows. text := escape.Multiline(n.Text) - lines := strings.Split(wrap(text, max(1, width-3)), "\n") + lines := textwrap.WrapProse(width-3, text, nil) for i, line := range lines { prefix := " " if i == 0 { diff --git a/internal/tui/chat/render.go b/internal/tui/chat/render.go index f16d64af..37fd6d50 100644 --- a/internal/tui/chat/render.go +++ b/internal/tui/chat/render.go @@ -3,12 +3,11 @@ package chat import ( "strings" - "github.com/charmbracelet/x/ansi" - "github.com/datadog-labs/bits-cli/internal/agent" "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/textwrap" ) // itemSpacing describes outer rows owned by a rendered transcript item. The @@ -106,7 +105,7 @@ func renderUser(text string, context []assistant.ContextEntity, width int, sty S // Same blank row as the composer between the chips and the prompt. lines = append(lines, "") } - for i, ln := range strings.Split(wrap(text, max(1, inner-w)), "\n") { + for i, ln := range textwrap.WrapProse(inner-w, text, nil) { if i == 0 { lines = append(lines, sty.Input.Marker.Render(marker)+sty.Input.Text.Render(ln)) continue @@ -188,10 +187,7 @@ func renderProgress(it agent.Block, width int, sty Styles) string { return sty.Meta.Render(wrap(escape.Multiline(it.Progress.Content), width)) } -// wrap word-wraps s to width. +// wrap word-wraps plain text s to width. func wrap(s string, width int) string { - if width < 1 { - width = 1 - } - return ansi.Wordwrap(s, width, "-") + return strings.Join(textwrap.WrapProse(width, s, nil), "\n") } diff --git a/internal/tui/chat/tool.go b/internal/tui/chat/tool.go index 0d474734..038bdf95 100644 --- a/internal/tui/chat/tool.go +++ b/internal/tui/chat/tool.go @@ -553,7 +553,7 @@ func renderExecApproval(tool *agent.ToolBlock, p toolPresentation, width int, st command := strings.TrimRight(p.argument, "\n") var rows []string for _, line := range highlightShellCommand(command, sty) { - rows = append(rows, ansi.Hardwrap(textwrap.Render(line), width, true)) + rows = append(rows, textwrap.Wrap(textwrap.Options{Width: width, Breakpoints: textwrap.Shell, KeepIndent: true}, line...)...) } detail := "" @@ -561,7 +561,7 @@ func renderExecApproval(tool *agent.ToolBlock, p toolPresentation, width int, st detail = escape.Inline(tool.Approval.Detail) } if detail != "" { - rows = append(rows, sty.ToolDetail.Render(ansi.Wordwrap(detail, width, "-"))) + rows = append(rows, textwrap.WrapProse(width, detail, sty.ToolDetail)...) } return strings.Join(rows, "\n") } @@ -596,7 +596,7 @@ func renderExecInvocation(tool *agent.ToolBlock, p toolPresentation, suffix []su if c.disclosure == fullView { return renderExecInvocationFull(tool, p, lines, suffix, c) } - p.argument = textwrap.Render(lines[0]) + p.argument = textwrap.Render(lines[0]...) header := renderToolHeader(tool, p.summary(tool), suffix, c) if len(lines) == 1 { return header @@ -604,7 +604,7 @@ func renderExecInvocation(tool *agent.ToolBlock, p toolPresentation, suffix []su rows := make([]string, 0, len(lines)-1) for _, line := range lines[1:] { - rows = append(rows, textwrap.Render(line)) + rows = append(rows, textwrap.Render(line...)) } limit := execCommandMaxLines - 1 omissionRow := -1 @@ -637,37 +637,24 @@ func renderExecInvocationFull(tool *agent.ToolBlock, p toolPresentation, lines [ leadWidth := statusGlyphWidth + ansi.StringWidth(action) + 1 meta := append(append(p.execMeta(), suffix...), lifecycleSpans(state)...) - out := textwrap.Wrap(slices.Concat(lines[0], spanSegments(meta, c.sty)), textwrap.Options{ + out := textwrap.Wrap(textwrap.Options{ Width: c.width, FirstPrefix: lead, RestPrefix: strings.Repeat(" ", leadWidth), - Breakpoints: shellBreakpoints, + 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: shellBreakpoints, + Breakpoints: textwrap.Shell, KeepIndent: true, } for _, row := range lines[1:] { - out = append(out, textwrap.Wrap(row, branch)...) + out = append(out, textwrap.Wrap(branch, row...)...) } - return strings.Join(truncateRows(out, c.width), "\n") -} - -// shellBreakpoints are extra word-wrap opportunities in shell source. "&" is -// left out: splitting "&&" helps no one. -const shellBreakpoints = "/|;" - -// truncateRows cuts rows wider than width, which wrapping only leaves when a -// prefix alone does not fit. -func truncateRows(rows []string, width int) []string { - for i := range rows { - rows[i] = ansi.Truncate(rows[i], max(1, width), "…") - } - return rows + return strings.Join(out, "\n") } func execSuffix(result spec.ExecCommandOutput) []summarySpan { @@ -734,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 { @@ -1030,14 +1019,13 @@ func renderInspectionChild(spans, outcome []summarySpan, first bool, width int, line := sty.ToolDetail.Render(prefix) + actionRendered + sty.ToolDetail.Render(" ") + renderSpans(rest, sty) return []string{ansi.Truncate(line, max(1, width), "…")} } - rows := textwrap.Wrap(spanSegments(rest, sty), textwrap.Options{ + return textwrap.Wrap(textwrap.Options{ Width: width, FirstPrefix: sty.ToolDetail.Render(prefix) + actionRendered + sty.ToolDetail.Render(" "), RestPrefix: sty.ToolDetail.Render(strings.Repeat(" ", indentWidth)), - Breakpoints: "-", + Breakpoints: textwrap.Prose, Ellipsis: "…", - }) - return truncateRows(rows, width) + }, spanSegments(rest, sty)...) } // collapseWS joins whitespace runs. diff --git a/internal/tui/components/questionnaire.go b/internal/tui/components/questionnaire.go index d4e4e066..be936f61 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,13 @@ 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, + }, 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 204a54bc..78e27850 100644 --- a/internal/tui/diffrender/diffrender.go +++ b/internal/tui/diffrender/diffrender.go @@ -94,22 +94,19 @@ func renderLine(line filediff.DiffLine, digits int, opts Options) string { 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, "…") + 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(content, textwrap.Options{ + return strings.Join(textwrap.Wrap(textwrap.Options{ Width: opts.Width, FirstPrefix: gutter, RestPrefix: gutterStyle.Render(diffLeftMargin + strings.Repeat(" ", digits) + separator), - Breakpoints: codeBreakpoints, + Breakpoints: textwrap.Code, KeepIndent: true, - }), "\n") + }, content...), "\n") } -// codeBreakpoints are wrap opportunities in source besides whitespace. -const codeBreakpoints = ",;/" - func gutterNumber(n, digits int) string { if n > 0 { return fmt.Sprintf("%*d", digits, n) diff --git a/internal/tui/diffrender/diffrender_test.go b/internal/tui/diffrender/diffrender_test.go index 515b93e4..4dc224a8 100644 --- a/internal/tui/diffrender/diffrender_test.go +++ b/internal/tui/diffrender/diffrender_test.go @@ -216,7 +216,7 @@ func TestHighlightReturnsOneRowPerSourceLine(t *testing.T) { } var lines []string for _, row := range rows { - lines = append(lines, ansi.Strip(textwrap.Render(row))) + lines = append(lines, ansi.Strip(textwrap.Render(row...))) } if got := strings.Join(lines, "\n"); got != text { t.Fatalf("Highlight(%q) text = %q", text, got) diff --git a/internal/tui/login/model.go b/internal/tui/login/model.go index 64a45518..5fa15a0f 100644 --- a/internal/tui/login/model.go +++ b/internal/tui/login/model.go @@ -16,6 +16,7 @@ import ( "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/styles" + "github.com/datadog-labs/bits-cli/internal/tui/textwrap" ) const ( @@ -430,7 +431,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, "", strings.Join(textwrap.WrapProse(width, m.loginErr.Error(), feedback.Error), "\n")) } return strings.Join(parts, "\n") case phaseWaiting: @@ -454,7 +455,7 @@ func (m *Model) bodyView(width, _ int) string { message = m.loginErr.Error() } return strings.Join([]string{ - feedback.Error.Render(ansi.Hardwrap(message, width, false)), + strings.Join(textwrap.WrapProse(width, message, feedback.Error), "\n"), 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..7d4e97fd 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,14 @@ 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, + }, 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..869f2eb9 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,12 @@ 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, + }, textwrap.Styled(value, style))...) } return strings.Join(lines, "\n") } diff --git a/internal/tui/textwrap/textwrap.go b/internal/tui/textwrap/textwrap.go index 95f249df..dc2a5f0a 100644 --- a/internal/tui/textwrap/textwrap.go +++ b/internal/tui/textwrap/textwrap.go @@ -4,9 +4,9 @@ // 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. The only -// exceptions are a prefix wider than the width and a single grapheme -// wider than the room left after the prefix; +// - 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 printable text is lost: only whitespace at a soft row break or at // the end of a line is dropped, unless [Options.Ellipsis] asks to cut // words wider than a row; @@ -54,7 +54,7 @@ type Options struct { // 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. "-" for prose or "/|;" for shell. + // 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 @@ -66,9 +66,19 @@ type Options struct { 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(segs []Segment, o Options) []string { +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)), @@ -92,8 +102,22 @@ func Wrap(segs []Segment, o Options) []string { return out } +// WrapProse word-wraps text to width rows in style, which may be nil, with +// [Prose] breakpoints. +func WrapProse(width int, text string, style Style) []string { + return Wrap(Options{Width: width, Breakpoints: Prose}, Segment{Text: text, Style: style}) +} + +// 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 { +func Render(segs ...Segment) string { var b strings.Builder for _, seg := range segs { if seg.Style != nil { diff --git a/internal/tui/textwrap/textwrap_test.go b/internal/tui/textwrap/textwrap_test.go index a0b65b38..221ffcdb 100644 --- a/internal/tui/textwrap/textwrap_test.go +++ b/internal/tui/textwrap/textwrap_test.go @@ -45,6 +45,12 @@ func TestWrap(t *testing.T) { o: Options{Width: 12, FirstPrefix: "• run ", RestPrefix: " "}, 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", @@ -108,7 +114,7 @@ func TestWrap(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - got := plainRows(Wrap([]Segment{Plain(test.text)}, test.o)) + 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) } @@ -118,7 +124,7 @@ func TestWrap(t *testing.T) { func TestWrapOverflowingBreakpointsStayInWidth(t *testing.T) { // ansi.Wrap lets "&&" overflow the row here; Wrap must not. - rows := plainRows(Wrap([]Segment{Plain("go test ./... && go vet ./...")}, Options{Width: 14, Breakpoints: "/|;&"})) + 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) @@ -142,7 +148,7 @@ func TestWrapEllipsisCutsOverlongWords(t *testing.T) { {"wide grapheme", "界界界 a", 5, []string{"界界…", "a"}}, } { t.Run(test.name, func(t *testing.T) { - got := plainRows(Wrap([]Segment{Plain(test.text)}, Options{Width: test.width, Ellipsis: "…"})) + got := plainRows(Wrap(Options{Width: test.width, Ellipsis: "…"}, Plain(test.text))) if !slices.Equal(got, test.want) { t.Fatalf("rows = %q, want %q", got, test.want) } @@ -165,7 +171,7 @@ func TestWrapAvoidBreak(t *testing.T) { {"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(test.segs, Options{Width: test.width, Breakpoints: "|"})) + got := plainRows(Wrap(Options{Width: test.width, Breakpoints: "|"}, test.segs...)) if !slices.Equal(got, test.want) { t.Fatalf("rows = %q, want %q", got, test.want) } @@ -174,7 +180,7 @@ func TestWrapAvoidBreak(t *testing.T) { } func TestWrapCapsIndentation(t *testing.T) { - rows := plainRows(Wrap([]Segment{Plain(" deep words")}, Options{Width: 10, KeepIndent: true})) + 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) @@ -187,7 +193,7 @@ 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(segs, Options{Width: 8}) + rows := Wrap(Options{Width: 8}, segs...) if len(rows) < 3 { t.Fatalf("expected several rows, got %q", rows) } @@ -204,18 +210,18 @@ func TestWrapReappliesStylesOnEveryRow(t *testing.T) { } func TestWrapRendersSegmentsWithTheirStyles(t *testing.T) { - rows := Wrap([]Segment{Styled("run", tag("A")), Plain(" "), Styled("ls", tag("B"))}, Options{Width: 20}) + 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([]Segment{Styled("alpha beta", tag("A"))}, Options{Width: 6, RestPrefix: " "}) + rows = Wrap(Options{Width: 6, RestPrefix: " "}, 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([]Segment{Styled(" alpha beta", tag("A"))}, Options{Width: 10, KeepIndent: true}) + rows := Wrap(Options{Width: 10, KeepIndent: true}, Styled(" alpha beta", tag("A"))) want := []string{" alpha", " beta"} if !reflect.DeepEqual(rows, want) { t.Fatalf("rows = %q, want %q", rows, want) @@ -223,7 +229,7 @@ func TestWrapStylesKeptIndent(t *testing.T) { } func TestRender(t *testing.T) { - if got, want := Render([]Segment{Styled("a b", tag("A")), Plain(" "), Styled("c", tag("B"))}), "a b c"; got != want { + 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) } } @@ -238,9 +244,10 @@ func (s tag) Render(strs ...string) string { // checkProperties asserts the package guarantees for one layout. func checkProperties(t *testing.T, segs []Segment, o Options) { t.Helper() - rows := Wrap(segs, o) + rows := Wrap(o, segs...) width := max(1, o.Width) - prefixWidth := max(ansi.StringWidth(o.FirstPrefix), ansi.StringWidth(o.RestPrefix)) + 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 != ""; { @@ -260,9 +267,9 @@ func checkProperties(t *testing.T, segs []Segment, o Options) { } var out strings.Builder for i, row := range plainRows(rows) { - prefix := ansi.Strip(o.RestPrefix) + prefix := ansi.Strip(rest) if i == 0 { - prefix = ansi.Strip(o.FirstPrefix) + prefix = ansi.Strip(first) } if !strings.HasPrefix(row, prefix) { t.Fatalf("row %q lacks its prefix %q", row, prefix) @@ -311,7 +318,7 @@ func FuzzWrap(f *testing.F) { half-- } checkProperties(t, []Segment{Plain(text[:half]), {Text: text[half:], AvoidBreak: true}}, Options{Width: width, KeepIndent: keepIndent, Breakpoints: "/|;-"}) - for _, row := range Wrap([]Segment{Plain(text)}, Options{Width: width, KeepIndent: keepIndent, Ellipsis: "…"}) { + 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) } diff --git a/internal/tui/welcome.go b/internal/tui/welcome.go index 8259f769..5cb2df2e 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,7 +75,7 @@ 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) + notice := textwrap.WrapProse(width, aiNotice, nil) for i, line := range notice { notice[i] = m.styles.Text.Secondary.Render(line) } @@ -110,7 +111,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) { + for _, line := range textwrap.WrapProse(width, aiNotice, nil) { lines = append(lines, m.styles.Text.Secondary.Render(line)) } return strings.Join(lines, "\n") From d083f71d92ce43f592dec83775bb6d172025db19 Mon Sep 17 00:00:00 2001 From: Pierre Guilleminot Date: Sat, 17 Oct 2026 00:47:04 +0200 Subject: [PATCH 7/9] fix(tui): keep trailing whitespace in wrapped diffs --- internal/tui/diffrender/diffrender.go | 11 ++++++----- internal/tui/diffrender/diffrender_test.go | 8 ++++++++ internal/tui/textwrap/textwrap.go | 13 ++++++++----- internal/tui/textwrap/textwrap_test.go | 12 ++++++++++++ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/internal/tui/diffrender/diffrender.go b/internal/tui/diffrender/diffrender.go index 78e27850..6ac55a70 100644 --- a/internal/tui/diffrender/diffrender.go +++ b/internal/tui/diffrender/diffrender.go @@ -99,11 +99,12 @@ func renderLine(line filediff.DiffLine, digits int, opts Options) string { // 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, + Width: opts.Width, + FirstPrefix: gutter, + RestPrefix: gutterStyle.Render(diffLeftMargin + strings.Repeat(" ", digits) + separator), + Breakpoints: textwrap.Code, + KeepIndent: true, + KeepTrailingSpace: true, }, content...), "\n") } diff --git a/internal/tui/diffrender/diffrender_test.go b/internal/tui/diffrender/diffrender_test.go index 4dc224a8..fe386ad3 100644 --- a/internal/tui/diffrender/diffrender_test.go +++ b/internal/tui/diffrender/diffrender_test.go @@ -232,3 +232,11 @@ func TestRenderWrapAvoidsSplittingStringLiterals(t *testing.T) { t.Fatalf("string literal was split:\n%s", out) } } + +func TestRenderWrapKeepsTrailingWhitespace(t *testing.T) { + diff := filediff.Build("a/f.go", "b/f.go", "x := 1\n", "x := 1 \n") + out := ansi.Strip(Render(diff, Options{Path: "f.go", Width: 40, Wrap: true})) + if !strings.Contains(out, "x := 1 ") { + t.Fatalf("trailing whitespace was dropped:\n%q", out) + } +} diff --git a/internal/tui/textwrap/textwrap.go b/internal/tui/textwrap/textwrap.go index dc2a5f0a..dcd6ebe0 100644 --- a/internal/tui/textwrap/textwrap.go +++ b/internal/tui/textwrap/textwrap.go @@ -8,8 +8,8 @@ // 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 printable text is lost: only whitespace at a soft row break or at -// the end of a line is dropped, unless [Options.Ellipsis] asks to cut -// words wider than a row; +// the end of a line is dropped, unless [Options.KeepTrailingSpace] keeps +// the latter 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. // @@ -60,6 +60,9 @@ type Options struct { // 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 + // KeepTrailingSpace keeps whitespace ending a source line, wrapping it + // like text, where it is content, e.g. in a diff. + KeepTrailingSpace 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. @@ -85,7 +88,7 @@ func Wrap(o Options, segs ...Segment) []string { ellipsis: o.Ellipsis, } for _, line := range splitLines(toCells(segs, o.Breakpoints)) { - l.line(line, o.KeepIndent) + l.line(line, o.KeepIndent, o.KeepTrailingSpace) } out := make([]string, len(l.rows)) for i, r := range l.rows { @@ -226,7 +229,7 @@ func (l *layout) add(cs []cell) { } } -func (l *layout) line(cells []cell, keepIndent bool) { +func (l *layout) line(cells []cell, keepIndent, keepTrailing bool) { indent := 0 if keepIndent { indent = min(leadingSpaces(cells), min(l.firstRoom, l.restRoom)/2) @@ -276,7 +279,7 @@ func (l *layout) line(cells []cell, keepIndent bool) { pending, pendingWidth, atLineStart = nil, 0, false } - if atLineStart { + if atLineStart || keepTrailing { // A line of only whitespace keeps it, as far as it fits. l.hardAdd(pending, indent) } diff --git a/internal/tui/textwrap/textwrap_test.go b/internal/tui/textwrap/textwrap_test.go index 221ffcdb..c0cedfab 100644 --- a/internal/tui/textwrap/textwrap_test.go +++ b/internal/tui/textwrap/textwrap_test.go @@ -105,6 +105,18 @@ func TestWrap(t *testing.T) { o: Options{Width: 10}, want: []string{" "}, }, + { + name: "trailing whitespace is dropped", + text: "foo \nbar ", + o: Options{Width: 5}, + want: []string{"foo", "bar"}, + }, + { + name: "trailing whitespace is kept on demand", + text: "foo \nbar ", + o: Options{Width: 5, KeepTrailingSpace: true}, + want: []string{"foo ", "bar ", " "}, + }, { name: "empty text is one empty row", text: "", From 3033d2e3eb44cf1d68235873d5d4a24c3ab51e37 Mon Sep 17 00:00:00 2001 From: Pierre Guilleminot Date: Mon, 19 Oct 2026 10:11:58 +0200 Subject: [PATCH 8/9] fix(tui): keep protected text intact after an overlong word --- internal/tui/textwrap/textwrap.go | 3 ++- internal/tui/textwrap/textwrap_test.go | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/tui/textwrap/textwrap.go b/internal/tui/textwrap/textwrap.go index dcd6ebe0..2a905fe1 100644 --- a/internal/tui/textwrap/textwrap.go +++ b/internal/tui/textwrap/textwrap.go @@ -311,7 +311,8 @@ func (l *layout) nextWord(cells []cell, indent int) ([]cell, int) { for i := range cells[:n] { cells[i].avoid = false } - n, width = wordEnd(cells, false) + // Rescan the overlong run alone, so the text after it stays protected. + n, width = wordEnd(cells[:n], false) return cells[:n], width } diff --git a/internal/tui/textwrap/textwrap_test.go b/internal/tui/textwrap/textwrap_test.go index c0cedfab..3a296b83 100644 --- a/internal/tui/textwrap/textwrap_test.go +++ b/internal/tui/textwrap/textwrap_test.go @@ -180,6 +180,7 @@ func TestWrapAvoidBreak(t *testing.T) { {"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"`}}, + {"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) { From a6b6b617a1062a2ee795170cb4c19fa4ac90d8b8 Mon Sep 17 00:00:00 2001 From: Pierre Guilleminot Date: Mon, 19 Oct 2026 10:45:09 +0200 Subject: [PATCH 9/9] fix(tui): keep whitespace in verbatim text and work on edge cases Keeping whitespace by default when wrapping, so text shown as written (diffs, exec commands and output, the approval prompt) no longer loses spaces at soft breaks. Prose opts out with the new TrimSpace option which replaces KeepTrailingSpace. --- internal/tui/approval.go | 13 +-- internal/tui/chat/markdown.go | 5 +- internal/tui/chat/render.go | 13 +-- internal/tui/chat/tool.go | 17 ++-- internal/tui/components/questionnaire.go | 1 + internal/tui/diffrender/diffrender.go | 11 ++- internal/tui/diffrender/highlight.go | 3 +- internal/tui/login/model.go | 7 +- internal/tui/permissions.go | 1 + internal/tui/status/model.go | 1 + internal/tui/textwrap/textwrap.go | 101 +++++++++++++++++------ internal/tui/textwrap/textwrap_test.go | 86 +++++++++++++++---- internal/tui/welcome.go | 11 +-- 13 files changed, 182 insertions(+), 88 deletions(-) diff --git a/internal/tui/approval.go b/internal/tui/approval.go index 9bd63c9a..387f7140 100644 --- a/internal/tui/approval.go +++ b/internal/tui/approval.go @@ -185,7 +185,7 @@ func (a *approvalPrompt) Layout(slot components.Slot) (string, bool) { Title: queue, Dismiss: "ESC x", BodyHeader: func(width int) string { - return prose(title, sty.Text, width) + return textwrap.WrapProseText(width, title, sty.Text) }, ScrollOffset: a.window.Offset, ScrollHint: "pgup/pgdown scroll", @@ -193,7 +193,7 @@ func (a *approvalPrompt) Layout(slot components.Slot) (string, bool) { if rendered, ok := chat.RenderToolApproval(block.Tool, width, a.chat); ok { return rendered } - return prose(escape.Inline(detail), sty.Detail, width) + return textwrap.WrapProseText(width, escape.Inline(detail), sty.Detail) }, BodyFooter: a.actions, BodyFooterGap: 1, @@ -201,11 +201,11 @@ func (a *approvalPrompt) Layout(slot components.Slot) (string, bool) { CompactBody: func(width int) string { // Wrapped, not truncated: a request that does not fit whole falls // back to the one-line form, which cannot be answered. - lines := []string{prose(title, sty.Text, width)} + lines := []string{textwrap.WrapProseText(width, title, sty.Text)} if rendered, ok := chat.RenderToolApproval(block.Tool, width, a.chat); ok { lines = append(lines, rendered) } else if detail != "" { - lines = append(lines, prose(escape.Inline(detail), sty.Detail, width)) + lines = append(lines, textwrap.WrapProseText(width, escape.Inline(detail), sty.Detail)) } // No blank line before actions: it already ends in the // disclaimer-or-blank row, keeping this form's height unchanged. @@ -311,8 +311,3 @@ func lowestDroppable(visible []int) (int, bool) { } return best, best != -1 } - -// prose word-wraps text to width in style. -func prose(text string, style lipgloss.Style, width int) string { - return strings.Join(textwrap.WrapProse(width, text, style), "\n") -} diff --git a/internal/tui/chat/markdown.go b/internal/tui/chat/markdown.go index 8aebdf8c..ca04f82a 100644 --- a/internal/tui/chat/markdown.go +++ b/internal/tui/chat/markdown.go @@ -20,6 +20,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" ) // Streaming keeps settled top-level blocks rendered and reparses only the tail. @@ -182,14 +183,14 @@ func (r *markdownRenderer) Render(src string, width int, style ansi.StyleConfig) r.prefix = markdownPrefix{} } if r.term == nil { - return wrap(escape.Multiline(src), width) + return textwrap.WrapProseText(width, escape.Multiline(src), nil) } out, err := r.renderStreamed(src) if err != nil { r.term = nil r.ready = false r.prefix = markdownPrefix{} - return wrap(escape.Multiline(src), width) + return textwrap.WrapProseText(width, escape.Multiline(src), nil) } return strings.Trim(out, "\n") } diff --git a/internal/tui/chat/render.go b/internal/tui/chat/render.go index 37fd6d50..af4c9112 100644 --- a/internal/tui/chat/render.go +++ b/internal/tui/chat/render.go @@ -71,7 +71,7 @@ func (r *blockRenderer) RenderBlock(it agent.Block, width int, sty Styles, frame // fallback renders an unsupported block kind. func fallback(it agent.Block, width int, sty Styles) string { - return sty.Meta.Render(wrap("["+it.Kind.String()+"]", width)) + return textwrap.WrapProseText(width, "["+it.Kind.String()+"]", sty.Meta) } func (r *blockRenderer) renderText(it agent.Block, width int, sty Styles) string { @@ -167,7 +167,7 @@ func renderWidget(it agent.Block, width int, sty Styles) string { if it.Widget != nil && it.Widget.Title != "" { label = "widget: " + escape.Inline(it.Widget.Title) } - return sty.Meta.Render(wrap("["+label+"]", width)) + return textwrap.WrapProseText(width, "["+label+"]", sty.Meta) } // renderDashboard summarizes a dashboard. @@ -176,7 +176,7 @@ func renderDashboard(it agent.Block, width int, sty Styles) string { if it.Dashboard != nil && it.Dashboard.Title != "" { label = "dashboard: " + escape.Inline(it.Dashboard.Title) } - return sty.Meta.Render(wrap("["+label+"]", width)) + return textwrap.WrapProseText(width, "["+label+"]", sty.Meta) } // renderProgress renders dimmed progress text. @@ -184,10 +184,5 @@ func renderProgress(it agent.Block, width int, sty Styles) string { if it.Progress == nil { return fallback(it, width, sty) } - return sty.Meta.Render(wrap(escape.Multiline(it.Progress.Content), width)) -} - -// wrap word-wraps plain text s to width. -func wrap(s string, width int) string { - return strings.Join(textwrap.WrapProse(width, s, nil), "\n") + return textwrap.WrapProseText(width, escape.Multiline(it.Progress.Content), sty.Meta) } diff --git a/internal/tui/chat/tool.go b/internal/tui/chat/tool.go index 038bdf95..cbdc9575 100644 --- a/internal/tui/chat/tool.go +++ b/internal/tui/chat/tool.go @@ -553,7 +553,7 @@ func renderExecApproval(tool *agent.ToolBlock, p toolPresentation, width int, st command := strings.TrimRight(p.argument, "\n") var rows []string for _, line := range highlightShellCommand(command, sty) { - rows = append(rows, textwrap.Wrap(textwrap.Options{Width: width, Breakpoints: textwrap.Shell, KeepIndent: true}, line...)...) + rows = append(rows, textwrap.Wrap(textwrap.Options{Width: width, RestPrefix: " ", Breakpoints: textwrap.Shell, KeepIndent: true}, line...)...) } detail := "" @@ -608,7 +608,7 @@ func renderExecInvocation(tool *agent.ToolBlock, p toolPresentation, suffix []su } limit := execCommandMaxLines - 1 omissionRow := -1 - if len(rows) > limit && c.disclosure == compactView { + if len(rows) > limit { omissionRow = min(2, limit-1) rows = middleClamp(rows, limit) } @@ -634,7 +634,7 @@ func renderExecInvocationFull(tool *agent.ToolBlock, p toolPresentation, lines [ state := statusOf(tool) action := p.actionLabel(state) lead := headerGlyph(state, c) + c.sty.ToolName.Render(action) + c.sty.ToolDetail.Render(" ") - leadWidth := statusGlyphWidth + ansi.StringWidth(action) + 1 + leadWidth := ansi.StringWidth(lead) meta := append(append(p.execMeta(), suffix...), lifecycleSpans(state)...) out := textwrap.Wrap(textwrap.Options{ @@ -1024,6 +1024,7 @@ func renderInspectionChild(spans, outcome []summarySpan, first bool, width int, 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)...) } @@ -1043,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 be936f61..68ae1e32 100644 --- a/internal/tui/components/questionnaire.go +++ b/internal/tui/components/questionnaire.go @@ -299,6 +299,7 @@ func (q *Questionnaire) rows(width int) ([]questionRow, int) { 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}) } diff --git a/internal/tui/diffrender/diffrender.go b/internal/tui/diffrender/diffrender.go index 6ac55a70..78e27850 100644 --- a/internal/tui/diffrender/diffrender.go +++ b/internal/tui/diffrender/diffrender.go @@ -99,12 +99,11 @@ func renderLine(line filediff.DiffLine, digits int, opts Options) string { // 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, - KeepTrailingSpace: true, + Width: opts.Width, + FirstPrefix: gutter, + RestPrefix: gutterStyle.Render(diffLeftMargin + strings.Repeat(" ", digits) + separator), + Breakpoints: textwrap.Code, + KeepIndent: true, }, content...), "\n") } diff --git a/internal/tui/diffrender/highlight.go b/internal/tui/diffrender/highlight.go index a5cce873..93ebe07f 100644 --- a/internal/tui/diffrender/highlight.go +++ b/internal/tui/diffrender/highlight.go @@ -78,7 +78,8 @@ func highlight(path, text string, dark bool, base lipgloss.Style, multiline bool style = base.Foreground(lipgloss.Color(colour.String())) } } - avoid := token.Type.InSubCategory(chroma.LiteralString) || token.Type.InCategory(chroma.Comment) + 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") diff --git a/internal/tui/login/model.go b/internal/tui/login/model.go index 5fa15a0f..f3cf9e9e 100644 --- a/internal/tui/login/model.go +++ b/internal/tui/login/model.go @@ -15,6 +15,7 @@ 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" ) @@ -431,7 +432,7 @@ func (m *Model) bodyView(width, _ int) string { ansi.Truncate(m.custom.View(), width, "…"), } if m.loginErr != nil { - parts = append(parts, "", strings.Join(textwrap.WrapProse(width, m.loginErr.Error(), feedback.Error), "\n")) + parts = append(parts, "", textwrap.WrapProseText(width, escape.Multiline(m.loginErr.Error()), feedback.Error)) } return strings.Join(parts, "\n") case phaseWaiting: @@ -452,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{ - strings.Join(textwrap.WrapProse(width, message, feedback.Error), "\n"), + 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 7d4e97fd..81099d14 100644 --- a/internal/tui/permissions.go +++ b/internal/tui/permissions.go @@ -192,6 +192,7 @@ func (m *Model) permissionOptionRows(label, detail string, width int, selected b 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))) diff --git a/internal/tui/status/model.go b/internal/tui/status/model.go index 869f2eb9..0ca8fc8f 100644 --- a/internal/tui/status/model.go +++ b/internal/tui/status/model.go @@ -212,6 +212,7 @@ func (m Model) renderSection(title string, rows []statusRow) string { 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/textwrap.go b/internal/tui/textwrap/textwrap.go index 2a905fe1..7b1dadee 100644 --- a/internal/tui/textwrap/textwrap.go +++ b/internal/tui/textwrap/textwrap.go @@ -7,9 +7,9 @@ // - 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 printable text is lost: only whitespace at a soft row break or at -// the end of a line is dropped, unless [Options.KeepTrailingSpace] keeps -// the latter or [Options.Ellipsis] asks to cut words wider than a row; +// - 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. // @@ -18,6 +18,7 @@ package textwrap import ( + "slices" "strings" "unicode" "unicode/utf8" @@ -60,9 +61,9 @@ type Options struct { // 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 - // KeepTrailingSpace keeps whitespace ending a source line, wrapping it - // like text, where it is content, e.g. in a diff. - KeepTrailingSpace 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. @@ -88,7 +89,7 @@ func Wrap(o Options, segs ...Segment) []string { ellipsis: o.Ellipsis, } for _, line := range splitLines(toCells(segs, o.Breakpoints)) { - l.line(line, o.KeepIndent, o.KeepTrailingSpace) + l.line(line, o.KeepIndent, !o.TrimSpace) } out := make([]string, len(l.rows)) for i, r := range l.rows { @@ -106,9 +107,14 @@ func Wrap(o Options, segs ...Segment) []string { } // WrapProse word-wraps text to width rows in style, which may be nil, with -// [Prose] breakpoints. +// [Prose] breakpoints and [Options.TrimSpace]. func WrapProse(width int, text string, style Style) []string { - return Wrap(Options{Width: width, Breakpoints: Prose}, Segment{Text: text, Style: style}) + 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. @@ -184,7 +190,6 @@ func splitLines(cells []cell) [][]cell { if c.kind == cellNewline { lines = append(lines, cells[start:i]) start = i + 1 - continue } } return append(lines, cells[start:]) @@ -229,7 +234,7 @@ func (l *layout) add(cs []cell) { } } -func (l *layout) line(cells []cell, keepIndent, keepTrailing bool) { +func (l *layout) line(cells []cell, keepIndent, keepSpace bool) { indent := 0 if keepIndent { indent = min(leadingSpaces(cells), min(l.firstRoom, l.restRoom)/2) @@ -241,7 +246,8 @@ func (l *layout) line(cells []cell, keepIndent, keepTrailing bool) { l.newRow(indent) // Leading whitespace is content until the line's first word lands; after - // that, whitespace before a word is dropped if the word wraps. + // that, whitespace before a word is kept, unless the word wraps and + // keepSpace is unset. atLineStart := true var pending []cell pendingWidth := 0 @@ -262,11 +268,21 @@ func (l *layout) line(cells []cell, keepIndent, keepTrailing bool) { l.add(pending) l.add(word) } else { - if atLineStart { - l.hardAdd(pending, indent) // leading whitespace beyond the indent cap + 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 l.rows[len(l.rows)-1].width > 0 { + 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() { @@ -279,12 +295,35 @@ func (l *layout) line(cells []cell, keepIndent, keepTrailing bool) { pending, pendingWidth, atLineStart = nil, 0, false } - if atLineStart || keepTrailing { + if atLineStart || keepSpace { // A line of only whitespace keeps it, as far as it fits. - l.hardAdd(pending, indent) + 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 @@ -300,11 +339,22 @@ func (l *layout) fits(width int) bool { } // 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: then it breaks -// like plain 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) { - n, width := wordEnd(cells, true) - if width <= max(1, l.restRoom-indent) { + 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 } @@ -312,19 +362,20 @@ func (l *layout) nextWord(cells []cell, indent int) ([]cell, int) { cells[i].avoid = false } // Rescan the overlong run alone, so the text after it stays protected. - n, width = wordEnd(cells[:n], false) + 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 are words of their own, whitespace and breakpoints included. -func wordEnd(cells []cell, avoid bool) (n, width int) { +// 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 && n > 0 && c.avoid != cells[n-1].avoid { + if avoid && split && n > 0 && c.avoid != cells[n-1].avoid { break // an AvoidBreak run starts or ends } if c.kind == cellSpace && !held { diff --git a/internal/tui/textwrap/textwrap_test.go b/internal/tui/textwrap/textwrap_test.go index 3a296b83..a8d44850 100644 --- a/internal/tui/textwrap/textwrap_test.go +++ b/internal/tui/textwrap/textwrap_test.go @@ -34,15 +34,21 @@ func TestWrap(t *testing.T) { want: []string{"go test ./..."}, }, { - name: "words drop the space at a soft break", + 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: " "}, + o: Options{Width: 12, FirstPrefix: "• run ", RestPrefix: " ", TrimSpace: true}, want: []string{"• run alpha", " beta", " gamma"}, }, { @@ -54,7 +60,7 @@ func TestWrap(t *testing.T) { { name: "first and rest rooms differ", text: "aa bb cc dd ee", - o: Options{Width: 9, FirstPrefix: "> ", RestPrefix: ">>>>> "}, + o: Options{Width: 9, FirstPrefix: "> ", RestPrefix: ">>>>> ", TrimSpace: true}, want: []string{"> aa bb", ">>>>> cc", ">>>>> dd", ">>>>> ee"}, }, { @@ -66,7 +72,7 @@ func TestWrap(t *testing.T) { { name: "without breakpoints a long token hard-wraps", text: "echo abcdefghijkl", - o: Options{Width: 5}, + o: Options{Width: 5, TrimSpace: true}, want: []string{"echo", "abcde", "fghij", "kl"}, }, { @@ -84,19 +90,19 @@ func TestWrap(t *testing.T) { { name: "without KeepIndent leading spaces are content", text: " ab cd", - o: Options{Width: 5}, + o: Options{Width: 5, TrimSpace: true}, want: []string{" ab", "cd"}, }, { name: "non-breaking spaces bind words", text: "a b\u00a0c", - o: Options{Width: 3}, + o: Options{Width: 3, TrimSpace: true}, want: []string{"a", "b\u00a0c"}, }, { name: "wide graphemes measure two cells", text: "日本語 abc", - o: Options{Width: 4}, + o: Options{Width: 4, TrimSpace: true}, want: []string{"日本", "語", "abc"}, }, { @@ -106,16 +112,40 @@ func TestWrap(t *testing.T) { want: []string{" "}, }, { - name: "trailing whitespace is dropped", + name: "trailing whitespace is trimmed on demand", text: "foo \nbar ", - o: Options{Width: 5}, + o: Options{Width: 5, TrimSpace: true}, want: []string{"foo", "bar"}, }, { - name: "trailing whitespace is kept on demand", + name: "trailing whitespace is kept", text: "foo \nbar ", - o: Options{Width: 5, KeepTrailingSpace: true}, - want: []string{"foo ", "bar ", " "}, + 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", @@ -160,7 +190,7 @@ func TestWrapEllipsisCutsOverlongWords(t *testing.T) { {"wide grapheme", "界界界 a", 5, []string{"界界…", "a"}}, } { t.Run(test.name, func(t *testing.T) { - got := plainRows(Wrap(Options{Width: test.width, Ellipsis: "…"}, Plain(test.text))) + 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) } @@ -180,11 +210,13 @@ func TestWrapAvoidBreak(t *testing.T) { {"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: "|"}, test.segs...)) + 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) } @@ -192,6 +224,26 @@ func TestWrapAvoidBreak(t *testing.T) { } } +// 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 { @@ -206,7 +258,7 @@ 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}, segs...) + rows := Wrap(Options{Width: 8, TrimSpace: true}, segs...) if len(rows) < 3 { t.Fatalf("expected several rows, got %q", rows) } @@ -227,14 +279,14 @@ func TestWrapRendersSegmentsWithTheirStyles(t *testing.T) { if want := []string{"run ls"}; !reflect.DeepEqual(rows, want) { t.Fatalf("rows = %q, want %q", rows, want) } - rows = Wrap(Options{Width: 6, RestPrefix: " "}, Styled("alpha beta", tag("A"))) + 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}, Styled(" alpha beta", tag("A"))) + 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) diff --git a/internal/tui/welcome.go b/internal/tui/welcome.go index 5cb2df2e..3124211c 100644 --- a/internal/tui/welcome.go +++ b/internal/tui/welcome.go @@ -75,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 := textwrap.WrapProse(width, aiNotice, nil) - 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 { @@ -111,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 textwrap.WrapProse(width, aiNotice, nil) { - 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") }