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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.")
14 changes: 10 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 prose(title, sty.Text, width)
},
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 prose(escape.Inline(detail), sty.Detail, width)
},
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{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.
Expand Down Expand Up @@ -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")
}
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
12 changes: 4 additions & 8 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 @@ -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 @@ -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")
}
Loading
Loading