Class: Rubino::UI::StreamingMarkdown
- Inherits:
-
Object
- Object
- Rubino::UI::StreamingMarkdown
- Defined in:
- lib/rubino/ui/streaming_markdown.rb
Overview
Incremental block splitter for streamed markdown.
The model streams an assistant message token-by-token; we want to render COMPLETED markdown blocks above the composer as soon as they finish, while showing the still-incoming (incomplete) block raw in the live region. This buffer accumulates streamed text and decides where one block ends and the next begins, so CLI can render+commit a finished block and leave only the in-progress tail live.
Block boundary detection — a small line-oriented fence state machine (the mainstream approach used by md2term / mdterm / Glamour-style streamers: you must NOT render a fenced code block until its closing “‘ arrives, or half-open fences render as garbage):
* Lines are split on "\n". A line is "complete" once its terminating "\n"
has been seen; the trailing remainder (no "\n" yet) is the live tail.
* A line matching ^\s*``` toggles the fence state.
- Entering a fence STARTS a code block (the fence line joins it).
- Leaving a fence ENDS the code block (the closing fence joins it);
the block is reported complete.
* While INSIDE a fence, blank lines do NOT split — code keeps its blanks.
* While OUTSIDE a fence, a blank line ENDS the current prose block. The
blank line itself is consumed as the separator (not re-emitted).
API:
feed(text) -> Array<String> newly-completed block texts (state advances)
tail -> String the current incomplete block, raw (live)
flush -> String|nil the remaining buffered block on stream end
(an unclosed fence is returned so the caller
can emit it as plain text — never lost)
Constant Summary collapse
- FENCE_RE =
/\A\s*```/- LIST_ITEM_RE =
An ordered (“1. ”, “2) ”) or unordered (“- ”, “* ”, “+ ”) list item. Used so a loose list (blank lines BETWEEN items) is kept as ONE block instead of being split per-item: each split item was re-rendered on its own, and kramdown restarts ordered numbering at 1 for every block, which produced the “1. Mercury / 1. Venus / 1. Earth” off-by-one (B4).
/\A\s*(?:[-*+]|\d+[.)])\s/
Instance Method Summary collapse
-
#feed(text) ⇒ Object
Accumulate streamed text; return the list of block texts that became COMPLETE as a result of this feed (possibly empty).
-
#flush ⇒ Object
Drain the remainder on stream end.
-
#initialize ⇒ StreamingMarkdown
constructor
A new instance of StreamingMarkdown.
-
#live_tail(rows = 1) ⇒ Object
The in-progress tail to show live (raw): the LAST
rowslines of the in-flight block — its most recent already-newlined lines plus the un-newlined remainder. -
#tail ⇒ Object
The current incomplete block as raw text: any lines already buffered for the in-progress block plus the un-newlined remainder.
Constructor Details
#initialize ⇒ StreamingMarkdown
Returns a new instance of StreamingMarkdown.
44 45 46 47 48 49 50 |
# File 'lib/rubino/ui/streaming_markdown.rb', line 44 def initialize @pending = +"" # un-newlined remainder (the live tail-in-progress line) @block = [] # completed lines accumulated for the current block @in_fence = false @in_list = false # current block is a markdown list (keep loose items together) @blanks = 0 # blank lines buffered inside a list, re-emitted iff it continues end |
Instance Method Details
#feed(text) ⇒ Object
Accumulate streamed text; return the list of block texts that became COMPLETE as a result of this feed (possibly empty). Advances state.
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/rubino/ui/streaming_markdown.rb', line 54 def feed(text) return [] if text.nil? || text.empty? @pending << text completed = [] while (idx = @pending.index("\n")) line = @pending[0...idx] @pending = @pending[(idx + 1)..] || +"" block = consume_line(line) completed << block if block end completed end |
#flush ⇒ Object
Drain the remainder on stream end. Promotes any un-newlined remainder to a final line, then returns the buffered block text (or nil if empty). An unclosed fence is returned all the same — the caller emits it as plain so output is never dropped.
102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/rubino/ui/streaming_markdown.rb', line 102 def flush unless @pending.empty? @block << @pending @pending = +"" end return nil if @block.empty? text = @block.join("\n") @block = [] @in_fence = false @in_list = false @blanks = 0 text end |
#live_tail(rows = 1) ⇒ Object
The in-progress tail to show live (raw): the LAST rows lines of the in-flight block — its most recent already-newlined lines plus the un-newlined remainder. Newline-joined; the live region renders one row per line.
Why a rolling window and not the whole #tail: the live region must stay bounded (a long open fence/table must never push the prompt off-screen), so we keep “only the last block can change” (Textual/Rich, Streamdown, Glamour-style streamers) but show a FEW trailing lines instead of just the one being typed — a long list block used to vanish line-by-line as each item completed, leaving a single flickering raw line until the whole block committed (#127). Earlier lines stay buffered and the block still snaps to rendered markdown the moment it completes.
92 93 94 95 96 |
# File 'lib/rubino/ui/streaming_markdown.rb', line 92 def live_tail(rows = 1) lines = @block.last(rows) lines += [@pending] unless @pending.empty? lines.last(rows).join("\n") end |
#tail ⇒ Object
The current incomplete block as raw text: any lines already buffered for the in-progress block plus the un-newlined remainder. Shown live; it gets re-rendered + committed once its block completes.
73 74 75 76 77 |
# File 'lib/rubino/ui/streaming_markdown.rb', line 73 def tail parts = @block.dup parts << @pending unless @pending.empty? parts.join("\n") end |