cli(update -i): measure table cells in terminal columns, not bytes
Problem
bun update --interactivesizes and pads the Package and Workspace columns by UTF-8 byte length (calculate_column_widthsand the row renderer insrc/runtime/cli/update_interactive_command.rs). A name with CJK, emoji or accented characters has more bytes than columns, so the cells after it do not line up.truncate_with_ellipsiscuts by byte index. It splits a multi-byte character, and the terminal receives invalid UTF-8:日本語パッ�…��ケージです.- The check for a truncated version compares byte lengths.
…is 3 bytes and 1 column, so a version 1 or 2 columns too wide prints in full and shifts the Latest column.
Fix
- Measure names with
strings::visible::width::exclude_ansi_colors::utf8, as #43236 does forbun outdated. Compute the name padding from the text that the row shows. truncate_with_ellipsiscuts the start withutf8_index_at_widthand the end with a new counterpart,utf8_suffix_index_at_width. Neither splits a character. A comparison with the original detects a truncated version.- Verified:
test/cli/update_interactive_formatting.test.ts(4 new tests, all fail on bun 1.4.3-canary.1). Also ran the two otherupdate_interactive_*test files. - Self-reviewed: 6 concerns raised, 5 addressed. Rejected: fold this into #43236. The files of the two changes do not overlap.
Background
- A terminal draws a CJK character or an emoji 2 columns wide. UTF-8 stores it in 3 or 4 bytes.
étakes 1 column and 2 bytes. - The width functions are Rust entry points to
src/jsc/bindings/stringWidth.cpp.console.tableand the REPL use them. - The table limits each column by the terminal width. Piped stdout counts as 80 columns: 35 for a name, 15 for a version.
Notes
How this was found. By a read of the code during the work on #43236. There is no user report. The npm registry does not allow a package name that is not ASCII, but a private registry can serve one, and a workspace name or a catalog name comes from the user's own package.json.
Before and after (NO_COLOR=1, piped stdout, the first number is Bun.stringWidth of the line):
before (1.4.3-canary.1+b52d51348)
55 | dependencies Current Target Latest
54 | ❯ □ dep 1.0.0 1.0.0 2.0.0
46 | □ 日本語パッケージ 1.0.0 1.0.0 2.0.0
after
47 | dependencies Current Target Latest
46 | ❯ □ dep 1.0.0 1.0.0 2.0.0
46 | □ 日本語パッケージ 1.0.0 1.0.0 2.0.0
On a 110-column pty, before: 工作区-文�… in the Workspace column (cut inside 档). After: 工作区-文档-😀 (14 columns, it fits in the 15-column limit).
With versions 1.0.0-canary.123 (16 columns) and a 15-column limit, before: 1.0.0-c…ary.123 1.0.0-canary.123 2.0.0-canary.4567. After: 1.0.0-c…ary.123 1.0.0-c…ary.123 2.0.0-c…ry.4567.
utf8_suffix_index_at_width. The suffix that fits is what remains after a prefix of at least total - max_width columns is dropped. utf8_index_at_width picks that prefix, so the two directions share one width rule, and the cut is never inside a code point or an ANSI escape. The forward function includes the zero-width code points that follow the prefix, so the suffix starts with a visible code point and a combining mark stays with its letter. When a 2-column code point straddles the cut, the first try drops one column too few. The next try drops that code point too. A code point is at most 2 columns wide, so two tries are enough, and a debug assertion checks the second one. The tests cover the exact cut (ASCII and decomposed é), the straddle (CJK) and a width of 0 (the Workspace column and the help text truncate at the end only).
Version columns. calculate_column_widths still sizes the Current, Target and Latest columns by byte length. A formatted semver version is ASCII (src/semver/Version.rs accepts only [0-9A-Za-z.+-] in a tag), so its byte length is its width. A truncated version holds a …, so the row renderer measures it with the width function.
Known limit. The UTF-8 width function adds the width of each code point. It differs from Bun.stringWidth on an emoji sequence (ZWJ, skin tone, keycap, VS16), as console.table and #43236 do. The tests do not pin the width of such a sequence. #41525 changes that in the shared function, and this table follows with no change here.
Tests. Verdaccio has no package with a name that is not ASCII, so the new tests use a manifest-only Bun.serve registry, bun install --lockfile-only and bun update -i --dry-run. The Workspace column needs a terminal wider than 100 columns, so that test runs bun update -i on a 110-column Bun.Terminal. It does not run on Windows: ConPTY redraws the screen with its own escape sequences, so the pty bytes are not the bytes that bun wrote.
Checked by hand with the debug build. Terminal widths of 50, 70, 80, 110, 140 and 170 columns, with and without colors. Names: Thai, decomposed é, a name that starts with combining marks, 50 zero-width spaces before the text, a tab and a bell, right-to-left text, flags, and mixed scripts. Every row puts the Current cell in the same column, and the table has no U+FFFD.
Same class, not changed here. These also pad or cut by byte length. Each is a different command with its own layout code and tests:
bun outdated: #43236.src/runtime/cli/multi_run.rs(the label column ofbun run --filterand--parallel).src/runtime/cli/build_command.rs(the output path column ofbun build).src/runtime/cli/test_command.rswithsrc/sourcemap_jsc/CodeCoverage.rs(the file column of the coverage table).strip_ansi_leninsrc/runtime/cli/pm_diff_command.rs.buf_writeinsrc/bun_core/Progress.rscuts the progress line at the end of its buffer. With a long name of wide characters, theResolving...line ofbun installcan end in a split character.
no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/update_interactive_formatting.test.ts