Skip to content

[low] fix(custom-command): truncate maxWidth output by display width, not string length - #626

Merged
sirmalloc merged 1 commit into
sirmalloc:mainfrom
elhoim:fix/custom-command-maxwidth-ansi
Oct 6, 2026
Merged

sirmalloc merged 1 commit into
sirmalloc:mainfrom
elhoim:fix/custom-command-maxwidth-ansi

Conversation

@elhoim

@elhoim elhoim commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: low. A custom command's maxWidth cut counts UTF-16 code units, not terminal columns.
  • Bug: with preserveColors, escape bytes use up the budget. The cut can land inside an escape sequence, and it drops the command's trailing reset, so the colour bleeds into the separators and widgets after it. Without preserveColors, CJK output takes twice its maxWidth and a cut can split a surrogate pair.
  • Fix: use the shared applyMaxWidth helper (truncateStyledText with the ... ellipsis), as GitBranch and GitRootDir already do. After a cut, append an SGR reset when the output still holds styling.
  • Test: 6 new CustomCommand tests. 4 fail on main and all pass with the fix.

Details

  • Before: src/widgets/CustomCommand.tsx ran output.substring(0, item.maxWidth - 3) + '...' on the raw output. getVisibleText only strips escapes when preserveColors is off.
  • Examples on main:
    • \x1b[38;5;208mfeature/login-refactor\x1b[0m at maxWidth 20 gives \x1b[38;5;208mfeatur.... That is 9 visible columns, and the colour is never reset, so the following | separator is painted orange.
    • At maxWidth 10 the cut lands inside the escape (\x1b[38;5;...), and the terminal swallows the following text.
    • '中'.repeat(20) at maxWidth 20 is not truncated at all and takes 40 columns.
    • '😀'.repeat(15) at maxWidth 20 leaves a lone surrogate.
  • Why the widget-level cut stays: the renderer's ANSI-aware maxWidth pass (getVisibleWidth + truncateStyledText(..., { ellipsis: false })) only runs for preserve-colors widgets. Non-preserve output relies on the widget. For preserve-colors output the renderer pass runs too late, because the widget has already damaged the text. The cut has been in the widget since it was added, and there is no reason to keep raw-length semantics.
  • Why the extra reset: truncateStyledText closes an open OSC 8 link, but it does not re-emit an SGR reset after it cuts. The widget therefore adds \x1b[0m when the truncated text still has SGR codes. The reset goes last, so the renderer's outer background, bold and dim wrapping is unaffected.
  • Small behaviour change: with maxWidth ≤ 3 the widget now returns '.'.repeat(maxWidth) (the shared helper's rule) instead of a 3-column ... that overflowed the limit.
  • ansi.ts is untouched. This is independent of [low] fix(ansi): never truncate to a line wider than maxWidth #625, which changes cluster handling inside truncateStyledText. This widget benefits from that fix automatically when it lands.

Checks

  • bun run lint (tsc + eslint --max-warnings=0): clean.
  • bun run build: clean.
  • New tests: 4 fail / 8 pass on main (colour cut, cut inside an escape, CJK width, surrogate pair). 12/12 pass with the fix. CustomCommand, renderer*, ansi*, GitBranch and GitRootDir suites: 165 pass, 0 fail.
  • Full bun test on a heavily loaded machine (load average ~23): only unrelated, pre-existing failures. These are 5 s timeouts in fetchUsageData error handling, custom command capture process tests and TUI component tests. The same 12 files run on unmodified main gave 54 failures, against 41 on this branch. None of them import the changed widget.

🤖 Generated with Claude Code

The widget cut its output with `output.substring(0, maxWidth - 3)`,
which counts UTF-16 code units, not terminal columns. With
preserveColors on, the output still holds its escape sequences, so:

- escape bytes use up the budget (`\x1b[38;5;208mfeature/login-refactor`
  at maxWidth 20 showed 6 characters),
- the cut can land inside an escape sequence, and the terminal then
  swallows the characters that follow,
- the command's trailing reset is dropped, so the colour bleeds into
  the separators and widgets after it.

Without preserveColors, CJK text took twice its maxWidth, and a cut
could split a surrogate pair.

The renderer's ANSI-aware maxWidth pass runs only for preserveColors
widgets, and only after the widget has already damaged the text. So
keep the widget-level cut, but use the shared applyMaxWidth helper
(truncateStyledText with an ellipsis), as GitBranch and GitRootDir do.
It keeps the '...' ellipsis. truncateStyledText does not re-emit a
reset after it cuts, so append one when the truncated output still
holds SGR styling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@sirmalloc
sirmalloc merged commit 8abcefa into sirmalloc:main Oct 6, 2026
3 checks passed
@elhoim
elhoim deleted the fix/custom-command-maxwidth-ansi branch October 7, 2026 22:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants