diff --git a/src/render.ts b/src/render.ts index a6df740..133607b 100644 --- a/src/render.ts +++ b/src/render.ts @@ -428,7 +428,8 @@ export function renderGroups( const bar = renderTeamPickHeader(dm.candidates, dm.focusedIndex, barWidth); const barPlain = stripAnsi(bar); // Pad between bar content and suffix to keep suffix right-aligned. - const padLen = Math.max(0, barWidth - barPlain.length); + // Use visibleWidth to account for CJK and multi-code-point graphemes. + const padLen = Math.max(0, barWidth - visibleWidth(barPlain)); const line = pc.dim(REPICK_PREFIX) + bar + " ".repeat(padLen) + pc.dim(REPICK_SUFFIX); lines.push(clipToWidth(line, termWidth) + "\n"); } else if (opts.teamPickMode?.active) { diff --git a/src/render/team-pick.test.ts b/src/render/team-pick.test.ts index 4ba15ba..d789ab6 100644 --- a/src/render/team-pick.test.ts +++ b/src/render/team-pick.test.ts @@ -154,6 +154,59 @@ describe("renderTeamPickHeader — windowed scrolling", () => { }); }); +// ─── renderTeamPickHeader — Unicode and wide characters ─────────────────────── + +describe("renderTeamPickHeader — CJK and emoji", () => { + it("renders CJK team names with correct width measurement", () => { + // CJK characters take 2 columns each: "中文" = 4 visible columns + const result = strip(renderTeamPickHeader(["中文", "team"], 0)); + expect(result).toContain("[ 中文 ]"); + expect(result).toContain("team"); + }); + + it("clips CJK focused team when it exceeds maxWidth", () => { + // "[ 中文测试平台 ]" = 11 chars, 19 visible columns + // With maxWidth=10, should clip to fit + const result = strip(renderTeamPickHeader(["中文测试平台"], 0, 10)); + expect(result.length).toBeLessThanOrEqual(10); + // Ensure the ellipsis is present (clipping occurred) + expect(result.endsWith("…")).toBe(true); + }); + + it("preserves focused ellipsis styling when clipping CJK names", () => { + // Verify the clipped output contains ANSI codes for bold/magenta + // and the ellipsis is styled + const result = renderTeamPickHeader(["中文测试平台"], 0, 10); + expect(result).toMatch(/\x1b\[/); // Has ANSI codes + expect(result).toContain("…"); // Has ellipsis + }); + + it("renders emoji team names with correct width", () => { + // Single emoji typically takes 2 columns: "🔍" = 2 visible columns + const result = strip(renderTeamPickHeader(["🔍platform", "backend"], 0)); + expect(result).toContain("[ 🔍platform ]"); + expect(result).toContain("backend"); + }); + + it("handles mixed ASCII and emoji in team names", () => { + const result = strip(renderTeamPickHeader(["data📊", "api", "web🌐"], 1)); + expect(result).toContain("[ api ]"); + expect(result).toContain("data📊"); + expect(result).toContain("web🌐"); + }); + + it("maintains maxWidth constraint with wide characters in window rendering", () => { + // Mix of CJK and ASCII in a windowed render + const teams = ["中", "team", "api", "平"]; + // Each CJK = 2 cols, ASCII words = their length + // "[ 中 ]"=5 cols, "team"=4, "api"=3, "平"=2 + const maxWidth = 20; + const result = strip(renderTeamPickHeader(teams, 0, maxWidth)); + // Verify output length is reasonable (accounting for SEP + ellipsis) + expect(result.length).toBeLessThanOrEqual(maxWidth + 4); // Allow some margin for ANSI codes + }); +}); + // ─── renderTeamPickHeader — guard conditions ────────────────────────────────── describe("renderTeamPickHeader — guard conditions", () => { diff --git a/src/render/team-pick.ts b/src/render/team-pick.ts index ef711e3..67ba95e 100644 --- a/src/render/team-pick.ts +++ b/src/render/team-pick.ts @@ -1,4 +1,5 @@ import pc from "picocolors"; +import { visibleWidth, clipToWidth } from "./terminal.ts"; const SEP = " "; @@ -45,7 +46,7 @@ export function renderTeamPickHeader( // Pre-compute visible text for each candidate (focused gets [ ] brackets). const texts = candidateTeams.map((t, i) => (i === safeIndex ? `[ ${t} ]` : t)); - const widths = texts.map((t) => t.length); + const widths = texts.map((t) => visibleWidth(t)); if (maxWidth === undefined) { // No width constraint — render all candidates. @@ -61,8 +62,10 @@ export function renderTeamPickHeader( // If the focused item alone is wider than maxWidth, clip it. if (widths[safeIndex] > maxWidth) { - const clipped = texts[safeIndex].slice(0, maxWidth - 1) + "…"; - return pc.bold(pc.magenta(clipped)); + const styledText = pc.bold(pc.magenta(texts[safeIndex])); + const clipped = clipToWidth(styledText, maxWidth - 1); + const styledEllipsis = pc.bold(pc.magenta("…")); + return clipped + styledEllipsis; } // ── Windowed rendering ────────────────────────────────────────────────────