Repository navigation
feat(coding/tui): palette-derived syntax styles, scroll chrome and a live-tail CPU bound - #39
Conversation
|
The Reproduced on pristine Every reported call path is in #40 clears the gate with the smallest bump that does it (the |
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.
1ede41c to
c8f0f4c
Compare
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
d40a9e4perf(coding/tui): bound what one un-frozen live tail may cost per framece23f83feat(coding/tui): derive a theme's syntax style from its palette5a5052dfeat(coding/tui): show the model's effective reasoning level83dc1dafeat(coding/tui): reserve a band under the transcript and a turn indicator above it0a46a02fix(coding/tui): copy a drag selection to the clipboard onlyc8f0f4cdocs(coding/tui): document the palette-derived styles, the band and the turn indicator1. 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.
TestMarkdownChromaRegistrationIsRaceFreerenders fences in parallel with concurrent registries fromloadThemeRegistryunder-race, andTestMarkdownRenderRegistersNothingIntoTheChromaRegistryasserts a render does not grow the registry.Everforest (dark and light, sainnhe/everforest
autoload/everforest.vim, medium) and Kanagawa (rebelot/kanagawa.nvimcolors.lua+ thewavetheme) ship as the first two families that needed this. Measured workspace-vs-code_backgroundcontrast: 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 configureddefaultshows 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, becauseController.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 historystays.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,/exportand 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 injectedClipboardWriterreplaces 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, allokgo test -race ./internal/coding/{tui,modelcatalog,cli} -count=1— cleangolangci-lint run --new-from-rev=origin/main ./...— 0 issuesgo build ./...plus the TUI suite, all greeninternal/coding/tui/testdata/tui.sha256.goldenuntouched: no fixture shows the band's arrow or the turn indicator, and the band replaced a blank row with a blank rowKnown limits, stated rather than hidden
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, soTestFamilyStyledBuiltInsKeepTheKnownTokenTiesrecords the measured set as a tripwire.code_backgroundfor 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.markdownFrozenRows, which is O(body) rather than O(body²) and is a separate, still-open item.