Skip to content

AnsiUtils

Category: Rendering

Source: ansi_utils.dart

Functions

Wrap [text] in an OSC 8 terminal hyperlink pointing to [url].

Modern terminals (iTerm2, Ghostty, Kitty, WezTerm, Windows Terminal) render this as a clickable link. Terminals that don't support OSC 8 simply show the visible text — graceful degradation.

Empty [url] returns the plain display text; this avoids emitting an empty OSC-8 envelope (\x1b]8;;\x07…\x1b]8;;\x07) that some terminals would render as a visible artifact.

Protocol: \x1b]8;;URL\x07VISIBLE_TEXT\x1b]8;;\x07

Wrap [path] in an OSC 8 file hyperlink using an absolute file URI while preserving [text] (or the original path) as the visible label.

String linkifyUrls(String text)

Convert bare http/https URLs in [text] into OSC 8 hyperlinks.

String stripAnsi(String text)

Strip all ANSI escape sequences from [text], including CSI sequences and OSC sequences (e.g. hyperlinks).

String stripControlChars(String text)

Remove terminal control bytes from untrusted [text] so it can be safely composed into terminal output.

Model output, tool results, and fetched web/MCP content are attacker- influenceable. If their raw bytes reach the terminal, an embedded escape sequence can drive the emulator: ESC ] 52 writes the system clipboard, ESC ] 8 forges hyperlinks, ESC [ 2J clears the screen, and device-query sequences (ESC [ 6n, DA/DSR) make the terminal answer onto stdin — bytes the host program reads back as if they were keystrokes.

This drops the introducers those attacks depend on: every C0 control byte (< 0x20) except \n and \t, DEL (0x7f), and the C1 range (0x80–0x9f, which includes the 8-bit CSI 0x9b and OSC 0x9d). It intentionally does NOT strip the CSI/OSC bytes that a renderer adds afterward — callers must sanitize the untrusted text first, then apply styling, never the reverse.

int visibleLength(String text)

Compute the visible column width of [text] in a terminal, accounting for ANSI escapes, wide characters (emoji, CJK), and zero-width characters (combining marks, variation selectors).

String ansiTruncate(String text, int maxVisible)

Truncate [text] to [maxVisible] visible columns, preserving ANSI sequences (both CSI and OSC) and handling wide characters. Appends '…' if truncated.

String ansiWrap(String text, int maxWidth)

Word-wrap [text] to fit within [maxWidth] visible columns. Preserves ANSI sequences and existing newlines.

ansiWrap("The quick brown fox jumped over the lazy dog", 20)
→  The quick brown fox
jumped over the
lazy dog

`String wrapIndented(

String text, int width, { String firstPrefix = '', String nextPrefix = '', })`

Word-wrap [text] to [width] visible columns, prepending [firstPrefix] to the first line and [nextPrefix] to continuation lines.

Unlike [ansiWrap] which only breaks long lines, this also handles prefixed/indented content where continuation lines need alignment.

// Plain indentation (firstPrefix & nextPrefix = '   '):
wrapIndented("The quick brown fox jumped", 20,
firstPrefix: '   ', nextPrefix: '   ')
→     The quick brown
fox jumped

// List bullet (firstPrefix = '• ', nextPrefix = '  '):
wrapIndented("The quick brown fox jumped", 20,
firstPrefix: '• ', nextPrefix: '  ')
→  • The quick brown
fox jumped

// Blockquote (firstPrefix & nextPrefix = '│ '):
wrapIndented("The quick brown fox jumped", 20,
firstPrefix: '│ ', nextPrefix: '│ ')
→  │ The quick brown
│ fox jumped

String applySelectionHighlight(String line, int startCol, int endCol)

Wrap visible columns [startCol, endCol) of an ANSI-styled [line] with reverse-video (\x1b[7m / \x1b[27m), preserving existing CSI and OSC sequences and treating wide glyphs as 2 cells.

Used to paint the transcript-selection highlight onto the visible viewport slice without forcing every renderer downstream to know anything about selections.

Columns count display cells: a CJK or emoji glyph occupies 2 cells. If [startCol] falls inside a wide glyph the entire glyph is included; likewise for [endCol]. Out-of-range bounds clamp to the line's visible width. Returns [line] unchanged if endCol <= startCol.

int ansiColumnToIndex(String s, int visiblePos)

Translate a visible column [visiblePos] into a code-unit index in [s], skipping CSI escape sequences (which occupy no columns) while counting every other code unit as one column.

Note: unlike [visibleLength], this counts code units (not display cells) for visible characters and only skips CSI (not OSC) escapes. It exists for the overlay-splice paths that slice an already-padded background row at a known column, where each background cell was emitted as a single code unit.

int charWidth(int cp)

Terminal column width of a single Unicode code point.

LIMITATION (L14): this measures one code point in isolation and cannot see grapheme clusters. Two known inaccuracies follow from that:

  • The 0x1F000–0x1FFFF plane is treated as uniformly width-2. That block is mostly emoji (correctly wide) but also holds width-1 symbols and unassigned code points, which are then over-counted.
  • ZWJ emoji sequences (e.g. 👩‍👩‍👧 = woman + ZWJ + woman + ZWJ + girl) render as a single 2-cell glyph, but here each base emoji counts as 2 and each joiner as 0, so the cluster is measured as 4–6 cells.

The practical effect is occasionally misaligned box borders or selection highlights around exotic emoji. A correct fix needs Unicode grapheme-cluster segmentation (UAX #29) plus an emoji-width table, which is out of scope here; callers should not rely on exact widths for arbitrary emoji input.

Released under the MIT License.