mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 11:52:55 +08:00
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:
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user