From 80108d3a7cb606db7c23317f04ed3fd95a76c55f Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Fri, 18 Sep 2026 00:02:16 +0800 Subject: [PATCH] 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 --- packages/pi-permission-ai-judge/package.json | 2 ++ .../src/advice/widget.ts | 24 +++++++------ .../test/advice.test.ts | 36 ++++++++++++++++--- pnpm-lock.yaml | 3 ++ 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/packages/pi-permission-ai-judge/package.json b/packages/pi-permission-ai-judge/package.json index fe5b881..49c9cd9 100644 --- a/packages/pi-permission-ai-judge/package.json +++ b/packages/pi-permission-ai-judge/package.json @@ -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", diff --git a/packages/pi-permission-ai-judge/src/advice/widget.ts b/packages/pi-permission-ai-judge/src/advice/widget.ts index 79b923e..a50d8aa 100644 --- a/packages/pi-permission-ai-judge/src/advice/widget.ts +++ b/packages/pi-permission-ai-judge/src/advice/widget.ts @@ -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: () => {}, })); diff --git a/packages/pi-permission-ai-judge/test/advice.test.ts b/packages/pi-permission-ai-judge/test/advice.test.ts index 94d4b3c..3c68f4f 100644 --- a/packages/pi-permission-ai-judge/test/advice.test.ts +++ b/packages/pi-permission-ai-judge/test/advice.test.ts @@ -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([ - "ai-judge defer — ambiguous intent", + "\x1b[35mai-judge defer — ambiguous intent\x1b[0m", ]); - expect(render(component, 10)[0]).toBe( - "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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e78892..f0be209 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)