charmbracelet / charmbracelet/bubbles
textarea: an optional hook for per-token line highlighting
- Dominant language
- Go
- Stars
- 8.9k
- Forks
- 457
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 5
Description
**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`](https://openrouter.ai/anthropic/claude-opus-5) using [Claude Code](https://claude.com/claude-code).
Contributor guide
Research direction
Start with the textarea renderer and the implementation and tests referenced in #1030. Trace how logical lines become wrapped segments and how cursor-line rendering currently works. Done means an optional LineHighlighter hook styles raw-line ranges correctly across wraps and grapheme clusters, while the no-hook path remains unchanged and ranges are normalized before styling.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100