Class: Rubino::UI::StreamingMarkdown

Inherits:
Object
  • Object
show all
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

Constructor Details

#initializeStreamingMarkdown

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

#flushObject

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

#tailObject

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