Skip to content
Merged
177 changes: 177 additions & 0 deletions fakedata/exec_wrap.star
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""exec_command wrapping scenarios for BITS_FAKE_BACKEND=1. Start with `BITS_FAKE_BACKEND=1 go run .`.

Run everything from the Starlark console:
load("fakedata/exec_wrap.star", "all"); all()

or one group / one case:
load("fakedata/exec_wrap.star", "realistic"); realistic()
load("fakedata/exec_wrap.star", "corners"); corners()
load("fakedata/exec_wrap.star", "hyphens"); hyphens()

What to check:
- Collapsed: each command is one header line (plus at most 9 │ rows for
multi-line commands), cut with "…".
- Expanded (Ctrl+O, or click ▶): no "…" inside a command, nothing lost at
the right edge; the first line wraps under the command's first column,
later lines wrap behind │ with soft continuations indented by 2 more,
and source indentation is kept on every row.
- Resize down to ~20 columns and back while expanded: text rewraps into a
narrower column, never truncated, │ markers stay.

Every command is read-only. go/git/python3/rg may be missing on a machine; the
call then fails, which still exercises the error header. In manual mode each
call waits on the approval panel, which shows the "awaiting approval" suffix.
"""

def words(word, n):
return " ".join([word] * n)

def run(cmd, **kwargs):
args = {"cmd": cmd}
args.update(kwargs)
return call("exec_command", args)

# ---------------------------------------------------------------------------
# Realistic agent-style commands.

def rg_search():
# ripgrep with globs, an alternation regex and a pipe.
run("rg -n --hidden --no-heading -g '*.go' -g '!vendor/**' -g '!**/*_test.go' -e 'renderExecInvocation|hangWrap|lipgloss\\.Wrap|ansi\\.Truncate' internal/tui | sort -t: -k1,1 -k2,2n | head -n 40", timeout_ms = 20000)

def go_chain():
# A typical long && chain; takes a few seconds, so the running state shows.
run("cd internal/tui/chat && go vet ./... && go test -count=1 -run 'TestExecCommand|TestRender' ./... 2>&1 | tail -n 20 && cd - >/dev/null && git status --short && git diff --stat -- internal/tui/chat", timeout_ms = 120000)

def git_history():
# git log | awk | xargs git show, with a format string full of % and -.
run("git log --oneline --no-merges -n 5 -- internal/tui/chat | awk '{print $1}' | xargs -I{} git show --stat --format='--- %h %an (%ar): %s' {} | head -n 60")

def find_sizes():
# find -print0 | xargs -0 with a fallback; workdir shows in the header.
run("find . -type f -name '*.go' -not -path './vendor/*' -not -path './.git/*' -print0 | xargs -0 wc -l | sort -rn | head -n 15 || echo 'find failed' >&2", workdir = "internal")

def shell_loop():
# set -euo pipefail, a for loop and a conditional, all on one line.
run("set -euo pipefail; for pkg in internal/tui/chat internal/tui/components internal/tui/diffrender; do n=$(ls \"$pkg\"/*.go 2>/dev/null | wc -l | tr -d ' '); if [ \"$n\" -gt 0 ]; then printf '%-32s %3s go files\\n' \"$pkg\" \"$n\"; else echo \"skip $pkg\"; fi; done", shell = "bash")

def python_inline():
# python3 -c one-liner with nested quotes.
run("python3 -c \"import json, pathlib, collections; c = collections.Counter(p.suffix or '<none>' 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/$/ </'")

def indentation():
# Review repro: at ~32 columns the indented print used to lose its
# indentation behind an empty │ row. Indentation now repeats on every row.
run("python3 - <<'PY'\nif True:\n print('abcdefghij')\n print('" + words("indented-continuation", 4) + "')\nPY")

def operators():
# Review repro: at ~20 columns "&&" used to overflow the row and be cut.
run("go test ./... && go vet ./... && git diff --stat")

def short():
# Control: expanding a short command changes nothing.
run("echo short")

def corners():
header_metadata()
exit_suffix()
timed_out()
hard_break()
long_path()
hyphens()
heredoc_long_line()
tabs_and_blanks()
unicode()
shell_quoting()
indentation()
operators()
short()

# ---------------------------------------------------------------------------

def batch():
# Several commands in one round: queued approvals, adjacent tool rows.
call([
("exec_command", {"cmd": "rg -n -g '*.go' -e 'hangWrap|renderExecInvocationFull' internal/tui/chat | head -n 20"}),
("exec_command", {"cmd": "echo first\necho " + words("second-line-wraps-behind-the-branch", 4) + "\necho third"}),
("exec_command", {"cmd": "echo short"}),
])

def all():
realistic()
corners()
batch()
say("Press Ctrl+O to expand every command, then resize the terminal down to ~20 columns and back.")
9 changes: 5 additions & 4 deletions internal/tui/approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -184,27 +185,27 @@ 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 textwrap.WrapProseText(width, title, sty.Text)
},
ScrollOffset: a.window.Offset,
ScrollHint: "pgup/pgdown scroll",
ScrollableBody: func(width int) string {
if rendered, ok := chat.RenderToolApproval(block.Tool, width, a.chat); ok {
return rendered
}
return sty.Detail.Render(ansi.Wordwrap(escape.Inline(detail), width, "-"))
return textwrap.WrapProseText(width, escape.Inline(detail), sty.Detail)
},
BodyFooter: a.actions,
BodyFooterGap: 1,
CompactTitle: queue,
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{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, sty.Detail.Render(ansi.Wordwrap(escape.Inline(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.
Expand Down
3 changes: 2 additions & 1 deletion internal/tui/chat/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions internal/tui/chat/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
}
Expand Down
21 changes: 6 additions & 15 deletions internal/tui/chat/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -168,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.
Expand All @@ -177,21 +176,13 @@ 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.
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 s to width.
func wrap(s string, width int) string {
if width < 1 {
width = 1
}
return ansi.Wordwrap(s, width, "-")
return textwrap.WrapProseText(width, escape.Multiline(it.Progress.Content), sty.Meta)
}
Loading
Loading