Skip to content

Commit 83a1e44

Browse files
committed
Truncate form text by display width and keep box width inside the terminal
fitTrailingText was using string length, so CJK and emoji overran the pane. Also drop the forced 16-column box floor so margin plus width cannot exceed tiny terminals.
1 parent 09378e6 commit 83a1e44

4 files changed

Lines changed: 47 additions & 9 deletions

File tree

src/tui/components/agent-modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,7 @@ export function AgentModal({
834834
paddingY={1}
835835
marginX={1}
836836
marginY={1}
837-
width={Math.max(16, columns - 2)}
837+
width={Math.max(1, columns - 2)}
838838
>
839839
<Text bold color={color("accent")}>
840840
Agent Configuration

src/tui/components/form-reflow.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,27 @@ describe("fitTrailingText", () => {
2323
expect(fitTrailingText("x", 0)).toBe("");
2424
expect(fitTrailingText("x", -3)).toBe("");
2525
});
26+
27+
test("truncates by display width for CJK (two cells each)", () => {
28+
// "你好世界" is 8 cells; budget 5 → "…" (1) + last two chars (4) = 5
29+
expect(fitTrailingText("你好世界", 5)).toBe("…世界");
30+
});
31+
32+
test("truncates by display width for emoji", () => {
33+
// each rocket is 2 cells; budget 5 → "…" (1) + two rockets (4) = 5
34+
expect(fitTrailingText("🚀🚀🚀🚀", 5)).toBe("…🚀🚀");
35+
});
36+
37+
test("never exceeds the column budget after truncation", () => {
38+
const samples = ["hello world", "你好世界测试", "a🚀b🚀c🚀d", "abcdefghijklmnopqrstuvwxyz"];
39+
for (const text of samples) {
40+
for (const width of [1, 2, 3, 5, 8, 10]) {
41+
const out = fitTrailingText(text, width);
42+
// stringWidth is exercised via the public API; Bun.stringWidth matches.
43+
expect(Bun.stringWidth(out)).toBeLessThanOrEqual(Math.max(0, width));
44+
}
45+
}
46+
});
2647
});
2748

2849
describe("wrapHelpSegments", () => {
@@ -43,7 +64,7 @@ describe("wrapHelpSegments", () => {
4364
const lines = wrapHelpSegments(formHelp, 40);
4465
expect(lines.length).toBeGreaterThan(1);
4566
for (const line of lines) {
46-
expect(line.length).toBeLessThanOrEqual(40);
67+
expect(Bun.stringWidth(line)).toBeLessThanOrEqual(40);
4768
}
4869
expect(lines.join(" · ")).toContain("Up/Down fields");
4970
expect(lines.join(" · ")).toContain("Esc cancel");
@@ -54,7 +75,7 @@ describe("wrapHelpSegments", () => {
5475
const lines = wrapHelpSegments(formHelp, width);
5576
expect(lines.length).toBeGreaterThan(0);
5677
for (const line of lines) {
57-
expect(line.length).toBeLessThanOrEqual(Math.max(8, width));
78+
expect(Bun.stringWidth(line)).toBeLessThanOrEqual(Math.max(8, width));
5879
}
5980
}
6081
});

src/tui/components/form-reflow.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Shared layout helpers for settings / agent forms on narrow terminals.
22

3+
import { stringWidth } from "../view/height.js";
4+
35
/** Stack labels above values below this terminal width. */
46
export const STACK_FORM_COLUMNS = 56;
57

@@ -12,12 +14,27 @@ export function formContentWidth(columns: number, narrow: boolean): number {
1214
/**
1315
* Caret sits at the end of append-only fields; keep the trailing slice so the
1416
* insertion point stays on-screen when the value is longer than the pane.
17+
* Budget is terminal columns (display width), not UTF-16 length — CJK and
18+
* emoji are two cells each.
1519
*/
1620
export function fitTrailingText(text: string, maxWidth: number): string {
1721
if (maxWidth <= 0) return "";
18-
if (text.length <= maxWidth) return text;
22+
if (stringWidth(text) <= maxWidth) return text;
1923
if (maxWidth === 1) return "…";
20-
return `…${text.slice(-(maxWidth - 1))}`;
24+
25+
// Walk code points from the end until the trailing slice fills maxWidth - 1
26+
// (one cell reserved for the leading ellipsis).
27+
const budget = maxWidth - 1;
28+
const units = Array.from(text);
29+
let used = 0;
30+
let start = units.length;
31+
for (let i = units.length - 1; i >= 0; i--) {
32+
const cw = stringWidth(units[i]!);
33+
if (used + cw > budget) break;
34+
used += cw;
35+
start = i;
36+
}
37+
return `…${units.slice(start).join("")}`;
2138
}
2239

2340
/** Pack " · "-separated help segments into lines that fit the pane. */
@@ -29,16 +46,16 @@ export function wrapHelpSegments(segments: readonly string[], maxWidth: number):
2946
for (const segment of segments) {
3047
if (segment.length === 0) continue;
3148
if (current.length === 0) {
32-
current = segment.length > width ? fitTrailingText(segment, width) : segment;
49+
current = stringWidth(segment) > width ? fitTrailingText(segment, width) : segment;
3350
continue;
3451
}
3552
const candidate = `${current} · ${segment}`;
36-
if (candidate.length <= width) {
53+
if (stringWidth(candidate) <= width) {
3754
current = candidate;
3855
continue;
3956
}
4057
lines.push(current);
41-
current = segment.length > width ? fitTrailingText(segment, width) : segment;
58+
current = stringWidth(segment) > width ? fitTrailingText(segment, width) : segment;
4259
}
4360
if (current.length > 0) lines.push(current);
4461
return lines;

src/tui/components/settings-overlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ export function SettingsOverlay({
546546
paddingY={1}
547547
marginX={1}
548548
marginY={1}
549-
width={Math.max(16, columns - 2)}
549+
width={Math.max(1, columns - 2)}
550550
>
551551
<Text bold color={color("accent")}>Settings</Text>
552552
<TabBar activeTab={activeTab} onSwitch={setActiveTab} stack={stack} />

0 commit comments

Comments
 (0)