fix(ai-judge): truncate advice widget with visible-width awareness

- delegate widget line truncation to pi-tui truncateToWidth so ANSI
  styling and double-width CJK reasons count visible cells only
- replace the code-point clamp that crashed the TUI when a Chinese
  reason rendered wider than the terminal
- add @earendil-works/pi-tui peer and dev dependencies
- cover CJK reasons at several terminal widths in tests
This commit is contained in:
2026-09-18 00:02:17 +08:00
parent 962d90dec8
commit 80108d3a7c
4 changed files with 50 additions and 15 deletions
@@ -31,11 +31,13 @@
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"@gotgenes/pi-permission-system": ">=32.0.0"
},
"devDependencies": {
"@earendil-works/pi-ai": "^0.85.1",
"@earendil-works/pi-coding-agent": "^0.85.1",
"@earendil-works/pi-tui": "^0.85.1",
"@gotgenes/pi-permission-system": "^32.0.2",
"@types/node": "^26.5.1",
"@vitest/coverage-istanbul": "5.0.1",
@@ -1,3 +1,4 @@
import { truncateToWidth } from "@earendil-works/pi-tui";
import type { HighRiskCategory } from "../authority/highrisk";
/**
@@ -78,18 +79,18 @@ function sanitizeLine(text: string, maxChars: number): string {
return `${chars.slice(0, maxChars - 1).join("")}…`;
}
/** Clamp a rendered line to the live terminal width. */
function clampToWidth(line: string, width: number): string {
/** Clamp a rendered line to the live terminal width.
*
* Delegates to pi-tui's ANSI- and wide-char-aware truncation: theme-styled
* text counts only visible cells, so CJK reasons cannot overflow the
* terminal (the crash class pi-tui reports as "Rendered line exceeds
* terminal width").
*/
function clampToWidth(styledLine: string, width: number): string {
if (width <= 0) {
return "";
}
const chars = [...line];
if (chars.length <= width) {
return line;
}
return width >= 2
? `${chars.slice(0, width - 1).join("")}…`
: chars.slice(0, width).join("");
return truncateToWidth(styledLine, width, "…");
}
export interface AdviceLines {
@@ -168,7 +169,10 @@ export class AdvicePresenter {
this.ui.setWidget(ADVICE_WIDGET_KEY, (_tui, theme) => ({
render: (width: number) =>
lines.map((line, index) =>
theme.fg(colors[index] ?? "dim", clampToWidth(line, width)),
clampToWidth(
theme.fg(colors[index] ?? "dim", line),
width,
),
),
invalidate: () => {},
}));
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { visibleWidth } from "@earendil-works/pi-tui";
import {
ADVICE_WIDGET_KEY,
AdvicePresenter,
@@ -8,8 +9,9 @@ import {
type AdviceWidgetUi,
} from "../src/advice/widget";
/** ANSI-styled fake: invisible to truncateToWidth, like a real theme. */
const theme: AdviceTheme = {
fg: (color, text) => `<${color}>${text}</>`,
fg: (_color, text) => `\x1b[35m${text}\x1b[0m`,
};
function render(component: AdviceComponent, width = 200): string[] {
@@ -139,14 +141,38 @@ describe("AdvicePresenter", () => {
) => AdviceComponent;
const component = factory({}, theme);
expect(render(component, 200)).toEqual([
"<accent>ai-judge defer — ambiguous intent</>",
"\x1b[35mai-judge defer — ambiguous intent\x1b[0m",
]);
expect(render(component, 10)[0]).toBe(
"<accent>ai-judge …</>",
);
const clamped = render(component, 10)[0]!;
expect(clamped.startsWith("\x1b[35mai-judge ")).toBe(true);
expect(clamped.endsWith("…"));
expect(visibleWidth(clamped)).toBeLessThanOrEqual(10);
component.invalidate();
});
it("never renders wider than the terminal for CJK reasons", () => {
const ui = recordingUi();
const presenter = new AdvicePresenter(ui, true);
presenter.present("req-1", {
state: "judgment",
verdict: "defer",
reason: "命令会重写已发布的历史记录且用户意图未确立".repeat(10),
shadow: false,
});
const factory = ui.calls[0]!.content as (
tui: unknown,
theme: AdviceTheme,
) => AdviceComponent;
const component = factory({}, theme);
for (const width of [5, 20, 80, 120]) {
const lines = render(component, width);
for (const line of lines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
}
expect(visibleWidth(render(component, 20)[0]!)).toBeGreaterThan(0);
});
it("is a no-op end to end when disabled", () => {
const ui = recordingUi();
const presenter = new AdvicePresenter(ui, false);
+3
View File
@@ -223,6 +223,9 @@ importers:
'@earendil-works/pi-coding-agent':
specifier: ^0.85.1
version: 0.85.1(supports-color@7.2.0)(ws@8.21.3)(zod@4.6.5)
'@earendil-works/pi-tui':
specifier: ^0.85.1
version: 0.85.1
'@gotgenes/pi-permission-system':
specifier: ^32.0.2
version: 32.0.2(@earendil-works/pi-coding-agent@0.85.1)(@earendil-works/pi-tui@0.85.1)