#1043·bubbles

textarea: an optional hook for per-token line highlighting

Author: joestumpCreated Aug 26, 2026Updated Aug 26, 2026
Labelsenhancement

Is your feature request related to a problem? Please describe.

There is no way to style part of a line in a textarea. Any app that wants lexical highlighting in its prompt — @file mentions, /command names, matched brackets, a URL — has to either post-process the string View() returned (fighting the ANSI escapes the textarea just emitted, and re-deriving where the wrap points landed) or fork the render loop wholesale.

The post-processing route is the one people actually reach for, and it breaks in the ways you would expect: a token that straddles a soft wrap gets styled on one side only, a double-width or ZWJ grapheme shifts every offset after it, and the styling collides with the virtual cursor on the cursor line.

Describe the solution you'd like

An optional, opt-in hook the renderer consults per logical line:

go
type LineHighlighter interface {
    Highlight(lineIdx int, line []rune) []lipgloss.Range
}

func (m *Model) SetHighlighter(h LineHighlighter)

The hook is asked for style ranges over the raw logical line's runes. The renderer intersects those ranges with each wrapped segment at render time, so an implementation never thinks about wrapping and never sees a segment boundary. With no highlighter installed the render path is unchanged.

Describe alternatives you've considered

  • func(string) string on the rendered line (the shape bubbline uses). Rejected: implementations have to parse and preserve escape sequences, and the textarea's wrap and cursor math would have to start operating on styled strings rather than raw runes.
  • A token/lexer type owned by textarea. Rejected as too opinionated — every app's notion of a token differs, and a range list is the smaller contract.
  • Leaving it to callers. That is the status quo, and it is what produces the wrap- and grapheme-related breakage above.

Additional context

Implementation and tests in #1030. The two subtleties it pins, in case they inform the design discussion:

  • Rune offsets must be converted to display cells with ansi.StringWidth, not by summing per-rune widths, because lipgloss.StyleRanges resolves cell offsets through ansi.Cut, which segments by grapheme cluster. Per-rune summing scores a skin-tone emoji 4 instead of 2 and a ZWJ family 7 instead of 2, and the highlight lands that many cells right of its token.
  • StyleRanges walks its ranges in the order given and tracks how far it has consumed, so unordered or overlapping ranges make it emit part of the line twice and drop another part. Since the hook is public, the renderer normalizes before handing ranges over.

Posted on behalf of @joestump by claude-opus-5 using Claude Code.