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 ¶
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 ¶
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 ¶
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 ¶
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) AtLineStart ¶
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.
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.
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 ¶
Before pays the debt, if there is one, immediately ahead of the group that is about to draw.
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 ¶
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 ¶
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.
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 ¶
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 ¶
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).
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 ¶
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 ¶
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.
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).