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
28 changes: 19 additions & 9 deletions src/cli/reviewProgress.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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);
});
});

Expand All @@ -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();
});
});
Expand Down
58 changes: 52 additions & 6 deletions src/cli/reviewProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -62,15 +105,18 @@ 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;
let last: string | undefined;

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;
};

Expand Down
Loading