Skip to content

Text Sizing Protocol (OSC 66)

The text sizing protocol (OSC 66) lets the application tell the terminal exactly how many cells a character should occupy. This solves measurement/rendering mismatches for two categories of characters:

  1. Private Use Area (PUA) — nerdfont icons and powerline symbols that string-width reports as 1-cell but terminals render as 2-cell
  2. Text-presentation emoji — characters like warning sign, checkmark, airplane that have ambiguous width across terminals

The Problem

PUA Characters

Nerdfont icons (U+E000-U+F8FF) cause layout misalignment:

  1. string-width says the icon is 1 cell wide (per Unicode EAW tables)
  2. The terminal renders the icon as 2 cells wide (because the font's glyph is double-width)
  3. Text after the icon is placed at the wrong column, causing truncation

Text-Presentation Emoji

Characters like \u26A0 (warning sign), \u2611 (checkmark), \u2708 (airplane) are Extended_Pictographic but do NOT have the Emoji_Presentation property. Terminals render them as 2-wide emoji glyphs, but string-width reports them as 1 cell.

The Solution

With OSC 66, the app wraps ambiguous-width characters in a sequence that specifies the exact width:

ESC ] 66 ; w=2 ; <character> BEL

When both the layout engine and the terminal agree on 2 cells, alignment is correct.

Enabling Text Sizing

tsx
import { run } from "@silvery/ag-term/runtime"

// Auto-detect: enable if terminal supports it (Kitty 0.40+, Ghostty)
await run(<App />, { textSizing: "auto" })

// Force enable
await run(<App />, { textSizing: true })

Programmatic Control

typescript
import { createMeasurer, runWithMeasurer, isTextSizingEnabled } from "@silvery/ag-term"

// Create a measurer with text sizing enabled
const measurer = createMeasurer({ textSizing: true })

// Use the measurer for width calculations
measurer.graphemeWidth("\uE0B0") // 2 (PUA treated as double-width)
measurer.displayWidth("icon\uE0B0text") // accounts for PUA width

// Scope a measurer for pipeline operations (output phase, etc.)
runWithMeasurer(measurer, () => {
  // All module-level functions (graphemeWidth, displayWidth, etc.)
  // use this measurer within the callback
})

// Check current state (module-level default)
console.log(isTextSizingEnabled()) // false (default)

Terminal Support

TerminalVersionStatus
Kittyv0.40+Full support
GhosttyallFull support
WezTerm--Not yet
iTerm2--Not yet
Alacritty--Not yet

Read createTerminalProfile().caps.textSizing for a fast synchronous caps check, or call detectTextSizingSupport() for definitive cursor-position-based detection.

Font Scale (OSC 66 s= parameter)

Beyond cell-width control, OSC 66 also supports font size scaling via the s= parameter. This lets you render headings at 2x, annotations at 0.5x, and body text at 1x — all in one terminal session.

textScaled(scale: number): string

Generate an OSC 66 escape sequence to set the text scale (font size multiplier). The scale applies to all subsequent text until reset or changed.

typescript
import { textScaled, resetTextScale } from "@silvery/ag-term"

// Set 2x size for a heading
process.stdout.write(textScaled(2) + "Big Heading" + resetTextScale())

// Set 0.5x for annotations
process.stdout.write(textScaled(0.5) + "fine print" + resetTextScale())

Scale values:

  • 3.0 — triple size (display titles)
  • 2.0 — double size (headings)
  • 1.5 — large (subheadings)
  • 1.0 — normal (body text, default)
  • 0.75 — slightly smaller
  • 0.5 — half size (annotations, captions)
  • 0.25 — quarter size (fine print)

resetTextScale(): string

Generate an OSC 66 escape sequence to reset text scale to default (1.0). Equivalent to textScaled(1).

textSize prop

The textSize style prop is available on Box and Text components. It declares the desired OSC 66 font scale for the node's content. Currently a hint for terminal-aware renderers — the standard terminal pipeline emits the escape sequences around the node's rendered content on terminals that support OSC 66.

tsx
<Text textSize={2} bold color="$primary">
  Large Heading
</Text>
<Text textSize={0.5} color="$muted">
  Fine print annotation
</Text>

API Reference

textSized(text: string, width: number): string

Wrap text in an OSC 66 sequence that tells the terminal to render it in exactly width cells.

typescript
import { textSized } from "@silvery/ag-term"

textSized("\uE0B0", 2) // "\x1b]66;w=2;\uE0B0\x07"

isPrivateUseArea(cp: number): boolean

Check if a code point is in the Private Use Area. Covers BMP PUA (U+E000-U+F8FF) and Supplementary PUA-A/B.

typescript
import { isPrivateUseArea } from "@silvery/ag-term"

isPrivateUseArea(0xe0b0) // true (Powerline separator)
isPrivateUseArea(0x41) // false (ASCII 'A')

createTerminalProfile().caps.textSizing: boolean

Fast synchronous check on the canonical terminal profile. The flag is computed from TERM=xterm-kitty + TERM_PROGRAM_VERSION parsing and returns true for Kitty v0.40+. (Ghostty's OSC 66 support is currently broken and reports false until a fix lands — see the notes in detectTerminalProfileFromEnv.)

getTerminalFingerprint(emulator): string

Build a program@version cache key from TerminalEmulator (or any structural { program, version }). Used together with detectTextSizingSupport so the probe result is cached per terminal type and doesn't re-run for the same terminal in the same process. Typical usage: getTerminalFingerprint(term.emulator) or getTerminalFingerprint(profile.emulator).

detectTextSizingSupport(write, read, fingerprint, timeout?): Promise<{ supported, widthOnly }>

Definitive detection using cursor position reports. Sends an OSC 66 test sequence and checks if the cursor advanced by the expected amount. fingerprint keys the result cache — pass the same value from getTerminalFingerprint(term.emulator) across probes in the same process.

createMeasurer(opts: { textSizing: boolean }): Measurer

Create a measurer instance with the given text sizing configuration. When textSizing is true:

  • measurer.graphemeWidth() returns 2 for PUA characters
  • measurer.displayWidth() accounts for PUA width
  • Used with runWithMeasurer() to scope the output phase for OSC 66 wrapping

Each measurer has its own independent width cache.

runWithMeasurer(measurer: Measurer, fn: () => T): T

Run a function with a scoped measurer. All module-level width functions (graphemeWidth, displayWidth, etc.) use the provided measurer within the callback. This is how the output phase and other pipeline stages get text sizing awareness without threading measurer arguments.

isTextSizingEnabled(): boolean

Check if text sizing mode is currently active at the module level (default measurer).

How It Works Internally

When text sizing is enabled:

  1. Measurement: graphemeWidth() returns 2 for PUA characters (normally 1). This flows through displayWidth(), wrapText(), truncateText(), and all layout calculations.

  2. Buffer: PUA characters are stored as wide characters in the terminal buffer (like CJK) -- the character occupies cells [x] and [x+1] with a continuation marker.

  3. Output: When generating ANSI output, PUA characters in wide cells are wrapped in ESC]66;w=2;...BEL so the terminal renders them at the correct width.

run() auto-enables text sizing on supported terminals (Kitty 0.40+, Ghostty). Unsupported terminals ignore the sequences harmlessly.