fix(dashboard): stabilize preview terminal fitting

This commit is contained in:
Brennan Benson
2026-08-07 17:18:05 -07:00
parent 066717f684
commit f635f88d27
7 changed files with 241 additions and 9 deletions
@@ -129,7 +129,8 @@ export function AgentTerminalPreview({
const gridClaim = createPreviewGridClaim({
ptyId,
container,
getTerminal: () => terminal
getTerminal: () => terminal,
getBaseFontSize: () => settingsRef.current?.terminalFontSize ?? 14
})
// Box growth/shrink (window resize) changes the reachable grid.
const boxResizeObserver =
@@ -30,7 +30,8 @@ describe('createPreviewGridClaim', () => {
const claim = createPreviewGridClaim({
ptyId: 'pty-1',
container,
getTerminal: () => ({ cols: 80, rows: 24 }) as never
getTerminal: () => ({ cols: 80, rows: 24, options: { fontSize: 14 } }) as never,
getBaseFontSize: () => 14
})
claim.schedule()
@@ -63,7 +64,8 @@ describe('createPreviewGridClaim', () => {
const claim = createPreviewGridClaim({
ptyId: 'pty-1',
container,
getTerminal: () => ({ cols: 80, rows: 24 }) as never
getTerminal: () => ({ cols: 80, rows: 24, options: { fontSize: 14 } }) as never,
getBaseFontSize: () => 14
})
claim.schedule()
@@ -79,4 +81,33 @@ describe('createPreviewGridClaim', () => {
expect(fit).toHaveBeenCalledWith('pty-1', 100, 30)
claim.dispose()
})
it('claims from base metrics after fallback fitting shrinks the preview font', async () => {
vi.useFakeTimers()
const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows }))
Object.assign(window, { api: { terminalPreview: { fit } } })
const box = document.createElement('div')
const container = document.createElement('div')
const screen = document.createElement('div')
screen.className = 'xterm-screen'
box.appendChild(container)
container.appendChild(screen)
dimension(box, 'clientWidth', 800)
dimension(box, 'clientHeight', 480)
// The 200x24 source grid was 1600x384 at 14px, then fitted to 800x192 at 7px.
dimension(screen, 'offsetWidth', 800)
dimension(screen, 'offsetHeight', 192)
const claim = createPreviewGridClaim({
ptyId: 'pty-1',
container,
getTerminal: () => ({ cols: 200, rows: 24, options: { fontSize: 7 } }) as never,
getBaseFontSize: () => 14
})
claim.schedule()
await vi.advanceTimersByTimeAsync(200)
expect(fit).toHaveBeenCalledWith('pty-1', 100, 30)
claim.dispose()
})
})
@@ -24,6 +24,7 @@ export function createPreviewGridClaim(args: {
ptyId: string
container: HTMLElement
getTerminal: () => Terminal | null
getBaseFontSize: () => number
}): { schedule: () => void; dispose: () => void } {
let lastRequestedFit: string | null = null
let timer: ReturnType<typeof setTimeout> | null = null
@@ -40,8 +41,12 @@ export function createPreviewGridClaim(args: {
return
}
// offsetWidth/Height are layout dims, unaffected by the scale transform.
const cellWidth = screen.offsetWidth / Math.max(1, terminal.cols)
const cellHeight = screen.offsetHeight / Math.max(1, terminal.rows)
// Font fitting must not make a later claim ask for the oversized source
// grid again; normalize the rendered cells back to settings font metrics.
const currentFontSize = terminal.options.fontSize ?? args.getBaseFontSize()
const fontScale = args.getBaseFontSize() / currentFontSize
const cellWidth = (screen.offsetWidth / Math.max(1, terminal.cols)) * fontScale
const cellHeight = (screen.offsetHeight / Math.max(1, terminal.rows)) * fontScale
if (
!Number.isFinite(cellWidth) ||
!Number.isFinite(cellHeight) ||
@@ -39,6 +39,55 @@ describe('planPreviewFitScale', () => {
expect(plan.residualScale).toBeLessThan(1)
})
it('rounds down when the nearest step would exceed the residual blur budget', () => {
const plan = planPreviewFitScale({
boxWidth: 518,
screenWidth: 1000,
currentFontSize: 14,
baseFontSize: 14
})
// ideal 7.252; rounding to 7.5 would require scale(0.967), so use 7px natively.
expect(plan.fontSize).toBe(7)
expect(plan.residualScale).toBe(1)
})
it('converges instead of alternating across rounded xterm measurements', () => {
let fontSize = 14
const plannedSizes: number[] = []
// These are the observed 84-column widths that previously produced the
// unbounded 14 -> 6 -> 6.5 -> 6 cycle in a 315px box.
const measuredWidths = new Map([
[14, 706],
[6, 302],
[6.5, 328]
])
for (let step = 0; step < 5; step += 1) {
const plan = planPreviewFitScale({
boxWidth: 315,
screenWidth: measuredWidths.get(fontSize)!,
currentFontSize: fontSize,
baseFontSize: 14,
maxFontSize: step === 0 ? 14 : fontSize
})
fontSize = plan.fontSize
plannedSizes.push(fontSize)
}
expect(plannedSizes).toEqual([6, 6, 6, 6, 6])
})
it('holds a same-layout font when residual scaling alone suggests the adjacent step', () => {
const plan = planPreviewFitScale({
boxWidth: 372,
screenWidth: 354,
currentFontSize: 7,
baseFontSize: 14,
maxFontSize: 7
})
expect(plan.fontSize).toBe(7)
expect(plan.residualScale).toBe(1)
})
it('never scales the font above the settings base when the box grows back', () => {
const plan = planPreviewFitScale({
boxWidth: 2000,
@@ -50,6 +99,26 @@ describe('planPreviewFitScale', () => {
expect(plan.residualScale).toBe(1)
})
it('restores the base font after the preview grid claim succeeds', () => {
const fallback = planPreviewFitScale({
boxWidth: 800,
screenWidth: 1600,
currentFontSize: 14,
baseFontSize: 14
})
expect(fallback.fontSize).toBe(7)
// The accepted claim halves the grid, so its 7px rendering is now 400px wide.
const claimed = planPreviewFitScale({
boxWidth: 800,
screenWidth: 400,
currentFontSize: fallback.fontSize,
baseFontSize: 14
})
expect(claimed.fontSize).toBe(14)
expect(claimed.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.
@@ -28,8 +28,10 @@ export function planPreviewFitScale(args: {
currentFontSize: number
/** The settings-configured font size the preview must never exceed. */
baseFontSize: number
/** Same-layout remeasurement caps growth to prevent adjacent-step cycles. */
maxFontSize?: number
}): PreviewFitScalePlan {
const { boxWidth, screenWidth, currentFontSize, baseFontSize } = args
const { boxWidth, screenWidth, currentFontSize, baseFontSize, maxFontSize = baseFontSize } = args
if (boxWidth <= 0 || screenWidth <= 0 || currentFontSize <= 0 || baseFontSize <= 0) {
return { fontSize: currentFontSize > 0 ? currentFontSize : baseFontSize, residualScale: 1 }
}
@@ -37,7 +39,20 @@ export function planPreviewFitScale(args: {
// 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 clampedFontSize = clamp(
idealFontSize,
MIN_PREVIEW_FONT_PX,
Math.min(baseFontSize, maxFontSize)
)
const nearestFontSize = Math.round(clampedFontSize * 2) / 2
const nearestPredictedWidth = (screenWidth / currentFontSize) * nearestFontSize
const nearestResidualScale = Math.min(1, boxWidth / nearestPredictedWidth)
// Prefer the next smaller native step when nearest rounding would leave a
// visible transform; below the readability floor, the fallback is accepted.
const fontSize =
clampedFontSize > MIN_PREVIEW_FONT_PX && nearestResidualScale < 1 - FONT_FIT_TOLERANCE
? Math.floor(clampedFontSize * 2) / 2
: nearestFontSize
const withinTolerance =
Math.abs(fontSize - currentFontSize) / currentFontSize <= FONT_FIT_TOLERANCE
const effectiveFontSize = withinTolerance ? currentFontSize : fontSize
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createPreviewFitScheduler } from './preview-terminal-fit'
function dimension(element: HTMLElement, name: string, get: () => number): void {
Object.defineProperty(element, name, { configurable: true, get })
}
describe('createPreviewFitScheduler', () => {
afterEach(() => vi.unstubAllGlobals())
it('settles the reported adjacent-size cycle without another frame', () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const box = document.createElement('div')
const container = document.createElement('div')
const screen = document.createElement('div')
screen.className = 'xterm-screen'
box.appendChild(container)
container.appendChild(screen)
const terminal = {
cols: 84,
rows: 24,
options: { fontSize: 14 },
buffer: { active: { cursorY: 0 } }
}
const measuredWidths = new Map([
[14, 706],
[6, 302],
[6.5, 328]
])
dimension(box, 'clientWidth', () => 315)
dimension(box, 'clientHeight', () => 480)
dimension(screen, 'offsetWidth', () => measuredWidths.get(terminal.options.fontSize)!)
dimension(screen, 'offsetHeight', () => 384)
const { scheduleFit } = createPreviewFitScheduler({
container,
getTerminal: () => terminal as never,
getBaseFontSize: () => 14
})
scheduleFit()
frames.shift()!(0)
expect(terminal.options.fontSize).toBe(6)
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(terminal.options.fontSize).toBe(6)
expect(frames).toHaveLength(0)
})
it('allows the base font to return after a successful grid claim', () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const box = document.createElement('div')
const container = document.createElement('div')
const screen = document.createElement('div')
screen.className = 'xterm-screen'
box.appendChild(container)
container.appendChild(screen)
const terminal = {
cols: 200,
rows: 24,
options: { fontSize: 7 },
buffer: { active: { cursorY: 0 } }
}
dimension(box, 'clientWidth', () => 800)
dimension(box, 'clientHeight', () => 480)
dimension(
screen,
'offsetWidth',
() => (terminal.cols === 200 ? 800 : 400) * (terminal.options.fontSize / 7)
)
dimension(screen, 'offsetHeight', () => 192)
const { scheduleFit } = createPreviewFitScheduler({
container,
getTerminal: () => terminal as never,
getBaseFontSize: () => 14
})
scheduleFit()
frames.shift()!(0)
expect(terminal.options.fontSize).toBe(7)
terminal.cols = 100
scheduleFit()
frames.shift()!(0)
expect(terminal.options.fontSize).toBe(14)
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(terminal.options.fontSize).toBe(14)
expect(frames).toHaveLength(0)
})
})
@@ -13,6 +13,7 @@ export function createPreviewFitScheduler(args: {
getBaseFontSize: () => number
}): { scheduleFit: () => void } {
const { container, getTerminal, getBaseFontSize } = args
let lastFitContext: { boxWidth: number; cols: number; baseFontSize: number } | null = null
const fitToBox = (): void => {
const terminal = getTerminal()
@@ -22,12 +23,20 @@ export function createPreviewFitScheduler(args: {
return
}
const baseFontSize = getBaseFontSize()
const currentFontSize = terminal.options.fontSize ?? baseFontSize
const allowFontGrowth =
!lastFitContext ||
box.clientWidth > lastFitContext.boxWidth ||
terminal.cols !== lastFitContext.cols ||
baseFontSize !== lastFitContext.baseFontSize
const plan = planPreviewFitScale({
boxWidth: box.clientWidth,
screenWidth: Math.max(1, screen.offsetWidth),
currentFontSize: terminal.options.fontSize ?? baseFontSize,
baseFontSize
currentFontSize,
baseFontSize,
maxFontSize: allowFontGrowth ? baseFontSize : currentFontSize
})
lastFitContext = { boxWidth: box.clientWidth, cols: terminal.cols, baseFontSize }
if (plan.fontSize !== terminal.options.fontSize) {
terminal.options.fontSize = plan.fontSize
// Re-measure next frame: the renderer applies the new metrics async.