From 066717f684502e5a09bca11f5e95f9d5655a0ecb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:55:15 -0700 Subject: [PATCH] fix(dashboard): scale preview terminal font instead of CSS transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the PTY grid is owned elsewhere (live pane, phone), the agent terminal preview keeps the source cols/rows and shrank the whole rendered terminal with transform: scale(<1) — resampling every glyph into visible smear (the 'Orca text is blurry' reports; measured advance 8.4 vs the crisp 8.0 in the same screenshot). Shrink the font size instead: same grid, no rewrap, glyphs rasterize natively at the final size. A residual transform remains only for the sliver the half-px font step cannot express and below the 4px floor. The base font is restored when the box grows or the grid claim succeeds, and the appearance effect re-runs the fit after rewriting fontSize. --- .../AgentTerminalPreview.test.tsx | 6 +- .../dashboard-popout/AgentTerminalPreview.tsx | 45 ++++------ .../preview-terminal-fit-scale.test.ts | 83 +++++++++++++++++++ .../preview-terminal-fit-scale.ts | 51 ++++++++++++ .../dashboard-popout/preview-terminal-fit.ts | 62 ++++++++++++++ 5 files changed, 216 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.ts create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-fit.ts diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 76cfa011611..db2ed233307 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -92,7 +92,11 @@ vi.mock('@xterm/xterm', () => ({ return { dispose: vi.fn() } }) - constructor() { + // Real xterm exposes the live options bag (fontSize etc.); the fit path reads and writes it. + options: Record = {} + + constructor(options?: Record) { + this.options = options ?? {} terminalHarness.instances.push(this) } } diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 50d65df9193..cceea6da28b 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -22,6 +22,7 @@ import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { installPreviewTerminalKeyHandler } from './preview-terminal-key-handler' import { createPreviewGridClaim } from './preview-grid-claim' +import { createPreviewFitScheduler } from './preview-terminal-fit' import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview' const PREVIEW_SCROLLBACK_ROWS = 24 @@ -59,6 +60,9 @@ export function AgentTerminalPreview({ }): React.JSX.Element { const containerRef = useRef(null) const terminalRef = useRef(null) + // Why: the appearance effect rewrites options.fontSize from settings, which + // clobbers the fit-scaled font; it re-runs the fit through this ref. + const scheduleFitRef = useRef<(() => void) | null>(null) const settings = useAppStore((state) => state.settings) const systemPrefersDark = useSystemPrefersDark() const macOptionAsAlt = useEffectiveMacOptionAsAlt(settings?.terminalMacOptionAsAlt) @@ -113,36 +117,14 @@ export function AgentTerminalPreview({ let retryTimer: ReturnType | null = null const pendingLivePayloads: Extract[] = [] - const fitToBox = (): void => { - const screen = container.querySelector('.xterm-screen') - const box = container.parentElement - if (!screen || !box || !terminal) { - return - } - const scale = Math.min(1, box.clientWidth / Math.max(1, screen.offsetWidth)) - container.style.transform = scale < 1 ? `scale(${scale})` : '' - // Anchor whichever end keeps the CURSOR row in view when the terminal is - // taller than the box: a fresh shell prompts at the TOP of its screen - // (blind bottom-anchoring clipped it away), while a busy TUI keeps its - // action at the bottom. - const cellHeight = screen.offsetHeight / Math.max(1, terminal.rows) - const cursorBottom = (terminal.buffer.active.cursorY + 1) * cellHeight * scale - const anchorTop = cursorBottom <= box.clientHeight - box.style.alignItems = anchorTop ? 'flex-start' : 'flex-end' - container.style.transformOrigin = anchorTop ? 'top left' : 'bottom left' - } - // Re-fit after every parsed write (cursor may move ends); rAF coalesces. - let fitScheduled = false - const scheduleFit = (): void => { - if (fitScheduled) { - return - } - fitScheduled = true - requestAnimationFrame(() => { - fitScheduled = false - fitToBox() - }) - } + // Why font-scaling (see createPreviewFitScheduler): CSS-downscaling the + // rendered terminal resamples glyph bitmaps into visible smear. + const { scheduleFit } = createPreviewFitScheduler({ + container, + getTerminal: () => terminal, + getBaseFontSize: () => settingsRef.current?.terminalFontSize ?? 14 + }) + scheduleFitRef.current = scheduleFit const gridClaim = createPreviewGridClaim({ ptyId, @@ -408,6 +390,7 @@ export function AgentTerminalPreview({ return () => { disposed = true + scheduleFitRef.current = null if (retryTimer) { clearTimeout(retryTimer) } @@ -438,6 +421,8 @@ export function AgentTerminalPreview({ buildPreviewAppearanceOptions(settings, macOptionAsAlt === 'true') ) syncPreviewTerminalLigatures(terminal, settings) + // The assign above restored the settings font size; re-shrink if the box needs it. + scheduleFitRef.current?.() }, [settings, macOptionAsAlt]) return ( diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.test.ts new file mode 100644 index 00000000000..39b82d81ba4 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { planPreviewFitScale } from './preview-terminal-fit-scale' + +describe('planPreviewFitScale', () => { + it('keeps the base font and no transform when the screen already fits', () => { + const plan = planPreviewFitScale({ + boxWidth: 900, + screenWidth: 800, + currentFontSize: 14, + baseFontSize: 14 + }) + expect(plan.fontSize).toBe(14) + expect(plan.residualScale).toBe(1) + }) + + it('shrinks the font instead of relying on a large transform', () => { + // Source pane ~2x wider than the box: the old code applied scale(0.5) and + // smeared every glyph. The plan must express (almost) all of it as font. + const plan = planPreviewFitScale({ + boxWidth: 500, + screenWidth: 1000, + currentFontSize: 14, + baseFontSize: 14 + }) + expect(plan.fontSize).toBe(7) + expect(plan.residualScale).toBe(1) + }) + + it('leaves only a sub-step residual for non-half-px ratios', () => { + const plan = planPreviewFitScale({ + boxWidth: 530, + screenWidth: 1000, + currentFontSize: 14, + baseFontSize: 14 + }) + // ideal 7.42 → rounds to 7.5; predicted width 1000*7.5/14 ≈ 535.7 → tiny shrink. + expect(plan.fontSize).toBe(7.5) + expect(plan.residualScale).toBeGreaterThan(0.97) + expect(plan.residualScale).toBeLessThan(1) + }) + + it('never scales the font above the settings base when the box grows back', () => { + const plan = planPreviewFitScale({ + boxWidth: 2000, + screenWidth: 500, + currentFontSize: 7, + baseFontSize: 14 + }) + expect(plan.fontSize).toBe(14) + expect(plan.residualScale).toBe(1) + }) + + it('holds steady within tolerance so measure-apply loops cannot oscillate', () => { + // After the first shrink lands, the re-measure produces a near-identical + // ideal; a repeated plan must return the current font unchanged. + const settled = planPreviewFitScale({ + boxWidth: 500, + screenWidth: 501, + currentFontSize: 7, + baseFontSize: 14 + }) + expect(settled.fontSize).toBe(7) + expect(settled.residualScale).toBeGreaterThan(0.99) + }) + + it('falls back to transform below the minimum readable font', () => { + const plan = planPreviewFitScale({ + boxWidth: 100, + screenWidth: 1000, + currentFontSize: 14, + baseFontSize: 14 + }) + expect(plan.fontSize).toBe(4) + // 1000*4/14 ≈ 285.7 wide at the floor → the rest stays a transform. + expect(plan.residualScale).toBeCloseTo(100 / (1000 * (4 / 14)), 3) + }) + + it('is inert on degenerate measurements', () => { + expect( + planPreviewFitScale({ boxWidth: 0, screenWidth: 0, currentFontSize: 14, baseFontSize: 14 }) + ).toEqual({ fontSize: 14, residualScale: 1 }) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.ts new file mode 100644 index 00000000000..b4a1353c60a --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-fit-scale.ts @@ -0,0 +1,51 @@ +/** + * Why font-scaling instead of `transform: scale()`: the preview must keep the + * PTY's real cols/rows (replaying serialized ANSI into different dimensions + * rewraps into garbage), but CSS-downscaling a rendered terminal resamples the + * glyph bitmaps into visible smear — the "Orca text is blurry" reports. Scaling + * the font size instead re-rasterizes every glyph natively at the final size: + * same grid, crisp text. A residual transform remains only for the sliver the + * discrete font-size step cannot express, and for the floor below MIN_FONT_PX. + */ + +/** Below this, glyphs are unreadable either way; cheaper to transform-scale. */ +const MIN_PREVIEW_FONT_PX = 4 +/** Ignore sub-2% drift so measure→apply→measure cannot oscillate. */ +const FONT_FIT_TOLERANCE = 0.02 + +export type PreviewFitScalePlan = { + /** Font size the preview terminal should render at (never above base). */ + fontSize: number + /** Remaining CSS scale after the font change; 1 means no transform needed. */ + residualScale: number +} + +export function planPreviewFitScale(args: { + boxWidth: number + /** Current rendered width of the xterm screen element. */ + screenWidth: number + /** Font size the screen was rendered at (the current terminal option). */ + currentFontSize: number + /** The settings-configured font size the preview must never exceed. */ + baseFontSize: number +}): PreviewFitScalePlan { + const { boxWidth, screenWidth, currentFontSize, baseFontSize } = args + if (boxWidth <= 0 || screenWidth <= 0 || currentFontSize <= 0 || baseFontSize <= 0) { + return { fontSize: currentFontSize > 0 ? currentFontSize : baseFontSize, residualScale: 1 } + } + // Cell width tracks font size near-linearly; one proportional step converges + // within the tolerance and the residual transform absorbs rounding. + const naturalWidthAtBase = (screenWidth / currentFontSize) * baseFontSize + const idealFontSize = baseFontSize * (boxWidth / naturalWidthAtBase) + const fontSize = Math.round(clamp(idealFontSize, MIN_PREVIEW_FONT_PX, baseFontSize) * 2) / 2 + const withinTolerance = + Math.abs(fontSize - currentFontSize) / currentFontSize <= FONT_FIT_TOLERANCE + const effectiveFontSize = withinTolerance ? currentFontSize : fontSize + const predictedWidth = (screenWidth / currentFontSize) * effectiveFontSize + const residualScale = Math.min(1, boxWidth / predictedWidth) + return { fontSize: effectiveFontSize, residualScale } +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-fit.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-fit.ts new file mode 100644 index 00000000000..4781f2ba7b0 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-fit.ts @@ -0,0 +1,62 @@ +import type { Terminal } from '@xterm/xterm' +import { planPreviewFitScale } from './preview-terminal-fit-scale' + +/** + * Fits the preview terminal into its box without resampling blur: shrink the + * font (same cols/rows, so replayed content never rewraps) and keep a CSS + * transform only for the sliver the half-px font step cannot express (see + * planPreviewFitScale). Anchors whichever end keeps the cursor row in view. + */ +export function createPreviewFitScheduler(args: { + container: HTMLElement + getTerminal: () => Terminal | null + getBaseFontSize: () => number +}): { scheduleFit: () => void } { + const { container, getTerminal, getBaseFontSize } = args + + const fitToBox = (): void => { + const terminal = getTerminal() + const screen = container.querySelector('.xterm-screen') + const box = container.parentElement + if (!screen || !box || !terminal) { + return + } + const baseFontSize = getBaseFontSize() + const plan = planPreviewFitScale({ + boxWidth: box.clientWidth, + screenWidth: Math.max(1, screen.offsetWidth), + currentFontSize: terminal.options.fontSize ?? baseFontSize, + baseFontSize + }) + if (plan.fontSize !== terminal.options.fontSize) { + terminal.options.fontSize = plan.fontSize + // Re-measure next frame: the renderer applies the new metrics async. + scheduleFit() + } + const scale = plan.residualScale + container.style.transform = scale < 1 ? `scale(${scale})` : '' + // Anchor whichever end keeps the CURSOR row in view when the terminal is + // taller than the box: a fresh shell prompts at the TOP of its screen + // (blind bottom-anchoring clipped it away), while a busy TUI keeps its + // action at the bottom. + const cellHeight = screen.offsetHeight / Math.max(1, terminal.rows) + const cursorBottom = (terminal.buffer.active.cursorY + 1) * cellHeight * scale + const anchorTop = cursorBottom <= box.clientHeight + box.style.alignItems = anchorTop ? 'flex-start' : 'flex-end' + container.style.transformOrigin = anchorTop ? 'top left' : 'bottom left' + } + + // Re-fit after every parsed write (cursor may move ends); rAF coalesces. + let fitScheduled = false + const scheduleFit = (): void => { + if (fitScheduled) { + return + } + fitScheduled = true + requestAnimationFrame(() => { + fitScheduled = false + fitToBox() + }) + } + return { scheduleFit } +}