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
This commit is contained in:
roboomp
2026-07-04 03:54:55 +00:00
parent fef9b62334
commit a8add580d9
9 changed files with 104 additions and 27 deletions
+4
View File
@@ -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
@@ -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",
@@ -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) {
@@ -524,7 +524,7 @@ describe("AgentSession handoff", () => {
(event): event is Extract<AgentSessionEvent, { type: "notice" }> =>
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.");
});
@@ -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",
@@ -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);
+4
View File
@@ -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
+31 -7
View File
@@ -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;
}
// ============================================================================
+38 -8
View File
@@ -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", () => {