prose

package
v0.3.1-rc.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 22, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Overview

Package prose renders model-written markdown into styled terminal rows.

It exists because of one line in the 13.3 audit: "questions render raw, tables wrap to soup". A model answers in markdown whether or not anyone asked it to, and a surface that draws that markdown as literal text is showing the reader the model's punctuation instead of the model's answer — `**maybe**`, a table folded into ribbons, a fenced block indistinguishable from the sentence above it. This package is the one place that text becomes rows.

The contract

rows := prose.Render(reply, prose.Options{Width: w, Styler: st})

Every returned string is ONE screen row: it contains no newline, and its printable width is at most Options.Width cells. A caller can hand these straight to a transcript. The line builder here is small enough to own (see line.go), which keeps this renderer independent of any surface package.

What it does NOT do

It does not use goldmark's HTML renderer and it does not use glamour. Both resolve colour themselves, and a second colour authority on this surface is exactly the failure internal/tui2/tokens was built to end: a theme that knows nothing about the profile ladder, the focus axis, or the contrast gate would paint a code keyword in a colour no test in this tree has ever measured. goldmark is used for its PARSER only — the AST is walked here and painted through tokens.Styler, so every cell this package draws is a cell the token layer named.

The shape of the output

  • Paragraphs wrap to the MEASURE, not to the terminal. A 200-column window is a wide window, not a wide sentence; Options.Measure is the reading length and Options.Width is the hard ceiling. Figures — tables and code blocks — get the full width, because a figure is looked at rather than read along, and so does a single token that cannot be broken — a URL, a path, a hash — which is copied rather than read along.
  • Headings promote by TIER, never by size (5.13). There is no larger type in a terminal, so a heading is louder by standing higher on the grey ramp and by the whitespace around it; h1 also takes bold, and every level past the third steps back down with tokens.Demote.
  • Lists hang. A wrapped item aligns under its own first word, never under its marker, so the marker column stays a column.
  • Tables TRUNCATE. Columns are fitted to the width and over-long cells end in an ellipsis; a cell is never wrapped, so a row is always one row. A table too wide even for its floors keeps that shape and is cut at the right edge — scrollable-shaped, for a caller that can crop — because rows that stay rows can be scrolled, and soup cannot.
  • Code blocks are highlighted at 256 colours and above and are drawn at the ordinary text tier below (see tokens.CodeHighlighting). A lying colour is worse than no colour (5.20).

Untrusted input

Every byte that reaches this package was written by a model or a tool, so the source goes through internal/sanitize with the token layer's ANSI-16 remap — the same chokepoint the chat engine, the rail and the homes line use — and then loses its SGR sequences entirely. Sanitizing alone would leave a reply free to paint itself; prose owns its own colours, so someone else's are stripped rather than honoured. Control bytes never reach a cell.

Section numbers in comments refer to the August 2026 chat-rebuild audit, no longer in the tree.

Index

Constants

View Source
const DefaultMeasure = 88

DefaultMeasure is the reading length prose wraps to when a caller states no other. It is the low end of the classic typographic measure — 45 to 90 characters a line, past which the eye loses the return sweep — and it is a CEILING rather than a target: a 60-column terminal wraps at 60.

The number is here rather than in tokens because it is a property of reading, not of the shared palette: it describes how long a sentence may be inside a pane.

View Source
const DemoSource = "# Rendering model prose\n" +
	"\n" +
	"A reply arrives as **markdown** whether or not anyone asked for it, so the\n" +
	"surface has to *read* it. Inline `code spans` sit on a raised ground, and a\n" +
	"link like [the manual](docs/README.md) keeps its address.\n" +
	"\n" +
	"## What changed\n" +
	"\n" +
	"1. Paragraphs wrap to the measure, not to the terminal.\n" +
	"2. Tables truncate instead of wrapping.\n" +
	"3. Fenced code is highlighted where the profile can carry it.\n" +
	"\n" +
	"- unordered items hang under their own first word, which is the whole point\n" +
	"  of a hanging indent\n" +
	"- nested lists indent once more\n" +
	"  - like this\n" +
	"\n" +
	"> A blockquote recedes one tier and grows a gutter bar.\n" +
	"> It is structure without a box.\n" +
	"\n" +
	"| column | meaning | width |\n" +
	"| --- | --- | ---: |\n" +
	"| measure | how long a sentence may be | 88 |\n" +
	"| width | the hard ceiling, always obeyed | 120 |\n" +
	"\n" +
	"```go\n" +
	"// wrap greedily, because greedy is the only rule under which\n" +
	"// appending text cannot rewrite a row that is already on screen.\n" +
	"func Wrap(dst []string, text string, width int) ([]string, int) {\n" +
	"\tif width < 1 {\n" +
	"\t\twidth = 1\n" +
	"\t}\n" +
	"\treturn dst, 0\n" +
	"}\n" +
	"```\n" +
	"\n" +
	"---\n" +
	"\n" +
	"That is the whole vocabulary.\n"

DemoSource exercises every shape this package knows how to draw, in the order a reviewer would want to see them. It is a const so a test can render it at forty widths without a fixture file, and it is deliberately a document a model might plausibly have written rather than a syntax showcase.

Variables

This section is empty.

Functions

func Demo

func Demo(width int, p tokens.Profile) []string

Demo renders DemoSource at a width and a profile. It is the fastest way to look at this package's output — from a test, from a scratch main, or from a settings sheet's live preview when one exists — without a caller having to build a Styler and remember which focus a preview is drawn at.

func HighlightBlock

func HighlightBlock(s *tokens.Styler, src, lang string, tier tokens.Token) []string

HighlightBlock paints a WHOLE file the way HighlightLine paints one row — one painted string per source line, the caller's tier everywhere the lexer found nothing — and it exists because a line is not a unit chroma can reason about.

LEXING LINE BY LINE GETS MULTI-LINE CONSTRUCTS WRONG, and it gets them wrong in the most visible way there is. A Go raw string, a C block comment, a docstring in Python: handed to the lexer one line at a time, the opening line is a string and the three under it are re-lexed from nothing — so the middle of a comment is coloured as keywords and operators, which is a claim about somebody's source that is simply false. internal/tui3's file preview draws exactly those files and was doing exactly that.

The whole text goes through the lexer ONCE and the rows come back split by [highlightPieces], which already handles a token value that spans rows. The caller is expected to memoise the answer against the file's own identity: this is about sixty microseconds a row and a preview is bounded at a few hundred of them, which is a cost worth paying once per file and not once per frame.

A caller that hands over more lines than it will draw gets them all back, in file order, so the row it wants is the row at that index. Nothing here truncates, clips or measures — the caller owns its rectangle, exactly as it does with HighlightLine.

func HighlightLine

func HighlightLine(s *tokens.Styler, src, lang string, tier tokens.Token) string

HighlightLine paints ONE line of source as lang, for a surface composing a row this package does not own.

It exists so that chroma keeps living in exactly one place. The record's tool rows want a shell command coloured the way a fenced block is coloured (§5: "shell commands syntax-highlighted at the dim end of the pastel ramp"), and the alternative to this function was a second copy of [codeStyle] — a table of two hundred token relationships, maintained twice, drifting once.

What comes back is a PAINTED SPAN and nothing else: no gutter, no ground, no wrapping, no truncation, and every newline flattened to a space, because the caller is putting this inside a row whose width it is measuring itself. Under a profile with no code ramp (tokens.CodeHighlighting) it returns the scrubbed text unpainted, which is the same degradation a fenced block makes and for the same reason.

tier is the span's OWN GREY, and it is load-bearing in two places. It is what the whole span falls back to under a profile with no ramp — and it is also what every run chroma had nothing to say about is painted at, instead of the code body tier. That is the difference between "highlighted" and "promoted": a fenced block owns its rectangle and may set the ink inside it, while a span inside somebody else's row must keep that row's voice and light up only where the lexer actually found something (a keyword, a string, a number). §5 asks for shell commands "syntax-highlighted at the dim end of the pastel ramp", and the dim end is exactly this: colour where there is meaning, the caller's quiet tier everywhere else.

func LexerName

func LexerName(filename string) string

LexerName is the language a FILE is written in — the word HighlightLine takes as its lang — or "" when nothing in the curated set claims that filename.

It exists so a caller holding a PATH rather than a fence's info string can still reach the one highlighter in this tree. internal/tui3 draws the body of a `write` call and the text a `read` returned, and the only thing either of those carries about its language is the file's own name; the alternative to this function was that package importing chroma, which is the thing this file's opening paragraph exists to prevent.

The empty answer is load-bearing: a caller gets to say "nothing here is source" and fall back to whatever it drew before, rather than have a fallback lexer paint a log file as if it were code. A filename outside the curated set is exactly that case, and answers "" on purpose.

IT IS MEMOISED, and it has to be. Match walks every file pattern of every curated lexer and is not fast, and the callers are drawing terminal rows at thirty frames a second. The table is keyed by the name it was asked about, so it is bounded by the files one session touched.

func Render

func Render(src string, opts Options) []string

Render turns markdown into styled terminal rows.

Each returned string is one screen row: no newlines, and never more than Options.Width printable cells. An empty source returns no rows, not one empty row — a reply that said nothing must not push the transcript down.

func RenderTo

func RenderTo(dst []string, src string, opts Options) []string

RenderTo is Render appending into a caller's slice, for a surface that re-renders on every resize and would rather keep its buffer.

Types

type Options

type Options struct {
	// Width is the hard ceiling in printable cells. EVERY row [Render] returns
	// is at most this wide — not usually, not for well-formed input, always.
	// A Width below 1 is clamped to 1.
	Width int
	// Measure is the reading length paragraphs, headings, lists, quotes and
	// rules wrap to, clamped to Width. Zero means [DefaultMeasure]; a caller
	// that genuinely wants prose to fill the pane sets Measure equal to Width.
	//
	// Tables and code blocks ignore it and take the full Width, because a
	// figure is looked at rather than read along: narrowing a table to a
	// sentence's length is how a table becomes soup.
	//
	// AND SO DOES A TOKEN THAT CANNOT BE BROKEN — a URL, a path, a hash. It is
	// given the whole Width before it is cut, for the same reason and on the same
	// terms ([wrapper.commit]): a link is copied rather than read along, and a
	// link broken at the measure while the column stood half empty was a link
	// that did not work.
	Measure int
	// Styler paints every cell. Nil renders unpainted text — the honest
	// headless default, and exactly what a NoColor profile would draw anyway.
	Styler *tokens.Styler
	// PlainCodeSpan reports whether an inline code span already has a visible
	// mark supplied by the caller and must therefore stay off the raised plane.
	// Nil means every inline code span uses prose's ordinary plane.
	PlainCodeSpan func(string) bool
}

Options is everything a render depends on. It is a value, and Render is a pure function of it and the source: the same source at the same width through the same Styler produces the same bytes, every time, which is what lets a caller cache rows per (width, version) the way the block engine already does.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL