fix(dashboard): scale preview terminal font instead of CSS transform

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.
This commit is contained in:
Brennan Benson
2026-08-07 16:55:15 -07:00
parent 7a867f12aa
commit 066717f684
5 changed files with 216 additions and 31 deletions
@@ -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<string, unknown> = {}
constructor(options?: Record<string, unknown>) {
this.options = options ?? {}
terminalHarness.instances.push(this)
}
}
@@ -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<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(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<typeof setTimeout> | null = null
const pendingLivePayloads: Extract<TerminalPreviewDataPayload, { type: 'data' }>[] = []
const fitToBox = (): void => {
const screen = container.querySelector<HTMLElement>('.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 (
@@ -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 })
})
})
@@ -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))
}
@@ -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<HTMLElement>('.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 }
}