bubbleteandmd

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 19 Imported by: 0

README

bubbleteandmd

Streaming Markdown for terminal programs built on bubbletea. A Go port of the ideas in llx-markdown (MIT; see NOTICE), for the terminal. First user: dun.

Status: layer 1 (streaming core: NewStream, Frame, Final) in v0.1.0. Layers 2-3 in progress.

Problem

An LLM reply arrives token by token. Rendering the whole document again per frame costs more as the reply grows, and half-written syntax (**bold, an open code fence, a link without its )) shows raw until it closes.

Design (three layers, each usable alone)

  1. Streaming core. A line scanner splits the source into blocks only where a full parse would split. Blocks before the last two are final: rendered once, cached by (source, width, style). The block before the last renders again only when its source changes. The last block (the tail) is healed while streaming only — the final text renders as written — and rendered again. Lists, blockquotes, footnotes and link definitions force a wider re-render where needed. Block rendering uses glamour.
  2. Directive blocks. llx-markdown's syntax, unchanged, so the same model output renders in both: > [!NOTE]-style callouts, > [!STEPS], > [!TABS] + #### panels, > [!STATS], > [!PROS]/> [!CONS], > [!DETAILS], > [!SOURCES], ```tree fences, title="…" on code fences, GFM task lists. Drawn with lipgloss. Mermaid and math show as code.
  3. Interactive blocks. A bubbletea component that holds fold and tab state per block id across re-renders: fold [!DETAILS] and trees, switch tabs. While a block streams, tabs show stacked.

Plus a short format prompt that teaches a model the directive forms (like llx-markdown's answerFormatPrompt).

Use

r, err := bubbleteandmd.NewRenderer(styles.DarkStyleConfig, width) // or a raw *glamour.TermRenderer
cache := bubbleteandmd.NewCache(512)                              // one per program
s := bubbleteandmd.NewStream(r, fmt.Sprintf("dark/%d", width), cache) // one per reply
out, err := s.Frame(reply) // each time the reply grows
out, err = s.Final(reply)  // once, when the reply ends
  • One Stream per reply. When the width or style changes, make a new Renderer and a new Stream with a key that names both.
  • Call Final when the stream ends and show its output. Frame hides a partial table row and other half-written syntax, so the last Frame of a reply that ends in one is not the reply.
  • Pass the Renderer raw. Process the source before Frame and the output after; a wrapper that trims glamour's newlines gives ErrTrimmed.
  • On an error, show the raw text (after Sanitize).
  • Call Sanitize on model text you render outside Stream.

Rules

  • Terminal controls in the source never reach the terminal: Stream calls Sanitize first; call it yourself for text you render outside Stream.
  • Healed text never reaches the stored source.
  • Joined blocks must equal glamour's whole-document spacing: the final frame must not move when the stream ends (checked by a test that compares streamed and whole renders at every chunk boundary).

Documentation

Overview

Package bubbleteandmd renders streaming LLM Markdown in a terminal, for bubbletea programs. Finished blocks are rendered once and cached; only the open tail is rendered again as tokens arrive, and that tail is healed (an open fence, `**`, a half-typed link) so raw syntax does not show mid-stream. It reads llx-markdown's directive syntax (`> [!STEPS]`, `> [!TABS]`, ```tree fences …). See README.md.

Index

Constants

This section is empty.

Variables

View Source
var ErrTrimmed = errors.New("bubbleteandmd: Renderer output ends a paragraph without a newline; pass a raw *glamour.TermRenderer")

ErrTrimmed is returned by Frame and Final when the Renderer ends a paragraph without its newline (a wrapper that trims glamour's output): the blocks cannot be joined as the whole document renders.

Functions

func Sanitize added in v0.2.0

func Sanitize(src string) string

Sanitize removes from src what a terminal reads as a command, not text, so model output cannot write the clipboard (OSC 52), set the title, move the cursor, clear the screen or overwrite a line. glamour v2.0.1 passes these through unchanged, in prose, tables and code.

Removed:

  • C0 controls (U+0000–U+001F) except tab and newline; "\r\n" becomes "\n" and any other "\r" goes too. ESC goes, so the text of an escape sequence after it stays as plain characters.
  • DEL (U+007F) and C1 controls (U+0080–U+009F), among them CSI U+009B.
  • bytes that are not UTF-8 (a raw 0x9B byte is one).
  • an incomplete UTF-8 sequence at the end of src: it can complete in the next chunk, so it is held back, not shown as an error.
  • Unicode bidi embeddings, overrides and isolates (U+202A–U+202E, U+2066–U+2069): they reorder the text shown, so a line can read differently from its bytes (spoofing).

Everything else stays, ZWJ and emoji included.

Sanitize is prefix-stable: for any cut k, Sanitize(s[:k]) is a prefix of Sanitize(s), so a stream that grows keeps its rendered blocks. It is idempotent. Stream.Frame and Stream.Final call it; a caller that renders model text some other way must call it first.

Types

type Cache

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

Cache holds rendered stable blocks, keyed by (Stream key, block source). One Cache can serve many Streams; it is safe for concurrent use.

func NewCache

func NewCache(max int) *Cache

NewCache returns a cache of at most max rendered blocks.

type DirectiveRenderer added in v0.2.0

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

DirectiveRenderer renders Markdown as glamour does, and draws llx-markdown's directive forms (callouts, steps, tabs, stats, pros and cons, details, sources, quotes with a source, file trees, titled code fences) and `$$` formulas. It satisfies Renderer, so NewStream can stream it. A document with no form and no `$$` formula renders exactly as glamour renders it.

A DirectiveRenderer is used by one goroutine at a time.

func NewRenderer added in v0.2.0

func NewRenderer(style ansi.StyleConfig, width int, opts ...glamour.TermRendererOption) (*DirectiveRenderer, error)

NewRenderer returns a renderer for style at word wrap width. opts go to glamour after the style, the word wrap and the chroma formatter; they must not change the style or the word wrap.

Code blocks use chroma formatter "bubbleteandmd-terminal256" (same bytes as "terminal256", style tables cached; chromafmt.go). The first NewRenderer in a process registers it in chroma's formatter registry, which has no lock: make it before other goroutines render with chroma.

func (*DirectiveRenderer) GlamourOptions added in v0.3.0

func (r *DirectiveRenderer) GlamourOptions() (ansi.Options, bool)

GlamourOptions returns the options of the glamour renderer that draws plain Markdown at the top level: the style, the word wrap and the chroma formatter. ok is false when NewRenderer was given glamour options: they cannot be read back. Stream uses it to render a growing table from cached rows (GlamourOptions).

func (*DirectiveRenderer) Render added in v0.2.0

func (r *DirectiveRenderer) Render(md string) (string, error)

Render renders md. An md that starts with join.Sentinel (as Stream sends every block after the first) renders its first block in not-first place.

type GlamourOptions added in v0.3.0

type GlamourOptions interface {
	GlamourOptions() (opts ansi.Options, ok bool)
}

GlamourOptions is an optional interface of a Renderer: the glamour options it renders a plain Markdown table with. ok is false when they are not known (for example, glamour options were given that cannot be read back). When ok, Stream renders a growing table in the tail from cached rows (internal/tablestream), with the same bytes as r.Render; without it, the tail renders whole on every call. *DirectiveRenderer implements it.

type Renderer

type Renderer interface {
	Render(string) (string, error)
}

Renderer renders one whole Markdown document. *glamour.TermRenderer satisfies it. A Renderer is used by one goroutine at a time.

Pass a raw *glamour.TermRenderer (or a DirectiveRenderer), not a wrapper that changes its output: Stream renders each block alone and joins the pieces by the document prefix, suffix and line ends glamour writes. A wrapper that trims the newlines makes Frame and Final return ErrTrimmed. Do pre-processing on src before Frame; do post-processing on the output of Frame and Final.

type Stream

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

Stream renders one document that grows at its end. Blocks before the last two are rendered once and cached; the block before the last is rendered when its source changes (the splitter can still reopen it); the last block (the tail) is rendered on every call. Both first remove terminal controls (Sanitize). Frame heals the tail; Final does not. The result equals r.Render of the whole sanitized (and healed, for Frame) text, with one exception (Carl B1): while a code fence of more than codestream.ChunkLines lines grows in the tail, Frame renders it in chunks, and SGR bytes (colours at a chunk seam) can differ; lines and their text do not. Final is exact. A growing table tail renders from cached rows, exact, when the Renderer tells its options (GlamourOptions).

A Stream is not safe for concurrent use.

func NewStream

func NewStream(r Renderer, key string, c *Cache) *Stream

NewStream returns a Stream that renders with r and caches stable blocks in c under key. key must name everything that changes r's output (dun: width and style). c may be shared by Streams; nil means no cache.

func (*Stream) Final

func (s *Stream) Final(src string) (string, error)

Final renders src as written, with no healing; only Sanitize applies.

func (*Stream) Frame

func (s *Stream) Frame(src string) (string, error)

Frame renders src while it is still streaming: the tail is healed (heal.Close), so half-written syntax does not show raw.

Directories

Path Synopsis
internal
blocks
Package blocks cuts a Markdown document into top-level blocks that can be rendered, and cached, one by one.
Package blocks cuts a Markdown document into top-level blocks that can be rendered, and cached, one by one.
codestream
Package codestream renders one growing fenced code block in chunks, so a frame does not re-render (and re-lex) the whole block.
Package codestream renders one growing fenced code block in chunks, so a frame does not re-render (and re-lex) the whole block.
corpus
Package corpus holds the streaming test corpora, the seeded reply generator and the chunking helpers ported from llx-markdown, for invariant tests.
Package corpus holds the streaming test corpora, the seeded reply generator and the chunking helpers ported from llx-markdown, for invariant tests.
directive
Package directive reads llx-markdown's directive forms out of one top-level block: callouts, steps, tabs, stats, pros and cons, folded sections, sources, quotes with a source, file trees and titled code fences.
Package directive reads llx-markdown's directive forms out of one top-level block: callouts, steps, tabs, stats, pros and cons, folded sections, sources, quotes with a source, file trees and titled code fences.
heal
Package heal stabilizes the tail of a markdown text that is still streaming.
Package heal stabilizes the tail of a markdown text that is still streaming.
join
Package join puts separately rendered glamour blocks together so that the result equals glamour's render of the whole document.
Package join puts separately rendered glamour blocks together so that the result equals glamour's render of the whole document.
lru
Package lru is a small bounded least-recently-used cache.
Package lru is a small bounded least-recently-used cache.
tablestream
Package tablestream renders a growing GFM table block in less time than a whole render, with the same bytes.
Package tablestream renders a growing GFM table block in less time than a whole render, with the same bytes.
Package prompt holds the instructions that teach a model to write the directive forms the renderer draws.
Package prompt holds the instructions that teach a model to write the directive forms the renderer draws.

Jump to

Keyboard shortcuts

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