AnsiUtils
Category: Rendering
Source: ansi_utils.dart
Functions
String osc8Link(String url, [String? text])
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
String osc8FileLink(String path, [String? text])
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 jumpedString 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–0x1FFFFplane 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.