diff --git a/src/cli/reviewProgress.test.ts b/src/cli/reviewProgress.test.ts index f641613..d5aa9af 100644 --- a/src/cli/reviewProgress.test.ts +++ b/src/cli/reviewProgress.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { spinnerFrame, formatProgress, startReviewProgress, oneLine, truncateLine } from './reviewProgress.js'; +import { spinnerFrame, formatProgress, startReviewProgress, oneLine, truncateLine, displayWidth } from './reviewProgress.js'; describe('spinnerFrame / formatProgress (INT-1963)', () => { it('cycles spinner frames and is safe for any tick', () => { @@ -23,9 +23,19 @@ describe('oneLine / truncateLine (INT-1966)', () => { expect(truncateLine('hello world', 8)).toBe('hello wโ€ฆ'); expect(truncateLine('short', 80)).toBe('short'); }); - it('formatProgress truncates to width and never wraps', () => { - const out = formatProgress(0, 3, 'a'.repeat(100), 20); - expect(out.length).toBe(20); + it('counts wide (CJK/Hangul/emoji) chars as 2 columns (INT-1966)', () => { + expect(displayWidth('๊ฐ€')).toBe(2); + expect(displayWidth('ab')).toBe(2); + expect(displayWidth('๊ฐ€a')).toBe(3); + expect(displayWidth('๐Ÿ˜€')).toBe(2); + }); + it('truncates Korean by display width, not code-unit length (INT-1966)', () => { + const out = truncateLine('๊ฐ€'.repeat(30), 10); + expect(displayWidth(out)).toBeLessThanOrEqual(10); // would have been ~30 cols if length-based + expect(out.endsWith('โ€ฆ')).toBe(true); + }); + it('formatProgress truncates ascii to a column budget', () => { + expect(displayWidth(formatProgress(0, 3, 'a'.repeat(100), 20))).toBeLessThanOrEqual(20); }); }); @@ -43,12 +53,12 @@ describe('startReviewProgress multi-line note (INT-1966)', () => { clearIntervalFn: () => {}, columns: 40, }); - p.note('line one\nline two\nline three with lots of detail here'); + // Korean (wide) multi-line note โ€” the real failing case: width must stay โ‰ค columns. + p.note('์ž‘์—… ์ค‘์ž…๋‹ˆ๋‹ค.\n๋ณ€๊ฒฝ๋œ 3๊ฐœ ํŒŒ์ผ (`data/bench_external/odysseybench/tasks/...`)\n์ ์ˆ˜ ํ™•์ธ'); intervalFn!(); - const frame = writes.at(-1)!; - // exactly one line of content after the clear sequence โ€” no embedded newlines - expect(frame.replace('\r\x1b[2K', '')).not.toContain('\n'); - expect(frame.replace('\r\x1b[2K', '').length).toBeLessThanOrEqual(40); + const content = writes.at(-1)!.replace('\r\x1b[2K', ''); + expect(content).not.toContain('\n'); // single line + expect(displayWidth(content)).toBeLessThanOrEqual(40); // never exceeds terminal width โ†’ no wrap p.stop(); }); }); diff --git a/src/cli/reviewProgress.ts b/src/cli/reviewProgress.ts index 8b1d94f..c695291 100644 --- a/src/cli/reviewProgress.ts +++ b/src/cli/reviewProgress.ts @@ -18,11 +18,54 @@ export function oneLine(s: string): string { return s.replace(/\s+/g, ' ').trim(); } -/** Truncate to width (with an ellipsis) so the spinner never wraps. Pure. */ +/** + * Terminal display width of a code point: East-Asian wide / fullwidth characters + * (Hangul, CJK, kana, fullwidth forms) and most emoji occupy 2 columns. (INT-1966) + */ +function charWidth(cp: number): number { + if ( + (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo + (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi, symbols + (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK symbols + (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A + (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified + (cp >= 0xa000 && cp <= 0xa4cf) || // Yi + (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables + (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility + (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compatibility Forms + (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth Forms + (cp >= 0xffe0 && cp <= 0xffe6) || + (cp >= 0x1f300 && cp <= 0x1faff) || // emoji & symbols + (cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B+ + ) { + return 2; + } + return 1; +} + +/** Display width of a string in terminal columns (wide chars count as 2). Pure. */ +export function displayWidth(s: string): number { + let w = 0; + for (const ch of s) w += charWidth(ch.codePointAt(0) ?? 0); + return w; +} + +/** + * Truncate to a terminal COLUMN budget (not code-unit length) with an ellipsis, + * so a line of wide chars never exceeds the width and wraps. (INT-1966) + */ export function truncateLine(s: string, width: number): string { - if (width <= 0 || s.length <= width) return s; - if (width <= 1) return s.slice(0, width); - return `${s.slice(0, width - 1)}โ€ฆ`; + if (width <= 0) return ''; + if (displayWidth(s) <= width) return s; + let out = ''; + let w = 0; + for (const ch of s) { + const cw = charWidth(ch.codePointAt(0) ?? 0); + if (w + cw > width - 1) break; // reserve one column for the ellipsis + out += ch; + w += cw; + } + return `${out}โ€ฆ`; } /** One progress line: `โ ™ reviewingโ€ฆ 3s ยท ๐Ÿ”ง read_file`. Pure. */ @@ -62,6 +105,9 @@ export function startReviewProgress(deps: ReviewProgressDeps = {}): ReviewProgre const clearIntervalFn = deps.clearIntervalFn ?? clearInterval; const intervalMs = deps.intervalMs ?? 200; const columns = deps.columns ?? process.stdout.columns ?? 80; + // Reserve the last column: writing into it auto-wraps on most terminals, which + // re-introduces the multi-row stacking we're trying to avoid. (INT-1966) + const maxCols = Math.max(10, columns - 1); const start = now(); let tick = 0; @@ -69,8 +115,8 @@ export function startReviewProgress(deps: ReviewProgressDeps = {}): ReviewProgre const render = () => { const elapsedSec = Math.max(0, Math.floor((now() - start) / 1000)); - // Single line, truncated to width โ€” a multi-line note must never stack. (INT-1966) - write(`${CLEAR_LINE}${formatProgress(tick, elapsedSec, last, columns)}`); + // Single line, truncated to display width โ€” a wide-char note must never wrap/stack. (INT-1966) + write(`${CLEAR_LINE}${formatProgress(tick, elapsedSec, last, maxCols)}`); tick += 1; };