Skip to content

feat(coding/tui): palette-derived syntax styles, scroll chrome and a live-tail CPU bound - #39

Merged
rsbin1178 merged 6 commits into
mainfrom
feat/tui-palette-syntax-and-scroll
Oct 9, 2026
Merged

rsbin1178 merged 6 commits into
mainfrom
feat/tui-palette-syntax-and-scroll

Conversation

@rsbin1178

@rsbin1178 rsbin1178 commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Six commits that make a theme's fenced code read as part of its own theme, and land four small TUI changes on top. Each commit builds and passes the TUI suite on its own, so the stack is bisectable.

Commits

commit change
d40a9e4 perf(coding/tui): bound what one un-frozen live tail may cost per frame
ce23f83 feat(coding/tui): derive a theme's syntax style from its palette
5a5052d feat(coding/tui): show the model's effective reasoning level
83dc1da feat(coding/tui): reserve a band under the transcript and a turn indicator above it
0a46a02 fix(coding/tui): copy a drag selection to the clipboard only
c8f0f4c docs(coding/tui): document the palette-derived styles, the band and the turn indicator

1. Syntax styles derived from the palette

A family chroma ships no style for, and every theme loaded from a file, had no syntax tokens of their own: the first borrowed another family's bundled style, the second followed the family its background resolved for. Fences therefore disagreed with the theme around them.

A complete style is now derived from the theme's 14 palette roles through one documented role -> token table whose every row carries its reason. The 22 built-ins whose family ships a chroma style keep that style and its tokens plus the palette diff accents, so their fences are unchanged; only the families with nothing to inherit and the file-loaded themes are derived.

Registration stays off the render path. chroma's style registry is an unlocked map, so writes happen under a mutex that every glamour render also takes, and each derived style is named by the theme's own canonical digest, which keeps two themes sharing a palette in separate styles and never lets a changed palette reuse an old one. TestMarkdownChromaRegistrationIsRaceFree renders fences in parallel with concurrent registries from loadThemeRegistry under -race, and TestMarkdownRenderRegistersNothingIntoTheChromaRegistry asserts a render does not grow the registry.

Everforest (dark and light, sainnhe/everforest autoload/everforest.vim, medium) and Kanagawa (rebelot/kanagawa.nvim colors.lua + the wave theme) ship as the first two families that needed this. Measured workspace-vs-code_background contrast: Everforest Light 5.18:1, Everforest Dark 7.38:1, Kanagawa 9.75:1.

2. Status line: model and effective reasoning level

Claude Opus 4.1 (high). The level is read from the resolved model, the same value the request assembly sends, so a configured default shows the level the model's metadata resolves it to rather than the literal word; a model that declares levels but has none selected shows (default), which is honest because pips ships no capability database; a model with no reasoning knob shows no suffix. It is cached, like the composer's permission mode, because Controller.Model() clones the resolved snapshot and the status line is drawn on every frame.

3. A reserved band, and a turn indicator above the transcript

The row the frame already left blank between the activity line and the composer is now an explicit band that counts in the height budget, so the composer never moves when its contents change. It shows a centred ▼ while the newest rows are off screen, and the confirmation of a drag copy (copied 3 lines). The status line's own scroll hint is gone, since two surfaces saying the same thing compete for a narrow line; PgUp for older history stays.

Above the transcript, while anything is above the window, the first row carries a centred ▲: a left click puts the newest user entry above the window at its top, so repeated clicks walk back through the conversation one turn at a time, and the arrow only leaves once the window reaches the beginning. It is drawn into the first row rather than into a band of its own because there is no free row there: reserving one would take a row from the messages and would change the window height that decides whether the indicator is needed, so showing it could hide it again on the next frame. Both glyphs are the filled triangles, because the hollow one is already this UI's attention marker (△ Approval required, △ Compact context, the picker's polarity mark).

The band's recorded row and the indicator's span are written with the frame that painted them, so a click always resolves against what the reader saw.

4. Drag copy: clipboard only

Releasing a drag called saveText, which wrote the addressed rows to the clipboard and to the saver's fallback file. A selection is a transient reading gesture, so leaving a file behind for it made the same gesture produce a different side effect depending on whether a saver happened to be configured. The drag path now performs one clipboard write and never reaches the saver; /copy, /export and a copy with a path keep theirs. A confirmed copy clears the highlight instead of leaving it until the notice expires; a copy that could not be made keeps the selection and says why. An injected ClipboardWriter replaces the OSC 52 request for every copy, so the two paths cannot disagree about where the text went.

5. CPU: the live-tail bound

Profiling the TUI under an open code fence put 41.5% of all on-CPU samples in one line: the whole un-frozen live tail is re-rendered through glamour on every frame, and the boundary scanner refuses to freeze a fence, a list, a blockquote or a table, so the cost grows with the tail and the work is O(body²) over one message.

Measured cost of one render of an open fence at width 80: 1 KiB 4.5 ms, 8 KiB 14.5 ms, 36 KiB 51 ms, 75 KiB 113 ms. Past roughly 16 KiB a render is longer than the 33 ms frame that asks for it, so repainting every frame is not available; the choice is whether the loop saturates or the tail lags.

A live frame whose un-frozen tail is at least 8 KiB now stands for max(renderFrame, 2×cost). Below that threshold nothing changes: the frame is rendered every time, which keeps a small body byte-identical to a whole render. Above it a frame is always the whole render of the content it was rendered from rather than a spliced mix, and exactness returns on the first frame after the cooldown lapses.

Whole-process before/after on the reproducing workload: CPU seconds 5.77 -> 3.53, mean CPU 48.3% -> 29.5%, allocation 6.24 -> 2.77 GB, GC cycles 464 -> 202. The heavy prose, light reasoning and idle workloads are unchanged. At the TUI's own 33 ms cadence on a 98 KiB open fence: 7.6 repaints a second, worst lag 240 ms.

Verification

  • TERM=xterm-256color go test ./... -count=1 — 89 packages, all ok
  • go test -race ./internal/coding/{tui,modelcatalog,cli} -count=1 — clean
  • golangci-lint run --new-from-rev=origin/main ./... — 0 issues
  • Every commit in the stack checked out into its own worktree and run: go build ./... plus the TUI suite, all green
  • internal/coding/tui/testdata/tui.sha256.golden untouched: no fixture shows the band's arrow or the turn indicator, and the band replaced a blank row with a blank row
  • Every new guard was first run against the behaviour it is meant to catch and seen to fail (the two band bugs, the copy path, the reasoning level, the live-frame reuse)

Known limits, stated rather than hidden

  • Five of the built-ins that keep a family style already resolve a token colour onto two entries of chroma's 256-colour table at the same distance, so that token's shade can change between frames (catppuccin-mocha, default-light, gruvbox-dark, gruvbox-light, one-dark). It comes from chroma's bundled style data and the terminal256 formatter's tie-break, and the existing 22 keep their family tokens, so TestFamilyStyledBuiltInsKeepTheKnownTokenTies records the measured set as a tripwire.
  • Below the reuse threshold the palette-derived styles hold every built-in's syntax roles to one escape; Everforest Light's official accents still sit under 3:1 against its own code_background for five roles, which is a property of that family's palette (dracula's muted is 1.94:1 there, catppuccin-latte's active 1.70:1). A fence paints no fill, so those accents are read against the terminal's canvas.
  • The live-tail bound caps the repaint rate of a tail over 8 KiB; it does not remove the O(body²). Removing it needs completed fence lines to freeze, which is not byte-exact against glamour's per-block margin rows.
  • The heavy prose workload is unchanged: its cost is markdownFrozenRows, which is O(body) rather than O(body²) and is a separate, still-open item.

@rsbin1178

Copy link
Copy Markdown
Owner Author

The Go quality and security check is red for a reason unrelated to this branch: its govulncheck step started failing on main too, because the vulnerability database moved after the last green Quality run on main (2026-10-08 15:12Z).

Reproduced on pristine origin/main, with none of this branch's changes applied:

$ go tool govulncheck ./...
Your code is affected by 14 vulnerabilities from 1 module and the Go standard library.

Every reported call path is in ai/internal/httpx, agent/harness, internal/coding/{session,approval,tools,teamintegration}. This branch touches none of them — git diff --name-only origin/main..HEAD matches zero of those paths — and its own files are internal/coding/tui, internal/coding/modelcatalog, internal/coding/cli and docs/coding-cli.md.

#40 clears the gate with the smallest bump that does it (the go directive to 1.26.9, golang.org/x/net to v0.60.0, golang.org/x/image to v0.43.0). The other three checks here — Agent Plugins on macOS, Ubuntu and Windows — pass.

Profiling the TUI under an open code fence put 41.5% of all on-CPU samples in one
line: renderLiveIncrementalRows re-renders the whole un-frozen live tail through
glamour on every frame, and the boundary scanner refuses to freeze a fence, a list,
a blockquote or a table, so the cost grows with the tail and the work is O(body^2)
over one message.

Measured, one render of an open fence at width 80: 1 KiB 4.5 ms, 8 KiB 14.5 ms,
36 KiB 51 ms, 75 KiB 113 ms. Past roughly 16 KiB a render is longer than the 33 ms
frame that asks for it, so repainting every frame is not available: the only choice
is whether the loop saturates or the tail lags.

A live frame whose un-frozen tail is at least 8 KiB now stands for
max(renderFrame, 2*cost) before the renderer pays for it again. Below that
threshold nothing changes: the frame is rendered every time, which is what keeps a
small body byte-identical to a whole render. Above it a frame is always the whole
render of the content it was rendered from rather than a spliced mix, and exactness
returns on the first frame after the cooldown lapses, so a body that stops growing
is exact within one cooldown of its last render.

Measured on a 98 KiB open fence at the TUI's own 33 ms cadence over 60 frames: 7.6
repaints a second, worst lag 240 ms, 13 KiB of content behind. For the whole
process on the reproducing workload: CPU seconds 5.77 -> 3.53, mean CPU 48.3% ->
29.5%, allocation 6.24 -> 2.77 GB, GC cycles 464 -> 202. The heavy prose, light
reasoning and idle workloads are unchanged.
A family chroma ships no style for, and every theme loaded from a file, had no
syntax tokens of their own: the first borrowed another family's bundled style and
the second followed the family its background resolved for, so a fence disagreed
with the theme around it.

Derive a complete style from the theme's 14 palette roles instead, through one
documented role -> token table whose every row carries its reason. The 22 built-ins
whose family ships a style keep that style and its tokens plus the palette diff
accents; only the families with nothing to inherit and the file-loaded themes are
derived.

Registration stays off the render path: chroma's style registry is an unlocked map,
so writes happen under a mutex that every glamour render also takes, and each
derived style is named by the theme's own canonical digest, which keeps two themes
that share a palette in separate styles and never lets a changed palette reuse an
old one.

Ship Everforest (dark and light) and Kanagawa from their official palettes as the
first two families that needed this. Kanagawa's active role is the official
autumnYellow rather than the family's carpYellow, because carpYellow sits at
exactly the same distance from two entries of chroma's 256-colour table and the
tie-break would change a fence's number colour between frames.
The status line named the model but not the level the next request would carry,
which is the one setting a reader cannot see anywhere else.

Read it from the resolved model rather than the raw configuration, so a configured
"default" shows the level the model's own metadata resolves it to instead of the
literal word. A model that declares levels but has none selected shows (default),
which is honest: pips ships no capability database, so the provider's own default
is unknown to it. A model with no reasoning knob shows no suffix.

The level is cached, like the composer's permission mode, because
Controller.Model() clones the resolved snapshot and the status line is drawn on
every frame.
…cator above it

The frame already left one blank row between the activity line and the composer;
make it an explicit band that counts in the height budget, so the composer never
moves when its contents change, and give it the immediate feedback that is not the
status line's job: a centred ▼ while the newest rows are off screen, and the
confirmation of a drag copy. The status line's own scrolled hint goes away, since
two surfaces saying the same thing compete for a narrow line.

Above the transcript, while anything is above the window, the first row carries a
centred ▲; a left click puts the newest user entry above the window at its top,
so repeated clicks walk back through the conversation one turn at a time, and the
arrow only leaves once the window reaches the beginning. It is drawn into the first
row rather than into a band of its own because there is no free row there:
reserving one would take a row from the messages and would change the window height
that decides whether the indicator is needed, so showing it could hide it again on
the next frame. Both glyphs are the filled triangles, because the hollow one is
this UI's attention marker.

The band's recorded row and the indicator's span are written with the frame that
painted them, so a click resolves against what the reader saw.
Releasing a drag called saveText, which wrote the addressed rows to the clipboard
and to the saver's fallback file. A selection is a transient reading gesture, so
leaving a file behind for it made the same gesture produce a different side effect
depending on whether a saver happened to be configured.

The drag path now performs one clipboard write and never reaches the saver; /copy,
/export and a copy with a path keep theirs. A confirmed copy clears the highlight
instead of leaving it until the notice expires, and a copy that could not be made
keeps the selection and says why. An injected ClipboardWriter replaces the OSC 52
request for every copy, so the two paths cannot disagree about where the text went.
…he turn indicator

Record the role -> token semantics and the two families chroma ships no style for,
the model-and-level status item, the reserved band with its two arrows, and the
clipboard-only drag copy.
@rsbin1178
rsbin1178 force-pushed the feat/tui-palette-syntax-and-scroll branch from 1ede41c to c8f0f4c Compare October 9, 2026 03:00
@rsbin1178
rsbin1178 merged commit c549442 into main Oct 9, 2026
4 checks passed
@rsbin1178
rsbin1178 deleted the feat/tui-palette-syntax-and-scroll branch October 9, 2026 03:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant