Skip to content

feat(vt): add Scrollback.Render() for retired-line snapshots#852

Open
manusa wants to merge 2 commits into
charmbracelet:mainfrom
marcnuri-forks:vt-scrollback-render
Open

feat(vt): add Scrollback.Render() for retired-line snapshots#852
manusa wants to merge 2 commits into
charmbracelet:mainfrom
marcnuri-forks:vt-scrollback-render

Conversation

@manusa

@manusa manusa commented Apr 30, 2026

Copy link
Copy Markdown

What

Adds (*Scrollback).Render() string — a new method on vt.Scrollback that renders the retired-line buffer as a styled string with all the required attributes and styles, mirroring (*Buffer).Render() (in ultraviolet) and (*Emulator).Render() on the live grid.

Implementation is a one-line delegation to the existing public renderer:

func (s *Scrollback) Render() string {
    if s == nil {
        return ""
    }
    return uv.Lines(s.lines).Render()
}

Why

vt.Scrollback exposes Push, Len, Lines, Line, CellAt but no Render(). Consumers that want to snapshot retired lines as a styled byte stream (terminal multiplexers, late-attach rehydrators, debug tools) currently have to walk cells via CellAt(x, y) and re-implement vt's internal renderLine SGR-pen-diff / hyperlink reset logic outside the package. That's fragile — every future fix to pen-reset edge cases, wide-char placeholder handling, hyperlink escape rules, or new cell attributes silently bypasses the external implementation.

This patch closes that gap by reusing Lines.Render() from ultraviolet, the same renderer Buffer.Render() already delegates to. Lines stay LF-separated with no trailing LF — same shape as the live grid's Render().

The motivating consumer is ai-beacon, a terminal multiplexer that needs to deliver retired-line content to late-attaching browser clients so xterm.js's scrollback shows what the agent emitted before attach. Without Scrollback.Render() the multiplexer either re-implements renderLine (the maintenance hazard above) or ships a vendored fork of vt — neither great.

Steps to showcase

Before — the only way to render retired lines was to walk cells:

e := vt.NewEmulator(20, 5)
for i := 0; i < 10; i++ { e.WriteString("scrolled\r\n") }
sb := e.Scrollback()
// No Render() — caller must walk sb.CellAt(x, y) for every (x, y)
// and replicate vt's renderLine logic outside the package.

After — one call, encoding stays in lock-step with Buffer.Render():

e := vt.NewEmulator(20, 5)
for i := 0; i < 10; i++ { e.WriteString("scrolled\r\n") }
out := e.Scrollback().Render()
// out: "scrolled\nscrolled\n...\nscrolled" (LF-separated, no trailing LF)

Tests

Five subtests added under TestScrollback:

  1. Render returns empty string when scrollback is empty — empty Scrollback, returns "".
  2. Render returns empty string on nil receiver — nil-safe.
  3. Render emits content from scrolled-off lines — content is preserved through retirement → render.
  4. Render preserves order from oldest to newest — earliest line appears before latest in the output.
  5. Render separates lines with newline and no trailing newline — pins the LF-only contract (no CR, no trailing LF), matching Lines.Render and Buffer.Render upstream.

All existing vt tests continue to pass.

Notes for maintainers

  • I have read CONTRIBUTING.md.
  • I have created a discussion that was approved by a maintainer (for new features).

I'd usually open a Discussion first per the new-feature workflow, but the patch is minimal (~6 LOC of production code, all delegating to existing public API) and addresses an obvious gap relative to Buffer.Render(). Happy to convert this into a Discussion thread instead if you'd prefer — just let me know.

When contributing to this project, you must agree that you have authored 100% of the content, (or) that you have the necessary rights to the content and that the content you contribute may be provided under the projects MIT license.

Confirmed.

manusa added 2 commits April 30, 2026 09:56
The visible-grid Render() omits scrolled-off lines by definition, which
forces consumers (terminal multiplexers, late-attach rehydrators, byte-
stream snapshotters) to reach into the cell-walk APIs and re-derive the
SGR pen-diff / hyperlink reset rules that renderLine already encodes.

Add Scrollback.Render() mirroring (*Buffer).Render(): delegates to
uv.Lines(s.lines).Render() so the encoding stays in lock-step with the
live grid. Lines are LF-separated with no trailing LF, matching the
output of Emulator.Render().

Signed-off-by: Marc Nuri <marc@marcnuri.com>
Callers (terminal multiplexers) substitute LF -> CRLF when targeting
receivers without an onlcr translation. Asserting absence of CR in
the render output makes a future change that emits CR fail loudly
instead of silently double-CR'ing downstream.

Signed-off-by: Marc Nuri <marc@marcnuri.com>
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