fix(ai-judge): wrap advice widget lines instead of truncating

- replace render-time truncation with wrapTextWithAnsi so the full
  reason stays visible on narrow terminals and CJK text
- cap content length only at the sanitize layer, never at render
- verify wrapping preserves reason content in tests
This commit is contained in:
2026-09-18 00:08:21 +08:00
parent 80108d3a7c
commit 157fc70e80
2 changed files with 32 additions and 16 deletions
@@ -1,4 +1,4 @@
import { truncateToWidth } from "@earendil-works/pi-tui"; import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
import type { HighRiskCategory } from "../authority/highrisk"; import type { HighRiskCategory } from "../authority/highrisk";
/** /**
@@ -79,18 +79,19 @@ function sanitizeLine(text: string, maxChars: number): string {
return `${chars.slice(0, maxChars - 1).join("")}…`; return `${chars.slice(0, maxChars - 1).join("")}…`;
} }
/** Clamp a rendered line to the live terminal width. /** Wrap a rendered line to the live terminal width.
* *
* Delegates to pi-tui's ANSI- and wide-char-aware truncation: theme-styled * Delegates to pi-tui's ANSI- and wide-char-aware wrapping: theme-styled
* text counts only visible cells, so CJK reasons cannot overflow the * text counts only visible cells, so CJK reasons cannot overflow the
* terminal (the crash class pi-tui reports as "Rendered line exceeds * terminal (the crash class pi-tui reports as "Rendered line exceeds
* terminal width"). * terminal width"), and long reasons wrap to further lines instead of
* being cut — the human sees the whole reason, not a prefix.
*/ */
function clampToWidth(styledLine: string, width: number): string { function wrapToWidth(styledLine: string, width: number): string[] {
if (width <= 0) { if (width <= 0) {
return ""; return [""];
} }
return truncateToWidth(styledLine, width, "…"); return wrapTextWithAnsi(styledLine, width);
} }
export interface AdviceLines { export interface AdviceLines {
@@ -168,8 +169,8 @@ export class AdvicePresenter {
const { lines, colors } = formatAdvice(view, focus); const { lines, colors } = formatAdvice(view, focus);
this.ui.setWidget(ADVICE_WIDGET_KEY, (_tui, theme) => ({ this.ui.setWidget(ADVICE_WIDGET_KEY, (_tui, theme) => ({
render: (width: number) => render: (width: number) =>
lines.map((line, index) => lines.flatMap((line, index) =>
clampToWidth( wrapToWidth(
theme.fg(colors[index] ?? "dim", line), theme.fg(colors[index] ?? "dim", line),
width, width,
), ),
@@ -1,5 +1,12 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { visibleWidth } from "@earendil-works/pi-tui"; import { visibleWidth } from "@earendil-works/pi-tui";
/** Tail length used to prove wrapping loses no content. */
const REASON_TAIL = 10;
function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, "");
}
import { import {
ADVICE_WIDGET_KEY, ADVICE_WIDGET_KEY,
AdvicePresenter, AdvicePresenter,
@@ -143,20 +150,24 @@ describe("AdvicePresenter", () => {
expect(render(component, 200)).toEqual([ expect(render(component, 200)).toEqual([
"\x1b[35mai-judge defer — ambiguous intent\x1b[0m", "\x1b[35mai-judge defer — ambiguous intent\x1b[0m",
]); ]);
const clamped = render(component, 10)[0]!; const clamped = render(component, 10);
expect(clamped.startsWith("\x1b[35mai-judge ")).toBe(true); expect(clamped.length).toBeGreaterThan(1);
expect(clamped.endsWith("…")); for (const line of clamped) {
expect(visibleWidth(clamped)).toBeLessThanOrEqual(10); expect(visibleWidth(line)).toBeLessThanOrEqual(10);
}
const joined = clamped.map(stripAnsi).join("").replace(/\s+/g, "");
expect(joined).toContain("ambiguousintent");
component.invalidate(); component.invalidate();
}); });
it("never renders wider than the terminal for CJK reasons", () => { it("never renders wider than the terminal and loses no CJK text", () => {
const ui = recordingUi(); const ui = recordingUi();
const presenter = new AdvicePresenter(ui, true); const presenter = new AdvicePresenter(ui, true);
const reason = "命令会重写已发布的历史记录且用户意图未确立".repeat(10);
presenter.present("req-1", { presenter.present("req-1", {
state: "judgment", state: "judgment",
verdict: "defer", verdict: "defer",
reason: "命令会重写已发布的历史记录且用户意图未确立".repeat(10), reason,
shadow: false, shadow: false,
}); });
const factory = ui.calls[0]!.content as ( const factory = ui.calls[0]!.content as (
@@ -169,8 +180,12 @@ describe("AdvicePresenter", () => {
for (const line of lines) { for (const line of lines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width); expect(visibleWidth(line)).toBeLessThanOrEqual(width);
} }
expect(visibleWidth(lines[0]!)).toBeGreaterThan(0);
} }
expect(visibleWidth(render(component, 20)[0]!)).toBeGreaterThan(0); // Wrapping, not truncation: the full (sanitized) reason survives.
const sanitized = [...reason].slice(0, 180).join("");
const joined = render(component, 20).map(stripAnsi).join("");
expect(joined).toContain(sanitized.slice(-REASON_TAIL));
}); });
it("is a no-op end to end when disabled", () => { it("is a no-op end to end when disabled", () => {