charmbracelet / charmbracelet/bubbles

textarea: an optional hook for per-token line highlighting

Open
#1,043 0 comments 0 reactions 0 assignees View on GitHub
enhancement
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.