render

package
v0.0.0-...-95517ba Latest Latest
Warning

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

Go to latest
Published: Aug 23, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

Documentation

Overview

Package render is a Go port of thetarnav/streaming-markdown (smd.js), MIT License, Copyright 2024 Damian Tarnawski, https://github.com/thetarnav/streaming-markdown — a streaming markdown parser: feed it chunks as they arrive from the model and it emits add/end-token, text, and attribute events to a Renderer without waiting for the document (or even the current line) to finish.

The parser is a character state machine transliterated from smd.js; the oracle is smd.js's own test suite, extracted to testdata/transliterated/render/smd-cases.json and replayed both as a single write and char by char.

Index

Constants

View Source
const (
	ThinkingOpen      = "‹thinking›"
	ThinkingClose     = "‹/›"
	ThinkingMoreLines = "… %5d more %s of thinking"

	// ProgressInterval is the minimum time between real-time progress updates.
	// 8 FPS.
	ProgressInterval = 125 * time.Millisecond
)

The delimiters around the model's thinking. Tag-shaped and deliberately not tag-valid: stripReasoning (coder/send.go) removes <tag>…</tag> from model output, so printing that shape would make the harness's voice indistinguishable from the model's, and a transcript would round-trip lossily through Strument itself. The guillemets are General Punctuation, the same block as the "…" the diff renderer already uses, so they need no font beyond what the elision marker already assumes.

Two constants so the glyphs can be swapped in one line while the shape stays put; the shape lives in Thinking below.

Variables

This section is empty.

Functions

func RendersDiff

func RendersDiff(tool string) bool

RendersDiff reports whether a tool's streamed arguments are worth drawing as they arrive. Only the two edit tools carry content the user wants to watch scroll past, and what they carry is a diff.

The observation tools must be excluded rather than merely rendering nothing: read and ls also take a "path" argument, and without this they would each print a bare path line as if it were a diff header, with no diff under it.

bash used to be here, and a live run is what took it out. Its command was reaching the terminal three times for one call: streamed from here, again in the confirmation prompt, and again as "Running …" at execution. The stream is the one worth losing. Watching a one-line command arrive character by character is worth little, while the other two appear exactly where a reader needs them — beside the decision, and beside the output. ToolDiff still renders a "command" field for anything that asks it to; this is only the gate that decides whether a streamed call is handed to one.

func Sanitize

func Sanitize(s string) string

Sanitize makes untrusted text safe to put on a terminal: newlines and tabs survive, every other control character and escape sequence does not.

The untrusted text is everything a model influences — its own words, the paths and patterns it passes as tool arguments, the diff bodies it writes, and the output of commands it asked to run. A model's instructions can come from a README or a scraped page, so all of it is attacker-reachable, and a terminal reading an escape sequence does what the sequence says: retitle the window, clear the screen, move the cursor back over text the user has already read. That last one matters most here, because what the user reads off the screen is the review surface the whole design rests on.

It is applied where untrusted text *enters* the output layer rather than at the writer, because the renderer's own colors are escape sequences too and share that writer. Stripping there would take them with it; whitelisting there would couple this to the renderer's alphabet. The guarantee is the test, which feeds an escape through every public entry point of the output and asserts none survives.

The gap this closes was not the one a review predicted. Reads of the code suggested the streamed answer was raw and tool arguments were quoted; on the wire it was the other way round. quoteToolArg adds quotes and nothing else, so a path carrying an OSC reached the terminal intact, while the answer stream stripped "\r" and mangled "\x1b[2J" because the markdown parser claims the "[". Inconsistent filtering is harder to reason about than none.

Types

type ANSI

type ANSI struct {
	// contains filtered or unexported fields
}

ANSI is a Renderer that streams markdown to a terminal with ANSI styling. It is the live-render half of the port: smd.js's default renderer targets the DOM, so the terminal texture here is Strument's own (hand-validated against aider's REPL feel in phase 7, per the guide's oracle table).

Layout choices: block elements are separated by one blank line (a single newline once inside a list), blockquotes get a "│ " prefix on every line, list items get bullets or ordinals with a two-space hanging indent per level, and tables render as cells joined with " │ " (streaming rules out column alignment). Link targets print after the label as " (url)"; bare URLs print once.

func NewANSI

func NewANSI(w io.Writer, color bool, theme Theme, width int) *ANSI

NewANSI returns an ANSI renderer writing to w. With color false it emits plain text with the same layout and no escape codes. theme supplies the palette (the assistant base color and the code color); width sets the horizontal-rule length (<=0 falls back to 80).

The assistant base color is seeded as the permanent bottom of the style stack: token styles push on top and pop off, but the base is never popped, so reapplyStyles (reset, then re-enter open styles) always restores it and every line of assistant output carries the color. The caller resets the terminal (SGR 0) once the stream ends.

func (*ANSI) AddText

func (r *ANSI) AddText(text string)

AddText implements Renderer.

func (*ANSI) AddToken

func (r *ANSI) AddToken(t Token)

AddToken implements Renderer.

func (*ANSI) AtLineStart

func (r *ANSI) AtLineStart() bool

AtLineStart reports whether the cursor is at the start of a fresh line — nothing written yet, or the last output ended in a newline. Callers mixing direct writes with the renderer use it to avoid double blank lines.

func (*ANSI) EndToken

func (r *ANSI) EndToken()

EndToken implements Renderer.

func (*ANSI) SetAttr

func (r *ANSI) SetAttr(a Attr, value string)

SetAttr implements Renderer.

type ArgScanner

type ArgScanner struct {
	// contains filtered or unexported fields
}

ArgScanner incrementally decodes a tool call's JSON arguments object and streams the decoded value of each top-level string field to emit(field, chunk) as fragments arrive. It tolerates arguments split at any byte boundary — mid-escape or mid-UTF-8. Nested and non-string values are skipped; the edit tools' arguments are flat string fields (path/old_string/new_string/content/command), which is what the diff renderer needs. Best-effort: malformed JSON just stops producing sensible output, and the authoritative parse happens elsewhere (json.Unmarshal on the whole arguments) before anything is applied.

func NewArgScanner

func NewArgScanner(emit func(field, chunk string)) *ArgScanner

NewArgScanner returns a scanner that reports decoded field values to emit.

func (*ArgScanner) Write

func (s *ArgScanner) Write(frag string)

Write feeds the next raw arguments fragment.

type Attr

type Attr int32

Attr identifies an attribute set on the current node. Values match smd.js's Attr enum.

const (
	AttrHref    Attr = 1
	AttrSrc     Attr = 2
	AttrLang    Attr = 4
	AttrChecked Attr = 8
	AttrStart   Attr = 16
)

Attribute kinds.

func (Attr) HTMLName

func (a Attr) HTMLName() string

HTMLName returns the HTML attribute name for a (smd.js attr_to_html_attr; Lang maps to "class" because the default DOM renderer styles code fences with a language class).

type GroupSep

type GroupSep struct {
	// contains filtered or unexported fields
}

GroupSep is the blank line between one step of a turn and the next.

A step is a block of thinking and the tool calls it explains, and the thinking is what heads it: "let me read the file", then the read. So the separator belongs *before* a thinking block rather than after it. It used to go after, which grouped each block with the calls above it — the ones it had nothing to do with — and in a terminal without faint, where the recessive palette does none of the work, that was the only grouping cue there was.

Lazy rather than eager, because the moment a step ends is not a moment the harness can see. A step's tool outcomes print after the stream has been flushed, and whether another step follows is not known until the next request comes back. So nothing is written when a group ends; a debt is recorded, and paid by whatever starts the next group. That also means the separator can never land at the top of a turn (nothing has drawn, so nothing is owed) or at the bottom of one (Clear settles the debt at the boundary).

The zero value is usable and owes nothing, which matters because both outputs are built as struct literals — in repl.go, in coder.go, and in every test.

func (*GroupSep) Before

func (g *GroupSep) Before(w io.Writer)

Before pays the debt, if there is one, immediately ahead of the group that is about to draw.

func (*GroupSep) Clear

func (g *GroupSep) Clear()

Clear settles the debt without writing, for a caller that has just written a blank line of its own or has reached a turn boundary.

func (*GroupSep) Drew

func (g *GroupSep) Drew()

Drew records that something reached the screen, so the next group owes a blank line. Callers that draw never pay the debt themselves: consecutive outcome lines belong to one group and must not separate each other.

type Parser

type Parser struct {
	// contains filtered or unexported fields
}

Parser is the streaming state machine, a transliteration of smd.js's parser. One Parser renders one document into one Renderer.

func NewParser

func NewParser(renderer Renderer) *Parser

NewParser returns a Parser that streams events into renderer. The implicit root is Document; the renderer never sees an event for it.

func (*Parser) AtLineStart

func (p *Parser) AtLineStart() bool

AtLineStart reports whether the renderer's cursor is at the start of a fresh line. Meaningful after End (once pending text is flushed); renderers that don't track it are assumed to be at a line start.

func (*Parser) End

func (p *Parser) End()

End finishes rendering: any pending characters are flushed as if the stream ended with a newline.

func (*Parser) Write

func (p *Parser) Write(chunk string)

Write parses and renders another chunk of markdown.

type Renderer

type Renderer interface {
	AddToken(t Token)
	EndToken()
	AddText(text string)
	SetAttr(a Attr, value string)
}

Renderer receives parse events as markdown streams through the Parser. AddToken opens a node as a child of the current one and makes it current; EndToken closes the current node, returning to its parent; AddText appends text to the current node (called any number of times); SetAttr sets an attribute on the current node (e.g. a link's href once the closing ")" arrives, which may be after the node's text was rendered).

type Theme

type Theme struct {
	UserInput   string // prompt and horizontal rules
	Assistant   string // base color for all assistant (markdown) output
	Error       string // tool errors
	Warning     string // tool warnings
	Code        string // color for inline code and code blocks (defaults to white / "37")
	Link        string // markdown links (underline + color)
	DiffRemoved string // removed ("-") lines in a tool-call diff
	DiffAdded   string // added ("+") lines in a tool-call diff
	Command     string // suggested-command ("$") lines
	Tool        string // the harness reporting what a tool did

	// Reasoning is how the model's thinking recedes against the answer. It is
	// SGR 2 (faint) rather than a color, and that is deliberate: faint is
	// relative to whatever foreground the user's theme sets, while a fixed color
	// is a bet on their palette.
	//
	// The bet was lost once already. This was "90" — bright black, palette slot
	// 8 — which Solarized repurposes as base03, the background color of
	// Solarized dark. On a canonical Solarized terminal the thinking rendered as
	// nothing at all. Faint has the opposite failure: a terminal that does not
	// implement it (QTerminal) shows ordinary readable text. One fails safe, the
	// other fails invisible, which is the whole argument.
	//
	// "2;"+Assistant is the variant to try if faint-but-in-palette-hue reads
	// better than faint-on-default.
	Reasoning string
}

Theme is the REPL's color palette, mirroring aider's scheme so a returning aider user feels no seam (args.py + the --dark-mode/--light-mode blocks in main.py). Each field is an SGR parameter string (no CSI/"m" wrapper); the render and repl packages share one Theme so their colors never drift. An empty field means "terminal default".

func DarkTheme

func DarkTheme() Theme

DarkTheme is aider's --dark-mode palette (brighter, for dark terminals).

func DefaultTheme

func DefaultTheme() Theme

DefaultTheme is aider's default palette: green input, blue assistant, bright-red errors, orange warnings. Truecolor for the exact hex values.

func LightTheme

func LightTheme() Theme

LightTheme is aider's --light-mode palette. aider names these colors ("green"/"blue"/"red"), which map to the 16-color SGRs; the warning stays truecolor orange.

type Thinking

type Thinking struct {
	// Marker writes a delimiter — dimmed in the terminal, plain elsewhere.
	Marker func(string)
	// Body writes the thinking text, through whatever renderer the caller uses.
	Body func(string)
	// CloseBody flushes that renderer and leaves the cursor on a fresh line. It
	// has to exist because Marker writes straight to the terminal while Body may
	// go through a buffering markdown renderer: anything the marker emits before
	// the body is closed arrives in the wrong place.
	CloseBody func()
	// Now is the clock the progress debounce reads; nil means time.Now. A seam,
	// so a test can drive the interval instead of sleeping through it.
	Now func() time.Time
	// Progress, when set, receives real-time "\r"-prefixed progress updates
	// while lines are being elided by a cap. Each call overwrites the previous
	// one on the terminal so the user can track that the model is still
	// thinking. End commits the final count through Marker with a "\n".
	// Leave nil for non-interactive output where "\r" would be noise.
	Progress func(string)
	// Display is how much to show.
	Display ThinkingDisplay
	// contains filtered or unexported fields
}

Thinking renders one reasoning block, and owns the part both outputs agree on: whether the block is inline or bracketed, how much of it to show, and where the markers go.

It lives here rather than in the REPL because script mode needs the same shape with different rendering — the terminal routes the text through a dimmed markdown renderer, a redirected run writes it plain — and two copies of this would drift the first time either was touched.

Most thinking is one line. In a seven-step session, five of seven blocks were a single sentence restating the tool call that immediately followed: "Let me check the output.go file", then Read output.go. So one line renders as a prefixed aside and several as a bracketed block, and the shape is read off the thinking itself.

func PlainThinking

func PlainThinking(w io.Writer, display ThinkingDisplay) *Thinking

PlainThinking builds a Thinking that writes markers and body straight to w, with no color and no markdown rendering — the shape a redirected run gets.

It still has to keep the fresh-line promise CloseBody makes, since a block need not end in a newline and the closing marker wants its own line. Nothing buffers here, so tracking the last byte written is the whole of it.

func (*Thinking) End

func (t *Thinking) End() bool

End closes the block and reports whether there was one. A one-liner needs no closing marker: the line it sits on is the whole of it.

Everything the marker writes comes after CloseBody, without exception. The body may be going through a renderer that buffers; the markers are not. Emit one before the other has been flushed and it lands in the middle of the text it was meant to follow.

func (*Thinking) Write

func (t *Thinking) Write(delta string)

Write feeds the next reasoning delta.

The shape cannot be decided as it streams: by the time a newline arrives the marker is already out. So the first line is held and nothing more — at most one line of latency, after which a long block streams live as it is generated. Newlines at either end are the provider's spacing rather than a second line, so only an interior one makes a block. Reading the text's own newlines is sound because the renderer does not wrap: ANSI uses its width for rule length alone.

type ThinkingDisplay

type ThinkingDisplay struct {
	Mode  ThinkingMode
	Lines int // meaningful only for ThinkingCapped
}

ThinkingDisplay is the caller's answer to "how much of it".

type ThinkingMode

type ThinkingMode int

ThinkingMode is what a Thinking does with a block. It mirrors config.ReasoningMode, which the caller translates — render does not import config, and this is the whole of what it needs to know.

const (
	ThinkingFull ThinkingMode = iota
	ThinkingCapped
	ThinkingOff
)

type Token

type Token int32

Token identifies a node kind in the streamed markdown tree. Values match smd.js's Token enum so the transliterated fixtures compare directly.

const (
	Document       Token = 1
	Paragraph      Token = 2
	Heading1       Token = 3
	Heading2       Token = 4
	Heading3       Token = 5
	Heading4       Token = 6
	Heading5       Token = 7
	Heading6       Token = 8
	CodeBlock      Token = 9
	CodeFence      Token = 10
	CodeInline     Token = 11
	ItalicAst      Token = 12
	ItalicUnd      Token = 13
	StrongAst      Token = 14
	StrongUnd      Token = 15
	Strike         Token = 16
	Link           Token = 17
	RawURL         Token = 18
	Image          Token = 19
	Blockquote     Token = 20
	LineBreak      Token = 21
	Rule           Token = 22
	ListUnordered  Token = 23
	ListOrdered    Token = 24
	ListItem       Token = 25
	Checkbox       Token = 26
	Table          Token = 27
	TableRow       Token = 28
	TableCell      Token = 29
	EquationBlock  Token = 30
	EquationInline Token = 31
)

Tree node kinds (smd.js Token values 1..31).

func (Token) String

func (t Token) String() string

String returns the smd.js token_to_string name (used in test diffs).

type ToolDiff

type ToolDiff struct {
	// contains filtered or unexported fields
}

ToolDiff renders a tool call's streaming arguments as a Git-style diff: the path on a header line, then removed lines in red and added lines in green. Feed it raw JSON argument fragments; it decodes and line-buffers internally. Colors use plain 31/32 (diff convention), gated on color.

write and bash stream line by line as their arguments arrive — the file's contents and the command are each one side of the story, so there is nothing to wait for. An edit is different: it carries a before and an after, and only with both in hand can the unchanged lines between them be shown as context rather than removed and added back. So an edit's two text fields accumulate whole and the diff is drawn in Flush. The header still prints the moment the path is complete, so the file being edited appears while the rest streams.

func NewToolDiff

func NewToolDiff(w io.Writer, color bool, theme Theme, tool string) *ToolDiff

NewToolDiff builds a diff renderer for one tool call writing to w.

func (*ToolDiff) Flush

func (d *ToolDiff) Flush()

Flush emits any buffered partial line, then an edit's diff; call once the tool call is complete.

func (*ToolDiff) Label

func (d *ToolDiff) Label() string

Label is the header line this diff belongs under, once the path field has completed; "" for a tool that has none.

func (*ToolDiff) SuppressHeader

func (d *ToolDiff) SuppressHeader()

SuppressHeader stops this diff from writing its own header line, leaving the caller to write it. A buffered call uses this: where it lands in the output is only settled when the set appends it, and only there can it be known whether the file has already been named.

func (*ToolDiff) Write

func (d *ToolDiff) Write(frag string)

Write feeds the next raw arguments fragment.

type ToolDiffSet

type ToolDiffSet struct {
	// contains filtered or unexported fields
}

ToolDiffSet fans a send's streamed tool-call fragments out to a ToolDiff per call index, so an Output can forward fragments without tracking indexes itself. A tool RendersDiff rejects is dropped and draws nothing.

Providers may stream several tool calls' arguments interleaved, so only the first call writes straight through; later calls buffer and are appended, each contiguous, in first-seen order on Flush. This keeps each diff whole instead of interleaving their lines.

func NewToolDiffSet

func NewToolDiffSet(w io.Writer, color bool, theme Theme) *ToolDiffSet

NewToolDiffSet builds a diff fan-out writing to w.

func (*ToolDiffSet) Drew

func (s *ToolDiffSet) Drew() bool

Drew reports whether any call in this set had something to render. read, grep, glob, ls, and check draw nothing — they print their own one-line outcome when they run — so a send made only of those has written nothing here and needs no separator after it.

func (*ToolDiffSet) Flush

func (s *ToolDiffSet) Flush()

Flush closes every open diff and appends the buffered ones after the live one, each whole, in first-seen order; then resets the set.

The buffered calls' headers are written here rather than by the diffs themselves, because this is the first point at which a call's position in the output is settled — and therefore the first point at which "is this the same file as the diff above?" has an answer. Several edits to one file print its name once and are separated by a blank line, which is what the repetition was standing in for.

func (*ToolDiffSet) Text

func (s *ToolDiffSet) Text(b []byte)

Text appends a rendered block of the model's prose to the sequence, so text written between two tool calls appears between their diffs.

func (*ToolDiffSet) Write

func (s *ToolDiffSet) Write(index int, name, frag string)

Write forwards an argument fragment for the tool call at index, opening a fresh diff the first time an index is seen. name is read from the first fragment (later fragments carry only args).

Jump to

Keyboard shortcuts

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