mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user