Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/lucky-dragons-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clack/prompts": patch
---

fix(note, box): handle CJK correctly
5 changes: 3 additions & 2 deletions packages/prompts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@
"sisteransi": "^1.0.5"
},
"devDependencies": {
"fast-string-width": "^1.1.0",
"fast-wrap-ansi": "^0.1.3",
"is-unicode-supported": "^1.3.0",
"memfs": "^4.17.2",
"vitest": "^3.2.4",
"vitest-ansi-serializer": "^0.1.2",
"fast-wrap-ansi": "^0.1.3"
"vitest-ansi-serializer": "^0.1.2"
}
}
17 changes: 10 additions & 7 deletions packages/prompts/src/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
S_CORNER_TOP_LEFT,
S_CORNER_TOP_RIGHT,
} from './common.js';
import stringWidth from "fast-string-width";

export type BoxAlignment = 'left' | 'center' | 'right';

Expand Down Expand Up @@ -72,13 +73,15 @@ export const box = (message = '', title = '', opts?: BoxOptions) => {
const symbols = (opts?.rounded ? roundedSymbols : squareSymbols).map(formatBorder);
const hSymbol = formatBorder(S_BAR_H);
const vSymbol = formatBorder(S_BAR);
const maxBoxWidth = columns - linePrefix.length;
let boxWidth = Math.floor(columns * width) - linePrefix.length;
const linePrefixWidth = stringWidth(linePrefix);
const titleWidth = stringWidth(title);
const maxBoxWidth = columns - linePrefixWidth;
let boxWidth = Math.floor(columns * width) - linePrefixWidth;
if (opts?.width === 'auto') {
const lines = message.split('\n');
let longestLine = title.length + titlePadding * 2;
let longestLine = titleWidth + titlePadding * 2;
for (const line of lines) {
const lineWithPadding = line.length + contentPadding * 2;
const lineWithPadding = stringWidth(line) + contentPadding * 2;
if (lineWithPadding > longestLine) {
longestLine = lineWithPadding;
}
Expand All @@ -98,9 +101,9 @@ export const box = (message = '', title = '', opts?: BoxOptions) => {
const innerWidth = boxWidth - borderTotalWidth;
const maxTitleLength = innerWidth - titlePadding * 2;
const truncatedTitle =
title.length > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title;
titleWidth > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title;
const [titlePaddingLeft, titlePaddingRight] = getPaddingForLine(
truncatedTitle.length,
stringWidth(truncatedTitle),
innerWidth,
titlePadding,
opts?.titleAlign
Expand All @@ -115,7 +118,7 @@ export const box = (message = '', title = '', opts?: BoxOptions) => {
const wrappedLines = wrappedMessage.split('\n');
for (const line of wrappedLines) {
const [leftLinePadding, rightLinePadding] = getPaddingForLine(
line.length,
stringWidth(line),
innerWidth,
contentPadding,
opts?.contentAlign
Expand Down
14 changes: 7 additions & 7 deletions packages/prompts/src/note.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import process from 'node:process';
import type { Writable } from 'node:stream';
import { stripVTControlCharacters as strip } from 'node:util';
import { getColumns } from '@clack/core';
import { type Options as WrapAnsiOptions, wrapAnsi } from 'fast-wrap-ansi';
import color from 'picocolors';
Expand All @@ -13,6 +12,7 @@ import {
S_CORNER_TOP_RIGHT,
S_STEP_SUBMIT,
} from './common.js';
import stringWidth from "fast-string-width";

type FormatFn = (line: string) => string;
export interface NoteOptions extends CommonOptions {
Expand All @@ -27,10 +27,10 @@ const wrapWithFormat = (message: string, width: number, format: FormatFn): strin
trim: false,
};
const wrapMsg = wrapAnsi(message, width, opts).split('\n');
const maxWidthNormal = wrapMsg.reduce((sum, ln) => Math.max(strip(ln).length, sum), 0);
const maxWidthNormal = wrapMsg.reduce((sum, ln) => Math.max(stringWidth(ln), sum), 0);
const maxWidthFormat = wrapMsg
.map(format)
.reduce((sum, ln) => Math.max(strip(ln).length, sum), 0);
.reduce((sum, ln) => Math.max(stringWidth(ln), sum), 0);
const wrapWidth = width - (maxWidthFormat - maxWidthNormal);
return wrapAnsi(message, wrapWidth, opts);
};
Expand All @@ -40,18 +40,18 @@ export const note = (message = '', title = '', opts?: NoteOptions) => {
const format = opts?.format ?? defaultNoteFormatter;
const wrapMsg = wrapWithFormat(message, getColumns(output) - 6, format);
const lines = ['', ...wrapMsg.split('\n').map(format), ''];
const titleLen = strip(title).length;
const titleLen = stringWidth(title);
const len =
Math.max(
lines.reduce((sum, ln) => {
const line = strip(ln);
return line.length > sum ? line.length : sum;
const width = stringWidth(ln);
return width > sum ? width : sum;
}, 0),
titleLen
) + 2;
const msg = lines
.map(
(ln) => `${color.gray(S_BAR)} ${ln}${' '.repeat(len - strip(ln).length)}${color.gray(S_BAR)}`
(ln) => `${color.gray(S_BAR)} ${ln}${' '.repeat(len - stringWidth(ln))}${color.gray(S_BAR)}`
)
.join('\n');
output.write(
Expand Down
64 changes: 64 additions & 0 deletions packages/prompts/test/__snapshots__/box.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,38 @@ exports[`box (isCI = false) > renders truncated long titles 1`] = `
]
`;

exports[`box (isCI = false) > renders wide characters with auto width 1`] = `
[
"┌─这是标题─────────────────┐
",
"│ 이게 첫 번째 줄이에요 │
",
"│ これは次の行です │
",
"└──────────────────────────┘
",
]
`;

exports[`box (isCI = false) > renders wide characters with specified width 1`] = `
[
"┌─这是标题─────┐
",
"│ 이게 첫 │
",
"│ 번째 │
",
"│ 줄이에요 │
",
"│ これは次の │
",
"│ 行です │
",
"└──────────────┘
",
]
`;

exports[`box (isCI = false) > renders with formatBorder formatting 1`] = `
[
"┌─title──────┐
Expand Down Expand Up @@ -423,6 +455,38 @@ exports[`box (isCI = true) > renders truncated long titles 1`] = `
]
`;

exports[`box (isCI = true) > renders wide characters with auto width 1`] = `
[
"┌─这是标题─────────────────┐
",
"│ 이게 첫 번째 줄이에요 │
",
"│ これは次の行です │
",
"└──────────────────────────┘
",
]
`;

exports[`box (isCI = true) > renders wide characters with specified width 1`] = `
[
"┌─这是标题─────┐
",
"│ 이게 첫 │
",
"│ 번째 │
",
"│ 줄이에요 │
",
"│ これは次の │
",
"│ 行です │
",
"└──────────────┘
",
]
`;

exports[`box (isCI = true) > renders with formatBorder formatting 1`] = `
[
"┌─title──────┐
Expand Down
Loading
Loading