From 157fc70e808e76ab04b6e6d690b319303a081b1e Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Fri, 18 Sep 2026 00:08:21 +0800 Subject: [PATCH] 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 --- .../src/advice/widget.ts | 19 ++++++------ .../test/advice.test.ts | 29 ++++++++++++++----- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/pi-permission-ai-judge/src/advice/widget.ts b/packages/pi-permission-ai-judge/src/advice/widget.ts index a50d8aa..5d67e09 100644 --- a/packages/pi-permission-ai-judge/src/advice/widget.ts +++ b/packages/pi-permission-ai-judge/src/advice/widget.ts @@ -1,4 +1,4 @@ -import { truncateToWidth } from "@earendil-works/pi-tui"; +import { wrapTextWithAnsi } from "@earendil-works/pi-tui"; import type { HighRiskCategory } from "../authority/highrisk"; /** @@ -79,18 +79,19 @@ function sanitizeLine(text: string, maxChars: number): string { 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 * 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) { - return ""; + return [""]; } - return truncateToWidth(styledLine, width, "…"); + return wrapTextWithAnsi(styledLine, width); } export interface AdviceLines { @@ -168,8 +169,8 @@ export class AdvicePresenter { const { lines, colors } = formatAdvice(view, focus); this.ui.setWidget(ADVICE_WIDGET_KEY, (_tui, theme) => ({ render: (width: number) => - lines.map((line, index) => - clampToWidth( + lines.flatMap((line, index) => + wrapToWidth( theme.fg(colors[index] ?? "dim", line), width, ), diff --git a/packages/pi-permission-ai-judge/test/advice.test.ts b/packages/pi-permission-ai-judge/test/advice.test.ts index 3c68f4f..f8fb1e6 100644 --- a/packages/pi-permission-ai-judge/test/advice.test.ts +++ b/packages/pi-permission-ai-judge/test/advice.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from "vitest"; 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 { ADVICE_WIDGET_KEY, AdvicePresenter, @@ -143,20 +150,24 @@ describe("AdvicePresenter", () => { expect(render(component, 200)).toEqual([ "\x1b[35mai-judge defer — ambiguous intent\x1b[0m", ]); - const clamped = render(component, 10)[0]!; - expect(clamped.startsWith("\x1b[35mai-judge ")).toBe(true); - expect(clamped.endsWith("…")); - expect(visibleWidth(clamped)).toBeLessThanOrEqual(10); + const clamped = render(component, 10); + expect(clamped.length).toBeGreaterThan(1); + for (const line of clamped) { + expect(visibleWidth(line)).toBeLessThanOrEqual(10); + } + const joined = clamped.map(stripAnsi).join("").replace(/\s+/g, ""); + expect(joined).toContain("ambiguousintent"); 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 presenter = new AdvicePresenter(ui, true); + const reason = "命令会重写已发布的历史记录且用户意图未确立".repeat(10); presenter.present("req-1", { state: "judgment", verdict: "defer", - reason: "命令会重写已发布的历史记录且用户意图未确立".repeat(10), + reason, shadow: false, }); const factory = ui.calls[0]!.content as ( @@ -169,8 +180,12 @@ describe("AdvicePresenter", () => { for (const line of lines) { 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", () => {