From a8add580d9a0b3b5273c5fe876aea1c08321bd4e Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 4 Jul 2026 03:54:55 +0000 Subject: [PATCH] fix(snapcompact): selected silver for cjk auto shape - Made resolveShapeForText choose silver16-bw for CJK-heavy auto transcripts while preserving explicit variants and unsafe glyph protection. - Added silver16-bw to the snapcompact shape settings submenu and renamed unsupported-glyph warnings. - Covered auto shape selection, explicit variant precedence, unsafe glyph scans, and settings option parity. Fixes #4486 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../src/config/settings-schema.ts | 5 ++ .../coding-agent/src/session/agent-session.ts | 10 ++-- .../test/agent-session-handoff.test.ts | 2 +- ...-session-snapcompact-auto-fallback.test.ts | 12 ++--- .../modes/components/settings-layout.test.ts | 10 ++++ packages/snapcompact/CHANGELOG.md | 4 ++ packages/snapcompact/src/snapcompact.ts | 38 ++++++++++++--- packages/snapcompact/test/snapcompact.test.ts | 46 +++++++++++++++---- 9 files changed, 104 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8de3614a0..e62939bca 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the `snapcompact.shape` settings UI to expose `silver16-bw` and updated snapcompact renderability warnings to report unsupported glyphs instead of a misleading non-ASCII rate. ([#4486](https://github.com/can1357/oh-my-pi/issues/4486)) + ## [16.3.5] - 2026-07-04 ### Fixed diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 2316034da..4d7f4a538 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -2268,6 +2268,11 @@ export const SETTINGS_SCHEMA = { description: "8x13 glyphs on an 11x16 cell — extra letter spacing so characters don't merge. Default for Anthropic.", }, + { + value: "silver16-bw", + label: "Silver 16, CJK", + description: "Embedded Silver TrueType font on a 16px grid for CJK and other non-Latin text.", + }, { value: "doc-8on16-bw", label: "Doc 8on16, black", diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f271f21bf..f0311d4cf 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -9529,11 +9529,11 @@ export class AgentSession { const percent = (renderScan.unrenderableRatio * 100).toFixed(1); this.emitNotice( "warning", - `snapcompact disabled: high non-ASCII rate detected (${percent}%). No LLM fallback was attempted.`, + `snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%). No LLM fallback was attempted.`, "compaction", ); throw new Error( - `snapcompact cannot render this conversation locally: high non-ASCII rate detected (${percent}%).`, + `snapcompact cannot render this conversation locally: unsupported characters for selected snapcompact font (${percent}%).`, ); } } @@ -12188,7 +12188,7 @@ export class AgentSession { // + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE // per frame; #computeSnapcompactMaxFrames sizes the frame cap from the // live window so we don't run snapcompact just to overflow every threshold - // tick. Any local blocker (non-ASCII transcript, kept-history too large, + // tick. Any local blocker (unsupported snapcompact glyphs, kept-history too large, // post-render overflow) downgrades auto maintenance to a context-full LLM // summary instead of wedging the session (#3659) — auto runs the default // strategy on the user's behalf, so a fallback that lets the session keep @@ -12210,11 +12210,11 @@ export class AgentSession { const renderScan = snapcompact.scanRenderability(probeText, { shape }); if (!renderScan.isSafe) { const percent = (renderScan.unrenderableRatio * 100).toFixed(1); - logger.warn("Snapcompact disabled: high non-ASCII rate detected", { + logger.warn("Snapcompact disabled: unsupported characters for selected snapcompact font", { model: this.model?.id, unrenderableRatio: renderScan.unrenderableRatio, }); - snapcompactBlocker = `snapcompact disabled: high non-ASCII rate detected (${percent}%); using context-full auto-compaction instead.`; + snapcompactBlocker = `snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%); using context-full auto-compaction instead.`; } else { const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings); if (maxFrames < 1) { diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 516a4f906..3cb9a6288 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -524,7 +524,7 @@ describe("AgentSession handoff", () => { (event): event is Extract => event.type === "notice" && event.source === "compaction" && - event.message.startsWith("snapcompact disabled: high non-ASCII rate detected"), + event.message.startsWith("snapcompact disabled: unsupported characters for selected snapcompact font"), ); expect(downgradeNotice?.message).toContain("using context-full auto-compaction instead."); }); diff --git a/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts b/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts index 0a9af2702..c8f2eb12b 100644 --- a/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts @@ -147,8 +147,8 @@ describe("AgentSession auto-snapcompact local-blocker fallback", () => { }); }); - it("downgrades to context-full when the transcript is too non-ASCII for snapcompact to render", async () => { - tempDir = TempDir.createSync("@pi-snapcompact-non-ascii-"); + it("downgrades to context-full when unsupported glyphs make snapcompact unsafe", async () => { + tempDir = TempDir.createSync("@pi-snapcompact-unsupported-glyphs-"); authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); const harness = await createHarness(tempDir, authStorage, { activeModel: { provider: "aimlapi", id: "claude-sonnet-4-5-20250929" }, @@ -167,11 +167,11 @@ describe("AgentSession auto-snapcompact local-blocker fallback", () => { expect(result.action).toBe("context-full"); expect(result.errorMessage).toBeUndefined(); expect(compactionModule.compact).toHaveBeenCalled(); - const cjkNotice = harness.notices.find(message => - message.startsWith("snapcompact disabled: high non-ASCII rate detected"), + const unsupportedGlyphNotice = harness.notices.find(message => + message.startsWith("snapcompact disabled: unsupported characters for selected snapcompact font"), ); - expect(cjkNotice).toBeDefined(); - expect(cjkNotice).toContain("using context-full auto-compaction instead."); + expect(unsupportedGlyphNotice).toBeDefined(); + expect(unsupportedGlyphNotice).toContain("using context-full auto-compaction instead."); expect(harness.sessionManager.getBranch().find(entry => entry.type === "compaction")).toMatchObject({ type: "compaction", summary: "compacted", diff --git a/packages/coding-agent/test/modes/components/settings-layout.test.ts b/packages/coding-agent/test/modes/components/settings-layout.test.ts index dbcbd1b0e..72c357be3 100644 --- a/packages/coding-agent/test/modes/components/settings-layout.test.ts +++ b/packages/coding-agent/test/modes/components/settings-layout.test.ts @@ -70,6 +70,16 @@ describe("settings layout", () => { }); }); + it("exposes every accepted snapcompact shape in the settings submenu", () => { + const def = getSettingsForTab("context").find(def => def.path === "snapcompact.shape"); + + expect(def?.type).toBe("submenu"); + if (def?.type !== "submenu") throw new Error("snapcompact.shape should render as a submenu"); + const values = def.options.map(option => option.value); + expect(values).toContain("silver16-bw"); + expect(values).toEqual([...SETTINGS_SCHEMA["snapcompact.shape"].values]); + }); + it("hides advisor dependent settings when advisor is disabled", () => { const advisorDependentPaths: SettingPath[] = ["advisor.subagents", "advisor.syncBacklog", "advisor.immuneTurns"]; const advisorDependentPathSet = new Set(advisorDependentPaths); diff --git a/packages/snapcompact/CHANGELOG.md b/packages/snapcompact/CHANGELOG.md index 1ca6f85fa..398ec60cf 100644 --- a/packages/snapcompact/CHANGELOG.md +++ b/packages/snapcompact/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `resolveShapeForText(..., "auto")` to select the existing `silver16-bw` shape for CJK-heavy transcript text while preserving explicit shape overrides. ([#4486](https://github.com/can1357/oh-my-pi/issues/4486)) + ## [16.2.8] - 2026-06-30 ### Fixed diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts index dfcc03560..4793cb262 100644 --- a/packages/snapcompact/src/snapcompact.ts +++ b/packages/snapcompact/src/snapcompact.ts @@ -385,15 +385,39 @@ export function resolveShape(model?: ShapeTarget, variant?: ShapeVariantName | " return priceShape(ideal?.frameSize ? { ...base, frameSize: ideal.frameSize } : base, family); } +const CJK_HEAVY_MIN_WIDE_CHARS = 8; +const CJK_HEAVY_WIDE_RATIO = 0.25; + +function isCjkHeavyText(text: string): boolean { + const chars = normalizedInputChars(text); + let graphicChars = 0; + let wideChars = 0; + for (const ch of chars) { + if (ch === " " || ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) continue; + const cp = ch.codePointAt(0); + if (cp === undefined || UNRENDERABLE.test(ch)) continue; + graphicChars++; + if (isWideCodePoint(cp)) wideChars++; + } + return wideChars >= CJK_HEAVY_MIN_WIDE_CHARS && wideChars / graphicChars >= CJK_HEAVY_WIDE_RATIO; +} + /** - * Pick the frame shape for `text` without changing the selected shape. - * - * Glyph-level Silver fallback happens during normalization/rendering, so this - * helper exists for callers that need a text-aware API name while preserving - * explicit and provider-selected shapes. + * Pick the frame shape for `text`. Explicit variants remain forced. Auto first + * resolves the model/provider default, then selects the Silver CJK grid when + * the default font cannot safely render the text or wide CJK glyphs dominate + * the transcript and Silver can render it safely. */ -export function resolveShapeForText(_text: string, model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape { - return resolveShape(model, variant); +export function resolveShapeForText(text: string, model?: ShapeTarget, variant?: ShapeVariantName | "auto"): Shape { + const shape = resolveShape(model, variant); + if (variant && variant !== "auto") return shape; + const silver = resolveShape(model, "silver16-bw"); + if (!scanRenderability(text, { shape }).isSafe) { + return scanRenderability(text, { shape: silver }).isSafe ? silver : shape; + } + return shape.font !== "silver" && isCjkHeavyText(text) && scanRenderability(text, { shape: silver }).isSafe + ? silver + : shape; } // ============================================================================ diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index 34625148c..a11fd4ed3 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -372,15 +372,45 @@ describe("shape resolution", () => { expect(snapcompact.isShape({ ...snapcompact.SHAPES.openai, imageDetail: "original" })).toBe(true); }); - it("keeps selected shapes while Silver fallback covers non-Latin glyphs", () => { - const text = "こんにちは 你好 안녕"; + it("keeps bitmap shapes render-safe via Silver fallback while CJK-heavy auto archives use Silver", () => { + const cjkHeavyText = "こんにちは 你好 안녕 世界 한국어"; + const silver = snapcompact.resolveShape({ api: "anthropic-messages" }, "silver16-bw"); + + expect(snapcompact.scanRenderability(cjkHeavyText).isSafe).toBe(true); + expect(snapcompact.scanRenderability(cjkHeavyText, { shape: silver }).isSafe).toBe(true); + expect(snapcompact.normalize(cjkHeavyText)).toBe(cjkHeavyText); + expect(snapcompact.normalize(cjkHeavyText, { shape: silver })).toBe(cjkHeavyText); + expect(snapcompact.resolveShapeForText(cjkHeavyText, { api: "anthropic-messages" }, "auto")).toEqual(silver); + }); + + it("keeps ASCII-heavy auto archives on the provider/model shape", () => { + const model = { api: "openai-responses" as const, id: "gpt-5.5" }; + const expected = snapcompact.resolveShape(model, "auto"); + const actual = snapcompact.resolveShapeForText( + "function render(value: string) { return value.trim().toLowerCase(); }", + model, + "auto", + ); + + expect(actual).toEqual(expected); + expect(actual.font).not.toBe("silver"); + }); + + it("respects explicit non-auto variants even for CJK-heavy text", () => { + const model = { api: "anthropic-messages" as const }; + const expected = snapcompact.resolveShape(model, "8on16-bw"); + const actual = snapcompact.resolveShapeForText("こんにちは 你好 안녕 世界 한국어", model, "8on16-bw"); + + expect(actual).toEqual(expected); + expect(actual.font).not.toBe("silver"); + }); + + it("reports unsupported CJK ideographs unsafe even with the Silver shape selected", () => { const silver = snapcompact.resolveShape(undefined, "silver16-bw"); - expect(snapcompact.scanRenderability(text).isSafe).toBe(true); - expect(snapcompact.scanRenderability(text, { shape: silver }).isSafe).toBe(true); - expect(snapcompact.normalize(text)).toBe(text); - expect(snapcompact.normalize(text, { shape: silver })).toBe(text); - expect(snapcompact.resolveShapeForText(text).font).not.toBe("silver"); - expect(snapcompact.resolveShapeForText("plain ascii").font).not.toBe("silver"); + const res = snapcompact.scanRenderability("\u{31350}".repeat(12), { shape: silver }); + + expect(res.isSafe).toBe(false); + expect(res.unrenderableRatio).toBe(1); }); it("images forwards the per-frame detail hint", () => {