Rendering complex scripts in terminal and OSC 66

11 min read Original article ↗

As a programmer, I spend most of my time in a terminal application like Kitty. I use Neovim as my code editor. I use CLI based AI agents. But the biggest pain, even in 2026, is that there is no terminal that can render complex scripts like Indic languages or Arabic. This is a significant limitation for me, as most of my work involves language processing.

In this article, I will give a brief overview of why this issue remains unsolved—covering the character-cell grid model, width measurement, and the distinction between text shaping and rendering—along with ongoing efforts and a small tool I built recently that illustrates a solution path.

Why complex script rendering is hard in terminals

The architecture of modern terminal emulators like GNOME Terminal, Kitty, and Ghostty is a software-based reproduction of physical hardware constraints from the 1970s. The transition from electromechanical teletypewriters to video display terminals established the character-cell grid as the universal interface for command-line computing. In this model, the screen is conceptualized as a matrix of rows and columns, where each intersection—a cell—houses exactly one graphic character.

Text User Interfaces (TUIs) rely on this assumption to calculate layouts and position the cursor. When a developer uses a tool like Vim or htop, the application calculates the exact column and row for every character, assuming that moving the cursor one “step” always corresponds to one cell on the display.

The “one character to one cell” mapping represents a fundamental architectural failure when applied to complex writing systems. These scripts, which include Arabic and the Indic family (Malayalam, Tamil, Devanagari), are characterized by contextual shaping, character reordering, and non-linear glyph fusion—concepts that are inherently incompatible with a static grid. Arabic has additional characteristics of right to left writing.The Arabic Calligraphic Problem

ന്തോ

Monospace fonts

This problem is compounded by how monospace fonts work. Monospace fonts are designed for working with the above constraints. But the concept of “monospace” cannot be defined for Indic scripts or Arabic. Every character in a monospace font takes exactly the same amount of horizontal space. Latin letters can fit into that design constraint, but not other scripts.

As an illustration, see the following Malayalam ligatures and observe the width it takes:

ന്നത്തത്തോന്ത്ര്യോ

A sequence of several Unicode codepoints might need to collapse into a single visual unit, or a single codepoint might need to expand to cover an irregular number of cells to remain legible. When terminals attempt to force these clusters into a rigid grid, the result is scrambled or overlapping charactersState of Terminal Emulators in 2025: The Errant Champions

Width Prediction

Given these constraints, how does a terminal decide how many cells a character should occupy? For decades, the standard method for a terminal application to determine the width of a character has been the wcwidth() function. This function takes a single Unicode codepoint and returns its width in cells (0, 1, or 2). However, this function only takes one argument—a single codepoint—and returns a fixed value representing its width, which is clearly insufficient for complex scripts where multiple codepoints contribute to each grapheme cluster.

source code for the C program that prepared this table

CharacterU+Codewcwidth
Malayalam: അ (a)U+000D051
Malayalam: ആ (aa)U+000D061
Malayalam: ക (ka)U+000D151
Malayalam: ഴ (zha)U+000D341
Malayalam: ൾ (chillu ll)U+000D7E1
Malayalam virama ് (U+0D4D)U+000D4D0
Chinese: 中 (zhong, middle)U+004E2D2
Japanese: 日 (hi, sun/day)U+0065E52
Korean: 한 (han)U+00D55C2
CJK: 語 (language)U+008A9E2
CJK: 火 (fire)U+00706B2
Fullwidth: A (U+FF21)U+00FF212
Fullwidth: ! (U+FF01)U+00FF012
Symbol: ★ BLACK STAR (U+2605)U+0026051
Symbol: → RIGHT ARROW (U+2192)U+0021921
ZWNJ (U+200C)U+00200C0
ZWJ (U+200D)U+00200D0
Combining grave accent (U+0300)U+0003000

From the above table, you can observe that wcwidth is mostly concerned with East Asian scripts. For a text like my name സന്തോഷ്, wcwidth calculates 7 cells. However, there are only 3 ligatures in it. Kitty terminal does not use this strategy. It calculates that it needs 3 cells and each of these ligatures is ‘fitted’ into those 3 cells, resulting in the following issue:

Actual textKittyGnome terminal
സന്തോഷ്
Screenshot from https://www.unicode.org/L2/L2023/23107-terminal-suppt.pdf

Screenshot from https://www.unicode.org/L2/L2023/23107-terminal-suppt.pdf

When the terminal emulator and the TUI application disagree on the width of a character, the contract of the grid is broken. The application might think the cursor is at column 10, while the terminal, having fused several characters, renders it at column 8. This leads to misaligned input, overwritten text, and visual corruption.

Screenshot of Malayalam text in kitty

Screenshot of Malayalam text in kitty

The Shaping vs. Rendering Distinction

To understand why terminals struggle with complex scripts, it helps to separate two stages that graphical applications handle transparently but terminals do not. Text rendering in a modern graphical environment is a two-stage process: shaping and rendering. Shaping is the process of converting a sequence of Unicode codepoints into a sequence of glyph IDs and their exact pixel positions. Rendering is the process of drawing those glyphs onto the screen.

Most terminals today skip shaping entirely and render codepoint by codepoint. A major reason is that advanced terminal applications such as shells and text editors need to know exactly where the cursor is at all times and remain in sync with the terminal grid state. Proper shaping would require the terminal to commit to a cell layout before the application can query it—a coordination problem that no standard currently solves cleanly. This is the gap that Kitty’s text sizing protocol attempts to bridge.

Kitty’s Text Sizing Protocol

Kitty, developed by Kovid Goyal, takes a different approach. While remaining strictly character-cell based for maximum performance, Kitty employs a custom text-splitting algorithm that meticulously interprets Unicode standards to determine how grapheme clusters should be divided into cells. Kitty has also pioneered a new “text sizing protocol” that allows programs running in the terminal to explicitly control how many cells a character occupies. This bypasses the limitations of wcwidth() and provides a mechanism for TUI applications to handle complex scripts without causing visual corruption.

Protocol (OSC 66)

Format: ESC ] 66 ; <key>=<value>[:<key>=<value>...] ; <text> BEL

  • ESC = \x1b, BEL = \x07 (the terminator — marks end of escape sequence)
  • Metadata is colon-separated key=value pairs
  • Text payload is UTF-8, max 4096 bytes per escape code
  • The BEL byte terminates the escape sequence; it is NOT part of the text

Example in shell:

printf "\e]66;w=2;സ\a"    # 2 cells for സ, terminated by BEL (\a)
printf "\e]66;w=4;ന്തോ\a"  # 4 cells for ന്തോ
printf "\e]66;w=2;ഷ്\a"    # 2 cells for ഷ്

Key metadata parameters:

  • s (1-7): Overall scale factor. Text occupies s*w cells wide, s cells tall.
  • w (0-7): Width in scaled cells. 0 = terminal auto-calculates from Unicode.
  • n/d (0-15): Fractional scale numerator/denominator.
  • v/h (0-2): Vertical/horizontal alignment for fractional scaling.

Constraints:

  • w maxes out at 7.
  • Text payload max 4096 bytes per escape code.
  • Text must be escape-code-safe UTF-8.

It is up to the clients to pass the correct w values (or other values — s/d/n/v/h, but all contribute to a calculated w). How do we calculate that? I wrote a program for that.

osc66

To test this in practice, I wanted to see whether OSC 66 could make Malayalam text legible in my own terminal workflow. The protocol requires the client to supply correct w values, but no existing tool did that for shaped Indic text. So I wrote one.

osc66 is a CLI utility, written in Rust, that accepts text as input and outputs text with OSC 66 escape characters.

Source code: https://github.com/santhoshtr/osc66

Algorithm

The processing pipeline flows from stdin through a line reader, then into HarfBuzz shaping, cluster grouping, width calculation, and finally OSC 66 emission to stdout.

Font loading begins with fontconfig locating the font file by family name, after which HarfBuzz loads a Face and Font from it. The tool then establishes a reference advance by shaping the ASCII character '0' to obtain its x_advance. This serves as the baseline—one cell equals that many font units. Each input line is then shaped as a single HarfBuzz buffer to preserve cross-glyph context such as ligatures and contextual forms.

Once shaping is complete, glyphs are grouped by their cluster field, which is a byte offset into the input. The x_advance values within each group are summed to produce the total advance for that cluster. The cell count is then calculated using the formula

\begin{aligned} \text{cells} = \left\lceil \frac{\text{cluster\_advance}}{\text{ref\_advance}} \right\rceil \end{aligned}

,clamped to 0–7 to fit the protocol’s 3-bit w field.

Finally, each cluster is emitted as an OSC 66 escape sequence in the form ESC]66;w=<cells>;<text>BEL. Zero-width clusters such as virama and ZWJ are skipped entirely.

Following is a screenshot of Malayalam rendering in Kitty when text is passed through osc66.

Screenshot of Malayalam text with OSC66 applied

Screenshot of Malayalam text with OSC66 applied

As you can see the output rendering is much better and readable now. However there are visible spaces between glyphs in many places. This happens because we are mapping the glyph widths to one or more cells. Since there are no fractional cells and cell width is fixed, there is always some extra space left out. I tried various strategies for improving the rounding formula $ \lceil \frac{\text{cluster\_advance}}{\text{ref\_advance}} \rceil $ by replacing the ceiling function with floor and round, but none of them give a perfect solution. If we prioritise omitting glyph cut-off, we cannot use floor rounding. If we use ceiling, there will be space.

If a Malayalam ligature’s actual pixel width is 1.3x the width of a standard English character, forcing it into the terminal grid requires allocating exactly 2 cells.

Because the terminal cannot render a 0.7 “fractional cell” of background, that 0.7 space is left visually empty. Complex scripts are a continuous flow, and the grid forces discrete math on them.

Fractional cells are not possible. Maybe a ’narrow cell’ concept can be useful so that we can do more divisions and achieve more optimal rounding. Another expensive strategy is in the font, but that approach is fragile — designing the font so that it restricts glyph width to a multiple of a fixed cell width and avoids fractional width multipliers. Meaning, every glyph is of width 1x, 2x, 3x, etc., where x is the size of the smallest ligature. But this will have a negative impact on the aesthetics of the script for sure.

Screenshot of Hindi text with OSC66 applied

Screenshot of Hindi text with OSC66 applied

Adoption and Limitations

OSC 66 works well in Kitty, but the tool’s usefulness is constrained by how widely the protocol is supported. Currently it is supported by Kitty and Foot. Discussions are ongoing in other projects—Ghostty has an open pull request and a tracking issue for implementation, while neovim tracks the feature request in issue 32539.

CaveatsNote that if tmux is used within kitty, it overrides the rendering and you won't see the output text.

Note that even after osc 66 application, kitty needs to be configured to render the output with the font you used.

symbol_map U+0D00-U+0DFF Manjari
symbol_map U+0900-U+09FF Noto Sans Devanagari

Standardization and the Future

OSC 66 demonstrates that the problem is solvable, but it is ultimately a workaround—a client-side shim for terminals that lack native complex script support. The real fix requires agreement at the protocol level, across terminal emulators, shell applications, and TUI frameworks simultaneously. That work is underway.

The TCSS Working Group

The most promising long-term solution to the complex script crisis is the formation of the Terminal Complex Script Support Working Group (TCSS WG) under the Unicode Technical Committee in 2023. This group, which includes representatives from Microsoft, Apple, and major terminal projects, is working to define a new standard for how terminals should handle shaped, bidirectional, and wide text on a fixed-width grid. I could not find any recent activity from this working group. The last activity I could find is from December 2023. Frederick Brennan submitted an opposition to this spec and was subsequently added to the committee.Sadly, Frederick Brennan passed away recently. I fondly remember some interactions we had about typography and type design software. RIP

The TCSS WG proposes moving away from codepoint-based rendering to a “Terminal Cluster” model. In this model:

  • String-Based Measurement: The terminal analyzes a full string of text to determine width, rather than asking for individual codepoint widths.
  • Explicit Metrics: Each cluster in the screen buffer is assigned a metric representing how many cells it occupies.
  • Logical Ordering: The internal representation of text remains in logical (typing) order, ensuring that copy-paste and search continue to work, while the display layer handles BiDi reordering and shaping.

Mode 2027

Mode 2027 is a proposal for grapheme support in terminals. This proposal is from the author of the Contour terminal. The idea is that a program running in a terminal can notify the terminal that it wishes to operate with full support for grapheme clustering, and this feature can be turned on and off. However, I don’t see any recent activity about this proposal. I read about this proposal in an article by Ghostty’s developer Mitchell HashimotoGhostty 1.3.0, released recently, claims it improved complex script rendering. In my testing I did not find any improvement for Malayalam or Indic scripts.

The situation today is fragmented: one terminal with a working protocol, a few others with open discussions, and two competing standardisation efforts at different stages. Progress is real but slow. For those of us who write in Malayalam or other Indic scripts and live in the terminal, even partial support—like what osc66 provides in Kitty—is a meaningful improvement over nothing. At least now I can pipe the text to osc66 to read.

I hope the momentum builds. Thanks for reading.

Further Reading