From 2aee058d9a50500c1f14db2f4d7fa28537e1e394 Mon Sep 17 00:00:00 2001 From: greymoth <246701683+greymoth-jp@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:56:49 +0900 Subject: [PATCH] fix(prompts): truncate wide-character box titles by display width --- .changeset/box-wide-title-truncation.md | 5 +++++ packages/prompts/src/box.ts | 18 ++++++++++++++++- packages/prompts/test/box.test.ts | 27 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 .changeset/box-wide-title-truncation.md diff --git a/.changeset/box-wide-title-truncation.md b/.changeset/box-wide-title-truncation.md new file mode 100644 index 00000000..085a3941 --- /dev/null +++ b/.changeset/box-wide-title-truncation.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Fix `box()` so a title wider than the box is truncated by display width instead of UTF-16 code units. A wide-character (CJK) title that overflowed the title budget previously produced ragged borders, and could throw `RangeError: Invalid count value` once the remainder passed to `'─'.repeat(...)` went negative. diff --git a/packages/prompts/src/box.ts b/packages/prompts/src/box.ts index 99d6f7c3..9ab34188 100644 --- a/packages/prompts/src/box.ts +++ b/packages/prompts/src/box.ts @@ -96,6 +96,22 @@ function getPaddingForLine( return [leftPadding, rightPadding]; } +function truncateToWidth(str: string, maxWidth: number): string { + const ellipsis = '...'; + const target = maxWidth - stringWidth(ellipsis); + let result = ''; + let width = 0; + for (const char of str) { + const charWidth = stringWidth(char); + if (width + charWidth > target) { + break; + } + result += char; + width += charWidth; + } + return result + ellipsis; +} + const defaultFormatBorder = (text: string) => text; /** @@ -162,7 +178,7 @@ export const box = (message = '', title = '', opts?: BoxOptions) => { const innerWidth = boxWidth - borderTotalWidth; const maxTitleLength = innerWidth - titlePadding * 2; const truncatedTitle = - titleWidth > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title; + titleWidth > maxTitleLength ? truncateToWidth(title, maxTitleLength) : title; const [titlePaddingLeft, titlePaddingRight] = getPaddingForLine( stringWidth(truncatedTitle), innerWidth, diff --git a/packages/prompts/test/box.test.ts b/packages/prompts/test/box.test.ts index 54378210..9e07aae4 100644 --- a/packages/prompts/test/box.test.ts +++ b/packages/prompts/test/box.test.ts @@ -1,5 +1,6 @@ import { styleText } from 'node:util'; import { updateSettings } from '@clack/core'; +import stringWidth from 'fast-string-width'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import * as prompts from '../src/index.js'; import { MockReadable, MockWritable } from './test-utils.js'; @@ -270,4 +271,30 @@ describe.each(['true', 'false'])('box (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test('truncates a wide-character title that overflows the box width', () => { + // '这是一个非常长的标题' is 10 wide chars = 20 display columns, which + // exceeds the title budget at width: 0.2. The title must be truncated by + // display width, not by UTF-16 code units, otherwise the trailing + // `'─'.repeat(...)` count goes negative and box() throws RangeError. + const title = '这是一个非常长的标题'; + expect(() => { + prompts.box('message', title, { + input, + output, + width: 0.2, + }); + }).not.toThrow(); + + const rendered = output.buffer.join(''); + expect(rendered).toContain('...'); + + // Every rendered border line must occupy the same number of display + // columns; otherwise the box corners are ragged. + const lineWidths = rendered + .split('\n') + .filter((line) => line.length > 0) + .map((line) => stringWidth(line)); + expect(new Set(lineWidths).size).toBe(1); + }); });