# frozen_string_literal: truerequire_relative"../utils/limit_stack"moduleClackymoduleUI2# OutputBuffer manages the logical sequence of rendered output lines.## It replaces the scattered state that used to live across# LayoutManager (@output_buffer + @output_row) and UIController# (@progress_message / "last line" assumptions).## Core concepts:## - Every append returns an +id+. Callers can later +replace(id, ...)+# or +remove(id)+ that exact entry without relying on "the last line".# - Each entry tracks whether it has been "committed" to the terminal# scrollback (i.e. scrolled off the top of the visible window by a# native terminal \n). Committed entries are NEVER re-drawn from the# buffer again — this is what prevents the classic "scroll up shows# a duplicated line" bug.# - Entries may contain multi-line content (already wrapped). Each entry# stores its visual line count so the renderer can compute exact rows# to clear when replacing or removing.## The buffer itself does NOT talk to the terminal. It is a pure data# structure; a renderer (LayoutManager) consumes it through the# snapshot APIs: +visible_entries+, +entry_by_id+, +tail_lines+.classOutputBuffer# A single logical output entry.## @!attribute id [Integer] Monotonic id, unique within the buffer# @!attribute lines [Array<String>] Rendered (already-wrapped) visual lines# @!attribute kind [Symbol] :text | :progress | :system (hint for renderer)# @!attribute committed [Boolean] True once pushed into terminal scrollbackEntry=Struct.new(:id,:lines,:kind,:committed,:committed_line_offset,keyword_init: true)do# Visual row count this entry currently OCCUPIES on screen. Once a# prefix of the entry's lines has been pushed into scrollback by# a scroll+partial-commit, those prefix rows are no longer on# screen — so height drops accordingly. When +committed+ flips to# true the entry is considered fully off-screen and height is 0.defheightreturn0ifcommittedlines.length-(committed_line_offset||0)end# The currently on-screen lines of this entry (lines that haven't# been pushed to scrollback yet). Returns [] once fully committed.defvisible_linesreturn[]ifcommittedoff=committed_line_offset||0off.zero??lines:lines[off..]||[]enddefto_slines.join("\n")endendDEFAULT_MAX_ENTRIES=2000attr_reader:entriesdefinitialize(max_entries: DEFAULT_MAX_ENTRIES)@entries=[]# Array<Entry> in insertion order@index={}# id => Entry (fast lookup)@next_id=1@max_entries=max_entries@mutex=Mutex.new# Monotonic counter incremented every time the buffer changes.# Renderers can compare this against a saved version to decide# whether their cached screen image is still valid.@version=0end# Append a new entry. +content+ may be a String (may include \n) or# an Array<String> of already-split lines.## @param content [String, Array<String>]# @param kind [Symbol] :text (default), :progress, :system# @return [Integer] id of the newly created entrydefappend(content,kind: :text)@mutex.synchronizedolines=normalize_lines(content)entry=Entry.new(id: next_id!,lines: lines,kind: kind,committed: false,committed_line_offset: 0)@entries<<entry@index[entry.id]=entrytrim_if_neededbump_versionentry.idendend# Replace an existing entry's content. If the id no longer exists# (e.g. the entry was trimmed or already committed and recycled),# this is a no-op and returns nil.## Replacing a committed entry is silently ignored — committed content# lives in terminal scrollback and cannot be edited in place. Same# for an entry whose prefix has been partial-committed: the prefix# is already in scrollback and replacing the entry would either# strand those lines (if shorter) or duplicate them (if longer).## @param id [Integer]# @param content [String, Array<String>]# @return [Integer, nil] Old visible height if replaced, nil if no-opdefreplace(id,content)@mutex.synchronizedoentry=@index[id]returnnilunlessentryreturnnilifentry.committedreturnnilif(entry.committed_line_offset||0)>0old_height=entry.heightentry.lines=normalize_lines(content)bump_versionold_heightendend# Remove an entry. Committed entries cannot be removed (they are in# terminal scrollback). Partially-committed entries also cannot be# removed — their prefix is frozen in scrollback. Returns the# removed Entry, or nil if no-op.## @param id [Integer]# @return [Entry, nil]defremove(id)@mutex.synchronizedoentry=@index[id]returnnilunlessentryreturnnilifentry.committedreturnnilif(entry.committed_line_offset||0)>0@entries.delete(entry)@index.delete(id)bump_versionentryendend# Mark an entry (and every older live entry) as committed to terminal# scrollback. Called by the renderer after it has emitted a native \n# that scrolled the top-of-screen row off into scrollback.## Committing always flows from oldest → newest: if entry X is# committed, every entry older than X must also be committed, because# they have already scrolled past X on the screen.## @param id [Integer]defcommit_through(id)@mutex.synchronizedocommitted_any=false@entries.eachdo|e|breakife.id>idunlesse.committede.committed=truecommitted_any=trueendendbump_versionifcommitted_anyendend# Commit the oldest N VISUAL rows. Used when the renderer scrolls N# lines off the top via native \n. Commits are precise at the visual# row granularity (even mid-entry): if the oldest live entry is# multi-line and only its prefix has scrolled off, that prefix is# recorded in +committed_line_offset+ and only the still-visible# suffix remains eligible for future repaints.## This is the critical invariant for preventing the "scroll up to# see a line already in scrollback, then render_output_from_buffer# repaints it again on screen" duplicate-output regression: every# visual row that went into terminal scrollback MUST be removed# from the buffer's pool of repaintable live rows, regardless of# whether it sat alone in a 1-line entry or at the top of a 10-line# entry.## @param line_count [Integer] Number of visual lines pushed to scrollback# @return [Integer] Number of entries NEWLY marked fully committed# (partial commits on an entry do NOT count toward this total —# callers use the return value only as a debug hint, not for row# bookkeeping).defcommit_oldest_lines(line_count)return0ifline_count<=0@mutex.synchronizedoremaining=line_countcommitted=0changed=false@entries.eachdo|e|breakifremaining<=0nextife.committedh=e.heightifh<=remaining# Full scroll-off of this entry's remaining visible rows.e.committed=truee.committed_line_offset=e.lines.length# normalizeremaining-=hcommitted+=1changed=trueelse# Partial scroll: record the new offset and stop (there are# still visible rows of this entry on screen).e.committed_line_offset=(e.committed_line_offset||0)+remainingremaining=0changed=truebreakendendbump_versionifchangedcommittedendend# Entries that are still live (not committed). These are candidates# for re-rendering into the visible output area.## @return [Array<Entry>]deflive_entries@mutex.synchronize{@entries.reject(&:committed).dup}end# The last N *visual lines* across live entries, preserving entry# boundaries. Returns an Array<String> suitable for row-by-row# painting. If the last live entry is taller than +n+, only its last# +n+ lines are returned.## @param n [Integer]# @return [Array<String>]deftail_lines(n)return[]ifn<=0@mutex.synchronizedocollected=[]@entries.reverse_eachdo|e|breakifcollected.length>=nnextife.committed# The entry's still-visible lines (excluding any prefix already# committed to scrollback via a partial commit).vis=e.visible_linesnextifvis.empty?# Prepend the entry's visible lines in orderremaining=n-collected.lengthifvis.length<=remainingcollected=vis+collectedelsecollected=vis.last(remaining)+collectedbreakendendcollectedendend# Look up an entry by id.# @param id [Integer]# @return [Entry, nil]defentry_by_id(id)@mutex.synchronize{@index[id]}end# Does this id still refer to a live, editable entry?# @param id [Integer]deflive?(id)@mutex.synchronizedoe=@index[id]!!(e&&!e.committed)endend# Does this id refer to an entry that can still be replaced or# removed in place? A partially-committed entry (prefix already in# scrollback via a scroll) is NOT editable — its visible suffix is# frozen until it either fully commits or (rare) a full repaint# rewrites the screen.## @param id [Integer]deffully_editable?(id)@mutex.synchronizedoe=@index[id]!!(e&&!e.committed&&(e.committed_line_offset||0)==0)endend# Total number of entries (committed + live) currently tracked.defsize@mutex.synchronize{@entries.size}end# Number of live entries.deflive_size@mutex.synchronize{@entries.count{|e|!e.committed}}end# Total visual lines across live entries.deflive_line_count@mutex.synchronize{@entries.sum{|e|e.committed?0:e.height}}end# Monotonic version (incremented on every mutation).defversion@versionend# Clear everything. Used by /clear command.defclear@mutex.synchronizedo@entries.clear@index.clearbump_versionendend# --- helpers ----------------------------------------------------------privatedefnext_id!id=@next_id@next_id+=1idendprivatedefbump_version@version+=1end# Drop the oldest entries when the buffer grows past the cap. This is# a soft safety net — in practice live entries stay small because# write_output_line commits them to scrollback as they scroll off.privatedeftrim_if_neededwhile@entries.size>@max_entriesdropped=@entries.shift@index.delete(dropped.id)endend# Normalize input into an array of visual lines (no trailing \n kept).# Empty strings are preserved so callers can explicitly append blank# rows.## Rules:# - nil → [""]# - Array<String> → deep copy (caller has pre-split)# - "hello" → ["hello"]# - "a\nb" → ["a", "b"]# - "a\n" → ["a"] (trailing newline is not a new line)# - "a\n\n" → ["a", ""] (explicit blank line preserved)# - "" → [""]privatedefnormalize_lines(content)casecontentwhennil[""]whenArraycontent.map(&:to_s)elsestr=content.to_sreturn[""]ifstr.empty?# Strip a single trailing newline so "a\n" → ["a"], but keep# explicit blank lines ("a\n\n" → ["a", ""]).str=str.chomp("\n")str.split("\n",-1)endendendendend