From f484e44cd9ff22e4f52afa318ec410305d8e2af2 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 27 Jun 2026 08:14:33 +0900 Subject: [PATCH] =?UTF-8?q?fix(cli):=20review=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=ED=95=9C=EA=B8=80=20=ED=8F=AD(wcwidth)=20?= =?UTF-8?q?=EC=A0=88=EB=8B=A8=20=E2=80=94=20=EC=A4=84=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=EC=8A=A4=ED=83=9D=20=ED=95=B4=EA=B2=B0=20(INT-1966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 후속 보고: 한글 입력 시 진행표시가 여전히 줄마다 쌓임. 원인은 truncateLine이 문자열 길이(코드유닛)로 잘라, 한글(2칸 폭)이 터미널 너비를 넘겨 wrap → \r\x1b[2K가 마지막 줄만 지워 윗줄이 누적. - displayWidth(s): East-Asian wide/fullwidth(Hangul/CJK/kana/fullwidth)·emoji를 2칸으로 계산. - truncateLine: 코드유닛이 아닌 **표시 컬럼 예산**으로 절단(말줄임 1칸 예약). - startReviewProgress: maxCols = columns-1(마지막 칸 auto-wrap 방지). 테스트: displayWidth(한글/emoji=2) + 한글 truncate(폭 기준) + 멀티라인 한글 노트가 columns 내 단일 라인 유지. 전체 green. INT-1966 후속(사용자 재보고). --- src/cli/reviewProgress.test.ts | 28 ++++++++++------ src/cli/reviewProgress.ts | 58 ++++++++++++++++++++++++++++++---- 2 files changed, 71 insertions(+), 15 deletions(-) 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; };