Class: Rubino::UI::BottomComposer
- Inherits:
-
Object
- Object
- Rubino::UI::BottomComposer
- Defined in:
- lib/rubino/ui/bottom_composer.rb
Overview
A persistent, VISIBLE, editable input line pinned at the bottom of the terminal while agent output streams ABOVE it and scrolls into native scrollback. No alternate screen, no mouse tracking — trackpad/wheel scroll and text selection keep working like a normal shell.
This is the Ruby equivalent of prompt_toolkit’s patch_stdout / run_in_terminal: every write that should land above the prompt goes through #print_above, which erases the input line, emits the output (it scrolls up), then redraws the input from the preserved buffer. A render Mutex makes each erase→print→redraw an atomic frame so the streaming writer and the keystroke handler never interleave a half-frame.
Responsibilities:
* own the editable +@buffer+ and draw it ({#draw_input})
* funnel all turn output through {#print_above} so it never clobbers the
input line (the {StdoutProxy} swaps +$stdout+ for the turn so the ~30
existing +$stdout.print/puts+ call sites need zero changes)
* run a raw, char-by-char keystroke loop in a thread that echoes typed
chars and pushes completed lines into the shared
{Interaction::InputQueue} the steering logic already consumes
Four collaborators carry the cohesive sub-jobs behind narrow seams, with the composer as the facade that owns the render mutex and the public API: EscapeReader (escape-sequence byte reading/parsing → semantic actions), CompletionMenu (the /command + @file dropdown state machine + rows), QueuedIndicators (the “⏳ queued:” stack + rows) and LiveRegion (the erase→commit→redraw frame discipline + width math). StatusBar formats the model/context line the composer pins BELOW the input (see below).
The INPUT BLOCK is multi-row: a buffer longer than the terminal width WRAPS and the input grows downward as the user types (like Claude Code), up to max_input_rows visual rows; past the cap it scrolls vertically, keeping the caret row in view. A multi-line PASTE keeps its REAL newlines in the buffer and the submitted payload (#57) and each newline now renders as a REAL row break in the editing view. ↑/↓ move by visual row while the caret is inside a multi-row buffer and fall back to history navigation on the first/last row (the readline/Claude Code convention). Below the input block an optional dim STATUS BAR shows the model id + context saturation; it is the live region’s LAST row, redrawn with every frame and omitted on narrow (< MIN_STATUS_COLS) terminals.
(Two earlier MVP limitations no longer apply: arrows/Home/End/Delete/ word-jump now drive the cursor via #consume_escape_sequence, and the draw/wrap/clamp paths all measure by DISPLAY width — a wide CJK/emoji glyph counts as two columns — so fullwidth lines wrap at the right column instead of “slightly early”.)
Constant Summary collapse
- PROMPT =
"❯ "- ANSI_RE =
/\e\[[0-9;]*m/- MAX_CARD_ROWS =
Hard ceiling on the subagent card block (rows ABOVE the partial + prompt). The registry caps live children at MAX_CONCURRENT (3) and the formatter adds an overflow + hint line, so 5 rows covers the worst case while guaranteeing the live region can never grow unbounded and push the prompt off-screen — a corrupt caller is clamped, not trusted.
6- MAX_PARTIAL_ROWS =
Hard ceiling on the live partial rows so a runaway caller can never push the prompt off-screen (mirrors MAX_CARD_ROWS for the card block).
4- MAX_INPUT_ROWS =
Default cap on the input block’s visual rows (config: display.input_max_rows, threaded in by the chat command). Past it the block scrolls vertically, keeping the caret row in view, so a huge paste can never push the live region off-screen.
8- MIN_STATUS_COLS =
The status bar is omitted on terminals narrower than this — at that width the truncated line carries no information worth a row.
40- QUEUED_PREFIX =
QUEUED-message prefix: submitting a line that starts with this queues the REST instead of interrupting — the discoverable, terminal-independent fallback for Alt+Enter (which some terminals don’t deliver).
"/queued "- ESC_INTERRUPT_HINT =
The type-ahead AFFORDANCE shown in the status row while a turn is active (#421): Esc cancels the current turn (Enter now QUEUES). Kept dim and parenthetical so it reads as a hint, not a chrome label.
"(esc to interrupt)"- DOUBLE_ESC_SECONDS =
Double-Esc window (seconds): two LONE Esc presses within this at the IDLE prompt fire the
on_double_eschook (the Esc-Esc rewind picker —the Claude Code muscle-memory chord). Tight enough that a deliberate single Esc (menu dismiss) followed by an unrelated Esc later never reads as a chord. 0.4- PASTE_ON =
Bracketed paste (DEC 2004): the terminal wraps pasted text in ESC[200~ … ESC[201~ so we can tell a PASTE from typed keystrokes and keep each embedded n from submitting a half-line (L1 — “pasteline2” glue). The body is inserted as ONE editable string with its REAL newlines preserved (#57, see #submit_paste); each renders as a real row break in the multi-row input block. We enable it on start, disable on stop/suspend; the EscapeReader accumulates the body between the markers.
"\e[?2004h"- PASTE_OFF =
"\e[?2004l"
Class Attribute Summary collapse
-
.current ⇒ Object
Returns the value of attribute current.
Instance Attribute Summary collapse
-
#buffer ⇒ Object
readonly
The current editable buffer (test/inspection helper).
-
#cards ⇒ Object
readonly
The card rows currently shown (test/inspection helper).
-
#output ⇒ Object
readonly
The REAL terminal IO captured before the StdoutProxy swap.
Class Method Summary collapse
-
.active?(input: $stdin, output: $stdout) ⇒ Boolean
True only when both ends are real TTYs.
-
.run_in_terminal ⇒ Object
Run
blockwith the REAL terminal restored — the Ruby equivalent of prompt_toolkit’srun_in_terminal.
Instance Method Summary collapse
-
#announce(text) ⇒ Object
Sets the TRANSIENT announcement row (the Shift+Tab mode confirmation).
-
#announce_pending(text) ⇒ Object
TRAP-SAFE announce for the during-turn Ctrl+C double-tap hint (#426).
-
#begin_content_stream ⇒ Object
Marks the start of an ACTIVE content stream (called by the CLI when the first answer token arrives).
-
#begin_turn ⇒ Object
Marks the START of a turn — the chat loop’s run_turn calls this when it hands a prompt to the runner.
-
#caret_position(rows) ⇒ Object
The caret’s [visual_row, display_col] within a layout.
-
#commit_queued(msg) ⇒ Object
Remove the FIRST pending “⏳ queued:” indicator matching
msg(public: the chat loop calls this when the queued item’s turn starts, so the indicator disappears from above the input as the item is committed as a normal message). -
#draw_input ⇒ Object
Redraws the INPUT BLOCK — the wrapped buffer rows plus the status bar — and parks the terminal cursor at the insertion point (@cursor).
-
#end_content_stream ⇒ Object
Marks the end of the content stream (CLI stream_end / finalize).
-
#end_turn ⇒ Object
Marks the END of a turn — the chat loop’s run_turn ‘ensure` calls this AFTER the runner has fully unwound (so the turn-summary footer is already in scrollback).
-
#finalize_region ⇒ Object
Row-accurately ERASE the whole live region in place and reset its on-screen geometry to a clean blank top row — used by the stream FINALIZE / INTERRUPT / force-summary paths right before they commit their last line (#421).
-
#handle_key(ch) ⇒ Object
Feeds a single character through the edit logic.
-
#idle_interrupt(window: 2.0) ⇒ Object
Handle a Ctrl+C pressed at the IDLE prompt (BH-2).
-
#initialize(input_queue:, input: $stdin, output: $stdout, prompt: PROMPT, rail: nil, on_ctrl_o: nil, on_mode_cycle: nil, completion_source: nil, history: nil, echo: :queued, on_interrupt: nil, pending_queued: nil, status_line: nil, max_input_rows: nil, paste_store: nil, on_double_esc: nil, on_agent_cycle: nil, on_escape: nil, on_busy_command: nil) ⇒ BottomComposer
constructor
A new instance of BottomComposer.
-
#interrupt_hint ⇒ Object
The dim “(esc to interrupt)” type-ahead affordance shown in the status row while a turn is active (#421).
-
#layout_input ⇒ Object
Lays out @buffer into wrapped VISUAL rows at the current width.
-
#menu_open? ⇒ Boolean
True when the /command + @file completion menu is open (inspection helper; the reader/specs check it to branch Tab/Enter/Esc handling).
-
#park_caret(rows, caret_col, below) ⇒ Object
Park the terminal cursor at the caret after the block is fully printed (relative moves are only safe once nothing else will scroll): walk up past the rows below the caret row, re-home, and step right to the caret column.
-
#partial? ⇒ Boolean
True when a live partial line is currently shown above the prompt.
- #pastel ⇒ Object
-
#prefill(text) ⇒ Object
Replaces the editable buffer with
text— MULTILINE-SAFE: real newlines stay in the buffer and render as real row breaks, exactly like a bracketed paste — parking the caret at the end, ready to edit. -
#print_above(str) ⇒ Object
Commits one block of output ABOVE the input line — it scrolls up into native scrollback — then redraws the prompt.
-
#resize ⇒ Object
Recomputes width from the terminal and redraws under the mutex.
-
#resume ⇒ Object
RESUME after #suspend: restore the StdoutProxy, re-enter raw mode, restart the reader, and redraw the input line from the preserved buffer.
-
#row_budget ⇒ Object
The display columns available per input row: one short of the width so a glyph in the final column never arms the terminal’s deferred auto-wrap (the same rule LiveRegion#emit_row applies).
-
#set_cards(lines) ⇒ Object
Sets the SUBAGENT CARD block — a small list of collapsed live rows shown above the streamed partial and the prompt (Variant A).
-
#set_partial(str) ⇒ Object
Renders a LIVE, un-committed streamed line on the row directly above the prompt, redrawn in place as it grows (it does NOT scroll).
-
#set_status(text) ⇒ Object
Updates the status bar pinned below the input (model + context saturation — see StatusBar) and repaints in place.
-
#start ⇒ Object
Starts the keystroke reader thread and draws the initial prompt.
-
#status_row ⇒ Object
The status-bar row for this frame, or nil when there is no bar: the status text is empty, the terminal is too narrow to be useful, or the styled line wouldn’t fit the row (omit whole rather than truncate mid-ANSI — a cut escape sequence would leak attributes into the terminal).
-
#stop ⇒ Object
Stops the reader thread, restores cooked mode, and leaves the cursor on a fresh line so the next REPL prompt isn’t glued to the input line.
-
#streaming? ⇒ Boolean
True while the model’s ANSWER content is actively streaming.
-
#suspend ⇒ Object
PAUSE the composer so an interactive prompt can own the real terminal (see BottomComposer.run_in_terminal).
-
#visible_input_rows ⇒ Object
The PRINTED input rows for this frame plus the caret position within them: the layout, windowed to @max_input_rows when the buffer outgrows the cap (the window follows the caret row minimally, like a scrolling viewport), each row rendered to its final string (prompt prefix + token highlight on a single-row buffer; plain continuation rows).
Constructor Details
#initialize(input_queue:, input: $stdin, output: $stdout, prompt: PROMPT, rail: nil, on_ctrl_o: nil, on_mode_cycle: nil, completion_source: nil, history: nil, echo: :queued, on_interrupt: nil, pending_queued: nil, status_line: nil, max_input_rows: nil, paste_store: nil, on_double_esc: nil, on_agent_cycle: nil, on_escape: nil, on_busy_command: nil) ⇒ BottomComposer
Returns a new instance of BottomComposer.
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'lib/rubino/ui/bottom_composer.rb', line 175 def initialize(input_queue:, input: $stdin, output: $stdout, prompt: PROMPT, rail: nil, on_ctrl_o: nil, on_mode_cycle: nil, completion_source: nil, history: nil, echo: :queued, on_interrupt: nil, pending_queued: nil, status_line: nil, max_input_rows: nil, paste_store: nil, on_double_esc: nil, on_agent_cycle: nil, on_escape: nil, on_busy_command: nil) @input_queue = input_queue @input = input @output = output @on_ctrl_o = on_ctrl_o @on_mode_cycle = on_mode_cycle # Invoked on a Tab with nothing to complete (empty buffer, menu closed): # cycle the active PRIMARY agent and adopt the returned status-bar line # — the agent counterpart of @on_mode_cycle (Shift+Tab). nil ⇒ Tab stays # a plain completion key. @on_agent_cycle = on_agent_cycle @on_double_esc = on_double_esc # Invoked on a LONE Esc at the idle prompt with no menu open, BEFORE the # Esc-Esc rewind chord arms (#319). Returns truthy to CONSUME the Esc # (the idle "polishing… (Esc to skip)" cancel): a single Esc then cancels # the detached post-turn polishing instead of arming rewind. Returns # falsy (nothing to cancel) to fall through to the normal arm. Runs on # the reader thread — the hook must only flip a flag, never block. @on_escape = on_escape # @last_esc_at: monotonic time of the last LONE Esc — nil (unarmed) by # default; only read behind `&&` (the double-tap rewind chord window). @echo = echo @on_interrupt = on_interrupt # @on_busy_command classifies a line typed mid-turn so a read-only/control # meta-command runs NOW (Executor#busy_disposition); a state-mutating one # gets a transient notice; free text queues. nil ⇒ legacy queue-all. @on_busy_command = on_busy_command # Per-session paste store (file-backed paste pipeline). nil ⇒ inline # pastes, the exact legacy behavior. @paste_store = paste_store # Shared (or private) stack of EXPLICITLY-queued messages, rendered as # "⏳ queued: <msg>" rows above the input while pending. @queued = QueuedIndicators.new(pending_queued || []) # Shared completion discovery (slash commands + @file picker) extracted # from LineInput. nil ⇒ the `/`+`@` completion menu is inert (steering / # standalone use), so the composer degrades to a plain editor. Kept for # the token highlight; the dropdown itself lives in the CompletionMenu. @completion = completion_source # History ring, backed by Reline::HISTORY by default for continuity with # the old idle prompt. nil keeps a private ring (tests / standalone). @history = history || InputHistory.new # The /command + @file dropdown: open/refine/accept/dismiss state and # the rendered rows (see CompletionMenu). Inert without a source. @menu = CompletionMenu.new(completion_source) # Escape-sequence reader: consumes the byte tail of an ESC keystroke # from @input and returns the semantic action (see EscapeReader). The # callable indirection keeps it on the composer's CURRENT input. @escapes = EscapeReader.new(-> { @input }) @prompt = prompt.to_s.empty? ? PROMPT : prompt # The brand rail (red "▍"): the first column of EVERY input row. # Empty ⇒ railless, the exact legacy geometry. @rail = (rail || "").to_s # Visible widths ignore ANSI color escapes so the wrap math is # correct for a colored rail/prompt. @prefix_width is the column the # input text starts in on EVERY row (rail + prompt on the first, # rail + hanging indent on continuations) — all caret/wrap math # anchors to it. @prompt_width = @prompt.gsub(ANSI_RE, "").length @prefix_width = @rail.gsub(ANSI_RE, "").length + @prompt_width @buffer = +"" # Insertion point, measured in CHARACTERS (codepoints) into @buffer. # Always in 0..@buffer.length; the terminal cursor is parked here on # every redraw. Replaces the old append-only model. @cursor = 0 @partial = +"" # live, un-committed streamed line shown above the prompt # TRANSIENT announcement row (e.g. the Shift+Tab mode confirmation): # rendered in the live region directly above the partial/prompt, redrawn # in place every frame and NEVER committed to scrollback. Cleared on the # next keystroke so it reads as a one-shot toast, not stacking scrollback # (D3). Empty ⇒ no row. @announce = +"" # True only while the model's ANSWER content is actively streaming (set by # the CLI's stream/stream_end lifecycle, NOT the thinking phase — commits # during thinking land cleanly above the partial). Gates the Ctrl+O reveal # so it never bisects a streaming answer (D1). @content_streaming = false # True for the WHOLE turn — from the moment the chat loop hands a prompt to # the runner until the turn fully unwinds — including the THINKING phase # that precedes the first content token. Set/cleared by the chat loop's # run_turn bracket (#begin_turn / #end_turn). A "queued ▸" type-ahead echo # is deferred whenever a turn is active (thinking OR content streaming), not # only when content is streaming: a line submitted while the model is still # THINKING would otherwise commit its echo ABOVE the thought line and the # whole answer (D7e). nil/false ⇒ idle, immediate echo as before. @turn_active = false # A reveal (Ctrl+O) requested WHILE content was streaming, queued to flush # once the stream ends so the `┊` aside renders cleanly AFTER the answer # instead of between chunks (D1). nil ⇒ nothing deferred. @deferred_reveal = false # Subagent CARD block (Variant A): zero or more collapsed live rows shown # ABOVE the streamed partial and the prompt, redrawn in place each frame. # Driven by UI::CLI#set_subagent_cards from the BackgroundTasks registry. @cards = [] # The live-region renderer: owns the count of rows currently drawn ABOVE # the prompt and the scroll-safe erase→commit→redraw frame discipline # (see LiveRegion). @region = LiveRegion.new(output) # The dim status line pinned BELOW the input block (model + context # saturation). Drawn as the live region's LAST row on every frame; # empty ⇒ no bar (one fewer row). Updated via #set_status at turn # boundaries only — it rides the existing redraws, never repaints on # its own per stream delta. @status = (status_line || "").to_s # Input-block geometry: the visual-row cap and the vertical scroll # offset (top visible layout row) once the buffer outgrows the cap. @max_input_rows = positive_int(max_input_rows) || MAX_INPUT_ROWS @input_scroll = 0 @render = Mutex.new @reader = nil @stop_pipe = nil # self-pipe write end used to wake the reader's select @running = false @suspended = false @saved_stdout = nil @cols = compute_cols end |
Class Attribute Details
.current ⇒ Object
Returns the value of attribute current.
311 312 313 |
# File 'lib/rubino/ui/bottom_composer.rb', line 311 def current @current end |
Instance Attribute Details
#buffer ⇒ Object (readonly)
The current editable buffer (test/inspection helper).
736 737 738 |
# File 'lib/rubino/ui/bottom_composer.rb', line 736 def buffer @buffer end |
#cards ⇒ Object (readonly)
The card rows currently shown (test/inspection helper).
679 680 681 |
# File 'lib/rubino/ui/bottom_composer.rb', line 679 def cards @cards end |
#output ⇒ Object (readonly)
The REAL terminal IO captured before the StdoutProxy swap. UI::Notifier rings the attention bell here while a turn owns the screen — BEL never moves the cursor, so it can’t disturb the pinned input block.
684 685 686 |
# File 'lib/rubino/ui/bottom_composer.rb', line 684 def output @output end |
Class Method Details
.active?(input: $stdin, output: $stdout) ⇒ Boolean
True only when both ends are real TTYs. Off this path the composer is a no-op and the caller falls back to the plain (cooked, no-proxy) flow —piped / -q / server input must not touch terminal modes.
300 301 302 303 304 |
# File 'lib/rubino/ui/bottom_composer.rb', line 300 def self.active?(input: $stdin, output: $stdout) input.tty? && output.tty? rescue StandardError false end |
.run_in_terminal ⇒ Object
Run block with the REAL terminal restored — the Ruby equivalent of prompt_toolkit’s run_in_terminal. When a composer owns the screen for the current turn, PAUSE it (stop the raw reader, restore $stdout to the real IO, leave cooked mode, clear the prompt rows) for the duration of the block, then RESUME it (re-enter raw mode, restart the reader, redraw the preserved buffer). With no active composer it just yields. This is what lets a mid-turn TTY::Prompt (approval / ask) read the real $stdin and let tty-screen probe the real $stdout’s size, instead of crashing on the write-only StdoutProxy or racing the reader thread for $stdin.
323 324 325 326 327 328 329 330 331 332 333 |
# File 'lib/rubino/ui/bottom_composer.rb', line 323 def self.run_in_terminal composer = current return yield unless composer composer.suspend begin yield ensure composer.resume end end |
Instance Method Details
#announce(text) ⇒ Object
Sets the TRANSIENT announcement row (the Shift+Tab mode confirmation). It renders in the live region above the prompt and is redrawn in place —cycling N times REPLACES it, never stacks — and is cleared on the next keystroke, so it leaves ZERO committed scrollback lines (D2/D3). An empty/nil string clears it. Must NOT be routed through print_above.
582 583 584 585 586 587 |
# File 'lib/rubino/ui/bottom_composer.rb', line 582 def announce(text) @render.synchronize do @announce = (text || "").to_s redraw end end |
#announce_pending(text) ⇒ Object
TRAP-SAFE announce for the during-turn Ctrl+C double-tap hint (#426). A SIGINT trap MUST NOT take the render mutex (Mutex#lock is forbidden in trap context) and MUST NOT do a raw scrolling $stderr write either: the old trap wrote “n(press Ctrl+C again to exit)n” straight to the terminal, scrolling the live region by two rows OUTSIDE LiveRegion’s row accounting. On a very-early interrupt — while the answer’s first line is still a RAW live-tail preview — that desynced @rows_above so the finalize commit’s e[1A walk-up landed one row short: the raw preview survived in scrollback above the rendered (curly) line and the prompt committed as a ghost ‘❯` (Bug B, same #265/#421 geometry-desync family). Here we only ASSIGN @announce (one atomic reference store, no mutex, no output) and let the NEXT mutex-held frame — the interrupt’s finalize redraw — paint it as an in-place transient row that never scrolls. The hint is cleared on the next keystroke like any other announce.
603 604 605 |
# File 'lib/rubino/ui/bottom_composer.rb', line 603 def announce_pending(text) @announce = (text || "").to_s end |
#begin_content_stream ⇒ Object
Marks the start of an ACTIVE content stream (called by the CLI when the first answer token arrives). The thinking phase does NOT set this, so a footer/aside that commits during thinking still lands cleanly above.
531 532 533 |
# File 'lib/rubino/ui/bottom_composer.rb', line 531 def begin_content_stream @content_streaming = true end |
#begin_turn ⇒ Object
Marks the START of a turn — the chat loop’s run_turn calls this when it hands a prompt to the runner. From here through #end_turn the composer is “in a turn” (the THINKING phase AND the content stream), so a “queued ▸” type-ahead echo is deferred for the WHOLE turn, not only while content streams (D7e). Idempotent.
556 557 558 559 560 561 562 |
# File 'lib/rubino/ui/bottom_composer.rb', line 556 def begin_turn @turn_active = true # Repaint so the "(esc to interrupt)" affordance (#421) appears in the # status row for the whole turn. Guarded: dropped while suspended, like # every other live repaint. @render.synchronize { redraw } unless @suspended end |
#caret_position(rows) ⇒ Object
The caret’s [visual_row, display_col] within a layout. The owning row is the LAST one starting at-or-before @cursor: a caret exactly on a WRAP boundary therefore lands on the wrapped row (where the next char will print), while a caret on a “n” stays at the END of the broken row (the next row starts one past the newline) — the readline feel.
781 782 783 784 785 786 787 788 789 790 791 792 793 |
# File 'lib/rubino/ui/bottom_composer.rb', line 781 def caret_position(rows) idx = rows.rindex { |r| @cursor >= r[:start] } || 0 row = rows[idx] # Every row's text hangs at the prefix width (P12), so the caret # column starts there on continuation rows too. col = @prefix_width row[:chars].each_with_index do |ch, j| break if row[:start] + j >= @cursor col += display_width(ch) end [idx, col] end |
#commit_queued(msg) ⇒ Object
Remove the FIRST pending “⏳ queued:” indicator matching msg (public: the chat loop calls this when the queued item’s turn starts, so the indicator disappears from above the input as the item is committed as a normal message). Operates on the shared pending list, so it works from whichever composer is current. Returns true if one was removed.
507 508 509 510 511 512 513 514 |
# File 'lib/rubino/ui/bottom_composer.rb', line 507 def commit_queued(msg) removed = false @render.synchronize do removed = !@queued.remove(msg).nil? redraw if removed end removed end |
#draw_input ⇒ Object
Redraws the INPUT BLOCK — the wrapped buffer rows plus the status bar —and parks the terminal cursor at the insertion point (@cursor). The buffer WRAPS at the terminal width (a real newline forces a row break), growing the block downward up to @max_input_rows visual rows; past the cap a vertical window keeps the caret row in view. The block manages its own erase: the previous frame’s rows (recorded in the LiveRegion as input geometry) are cleared first, so a shrinking buffer never leaves stale rows, and the cheap keystroke path stays correct without a full live-region frame. All caret repositioning happens AFTER the last byte is printed, so a natural scroll while the block grows at the bottom of the screen can never desync the relative moves. Must be called under
704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 |
# File 'lib/rubino/ui/bottom_composer.rb', line 704 def draw_input rows, caret_row, caret_col = visible_input_rows status = status_row @region.clear_input_block rows.each_with_index do |row, i| @output.print("\r\e[2K#{row}") @output.print("\r\n") if i < rows.length - 1 || status end @output.print("\r\e[2K#{status}") if status below = (rows.length - 1 - caret_row) + (status ? 1 : 0) park_caret(rows, caret_col, below) @region.input_drawn(above: caret_row, below: below) @output.flush end |
#end_content_stream ⇒ Object
Marks the end of the content stream (CLI stream_end / finalize). Flushes the Ctrl+O reveal (‘┊` aside) deferred during the stream so it renders AFTER the finished answer block instead of between its chunks — the reveal belongs to the JUST-finished answer, so it lands right after the contiguous answer and BEFORE the turn-summary footer (D1). The “queued ▸” type-ahead echoes are NOT flushed here: they belong to the NEXT input the user lined up, so they flush at TURN END (#end_turn), after the footer, so the order reads answer → reveal → `↳ turn` footer → `queued ▸` echo(es) (D7a-c).
543 544 545 546 547 548 549 |
# File 'lib/rubino/ui/bottom_composer.rb', line 543 def end_content_stream @content_streaming = false return unless @deferred_reveal @deferred_reveal = false @on_ctrl_o&.call end |
#end_turn ⇒ Object
Marks the END of a turn — the chat loop’s run_turn ‘ensure` calls this AFTER the runner has fully unwound (so the turn-summary footer is already in scrollback). Idempotent. (The “queued ▸” deferred-echo flush that used to live here is retired: in the interrupt-by-default model a mid-turn Enter interrupts and runs next, and an explicit queue shows a live “⏳ queued:” indicator instead of a post-footer echo.)
570 571 572 573 574 575 |
# File 'lib/rubino/ui/bottom_composer.rb', line 570 def end_turn @turn_active = false # Repaint so the "(esc to interrupt)" affordance (#421) clears from the # status row once the turn ends. Guarded like every other live repaint. @render.synchronize { redraw } unless @suspended end |
#finalize_region ⇒ Object
Row-accurately ERASE the whole live region in place and reset its on-screen geometry to a clean blank top row — used by the stream FINALIZE / INTERRUPT / force-summary paths right before they commit their last line (#421). The interrupt/force-summary repaints run after the status-row ticker and a flurry of intermediate transient frames (status_hide → clear_stream_region → status_stop, each a paint_live(“”)) have left the region’s recorded geometry out of step with the physical rows — the ticker paints a status row that #live_rows does NOT include, so @rows_above under-counts and the next #print_above’s relative e[1Ae[2K walk-up clears one row short: the live prompt is left on screen and gets COMMITTED into scrollback as the ghost ‘❯` above the `⎿ interrupted` marker, and the kept partial / whole summary block repaints a second time below it (the duplicated block). LiveRegion#clear walks UP exactly the rows it last painted and zeroes the counters, so the subsequent commit lands as ONE clean frame from a known-blank top row —the same geometry-reset discipline Ctrl+L (#395) / resize (#401) use, applied to the finalize path. Drops the partial too so a stale tail can’t repaint. A no-op-safe single frame: nothing is committed here, only the transient rows are erased and the prompt redrawn fresh.
452 453 454 455 456 457 458 |
# File 'lib/rubino/ui/bottom_composer.rb', line 452 def finalize_region @render.synchronize do @partial = +"" @region.clear redraw end end |
#handle_key(ch) ⇒ Object
Feeds a single character through the edit logic. Public so the PTY/unit tests can drive editing without a live raw read. Returns :submit when the key committed a line, :quit on EOF/empty-Ctrl+D, otherwise nil.
The buffer is edited at @cursor (a codepoint index), so insert/delete and the arrow/Home/End/word-jump moves all act mid-line, not just at the end.
897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 |
# File 'lib/rubino/ui/bottom_composer.rb', line 897 def handle_key(ch) # The transient mode announcement is a one-shot toast: any keystroke # clears it (a fresh Shift+Tab re-sets it below via #cycle_mode). It lives # only in the live region, so this never touches scrollback (D2/D3). clear_announce case ch when nil return :quit when "\r", "\n" # Enter while a completion menu is open ACCEPTS the highlighted # candidate rather than submitting (matches the old Reline dropdown) — # UNLESS the buffer is ALREADY an exact, complete command, in which # case Enter SUBMITS it directly instead of splicing a trailing space # and requiring a second Enter (D5). if && !@menu.exact_command?(@buffer) accept_completion return nil end submit_line return :submit when "\t" # Tab: accept the menu selection, or open the menu if a token is typed. handle_tab when "", "\b" # DEL / Backspace: delete the char BEFORE the cursor. delete_back when "\x04" # Ctrl+D: delete forward; on an empty buffer it's EOF/quit. return :quit if @buffer.empty? delete_forward when "\x01" then move_to(0) # Ctrl+A → line start when "\x05" then move_to(@buffer.length) # Ctrl+E → line end when "\x02" then move_by(-1) # Ctrl+B → left when "\x06" then move_by(1) # Ctrl+F → right when "\x0b" then kill_to_end # Ctrl+K → delete to end of line when "\x15" then kill_to_start # Ctrl+U → delete to start of line when "\x0f" # Ctrl+O: reveal the last retained reasoning aside. request_reveal when "\x0c" # Ctrl+L: clear the screen and redraw the prompt in place. clear_screen when "\e" # ESC: start of a CSI/SS3 escape (arrows, Home/End, word-jump, # Shift+Tab, bracketed paste) OR a lone ESC that dismisses the menu. consume_escape_sequence else insert(ch) if printable?(ch) # Other control bytes (incl. \x03 Ctrl+C, which the kernel turns into # SIGINT before it reaches here under raw(intr: true)) are ignored. end nil end |
#idle_interrupt(window: 2.0) ⇒ Object
Handle a Ctrl+C pressed at the IDLE prompt (BH-2). Mirrors the industry norm (Claude Code / Codex / readline) and the during-turn double-tap so a single Ctrl+C never silently discards a typed draft:
* buffer NON-EMPTY → CLEAR the line (and any open completion menu) and
stay (returns :cleared). The draft-clear resets the exit timer, so a
subsequent empty Ctrl+C starts the two-tap exit fresh.
* buffer EMPTY, first tap → show a transient "(press Ctrl+C again to
exit)" hint and stay (returns :hint).
* buffer EMPTY, second tap within +window+ seconds → exit (returns
:exit); the caller ends the session.
Called by the idle reader OUTSIDE trap context (the SIGINT trap only flips a flag — Mutex#lock is forbidden in a trap), so the render mutex is safe here. window is the double-tap window in seconds (the chat loop passes its DOUBLE_TAP_SECONDS so idle and in-turn behave identically).
638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 |
# File 'lib/rubino/ui/bottom_composer.rb', line 638 def idle_interrupt(window: 2.0) now = Process.clock_gettime(Process::CLOCK_MONOTONIC) unless @buffer.empty? @last_idle_int_at = nil @render.synchronize do @menu.close! @buffer.clear @cursor = 0 @announce = +"" redraw end return :cleared end return :exit if @last_idle_int_at && (now - @last_idle_int_at) <= window @last_idle_int_at = now announce("(press Ctrl+C again to exit)") :hint end |
#interrupt_hint ⇒ Object
The dim “(esc to interrupt)” type-ahead affordance shown in the status row while a turn is active (#421). Memoized — it never changes.
883 884 885 |
# File 'lib/rubino/ui/bottom_composer.rb', line 883 def interrupt_hint @interrupt_hint ||= pastel.dim(ESC_INTERRUPT_HINT) end |
#layout_input ⇒ Object
Lays out @buffer into wrapped VISUAL rows at the current width. Returns [rows, caret_row, caret_col] where each row is { chars:, start:, prompt: } — its codepoints, the buffer index of its first char, and whether it carries the prompt prefix (only the first) —and caret_row/caret_col locate the insertion point (col in DISPLAY columns from the screen’s left edge, so the caret column is comparable across rows for ↑/↓ navigation). A real “n” forces a row break; a char that would overflow the per-row budget wraps whole (wide glyphs are never split across rows). The caret is placed where the NEXT typed char will land.
Continuation rows (wrap or “n”) carry a HANGING INDENT of the prefix width (P12): every row’s text starts in the same column as the first row’s — after the rail + prompt — instead of dropping flush-left to column 0. The indent is pure layout (rail + spaces on render, width here) — never buffer content.
754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 |
# File 'lib/rubino/ui/bottom_composer.rb', line 754 def layout_input budget = row_budget rows = [{ chars: [], start: 0, prompt: true }] width = @prefix_width @buffer.each_char.with_index do |ch, i| if ch == "\n" rows << { chars: [], start: i + 1, prompt: false } width = @prefix_width next end w = display_width(ch) if width + w > budget rows << { chars: [], start: i, prompt: false } width = @prefix_width end rows.last[:chars] << ch width += w end [rows, *caret_position(rows)] end |
#menu_open? ⇒ Boolean
True when the /command + @file completion menu is open (inspection helper; the reader/specs check it to branch Tab/Enter/Esc handling).
688 689 690 |
# File 'lib/rubino/ui/bottom_composer.rb', line 688 def @menu.open? end |
#park_caret(rows, caret_col, below) ⇒ Object
Park the terminal cursor at the caret after the block is fully printed (relative moves are only safe once nothing else will scroll): walk up past the rows below the caret row, re-home, and step right to the caret column. Skipped entirely when printing already left the cursor there — the caret at the end of a frame’s last row, the common typing case — so those frames end with the buffer text, byte-minimal.
727 728 729 730 731 732 733 |
# File 'lib/rubino/ui/bottom_composer.rb', line 727 def park_caret(rows, caret_col, below) return if below.zero? && caret_col == display_width(rows.last.gsub(ANSI_RE, "")) @output.print("\e[#{below}A") if below.positive? @output.print("\r") @output.print("\e[#{caret_col}C") if caret_col.positive? end |
#partial? ⇒ Boolean
True when a live partial line is currently shown above the prompt.
517 518 519 |
# File 'lib/rubino/ui/bottom_composer.rb', line 517 def partial? !@partial.empty? end |
#pastel ⇒ Object
887 888 889 |
# File 'lib/rubino/ui/bottom_composer.rb', line 887 def pastel @pastel ||= Pastel.new end |
#prefill(text) ⇒ Object
Replaces the editable buffer with text — MULTILINE-SAFE: real newlines stay in the buffer and render as real row breaks, exactly like a bracketed paste — parking the caret at the end, ready to edit. Used by the Esc-Esc rewind to pre-fill the picked message for edit-and-resend. Any open completion menu is closed (the text is a finished message, not a token being typed; typing afterwards reopens it via the normal auto-update) and history navigation resets so a fresh ↑ starts from the newest entry. nil/empty clears the buffer.
668 669 670 671 672 673 674 675 676 |
# File 'lib/rubino/ui/bottom_composer.rb', line 668 def prefill(text) @render.synchronize do @menu.close! @buffer.replace(text.to_s) @cursor = @buffer.length @history.reset! redraw end end |
#print_above(str) ⇒ Object
Commits one block of output ABOVE the input line — it scrolls up into native scrollback — then redraws the prompt. This is THE coordinator every finished above-the-prompt write goes through (StdoutProxy routes committed lines here). str may contain embedded newlines; each line is emitted with a trailing “rn” because OPOST is off in raw mode (a bare “n” would not return the carriage and the next line would stair-step). Any live streamed partial is cleared first so it doesn’t duplicate. A nil str just repaints the prompt; an EMPTY string commits one deliberate blank row (the P3 rhythm gaps — see LiveRegion#commit).
426 427 428 429 430 431 |
# File 'lib/rubino/ui/bottom_composer.rb', line 426 def print_above(str) @render.synchronize do @partial = +"" render_frame(committed: str) end end |
#resize ⇒ Object
Recomputes width from the terminal and redraws under the mutex. Public so the SIGWINCH handler (trap-context) and tests can call it.
Redraws the WHOLE live region (the in-progress streamed @partial AND the prompt), not just the prompt: on resize xterm reflows/clears the bottom rows, so repainting only the prompt left the live streaming line blank until the turn committed (X1). Repainting the partial at the new width keeps mid-stream output visible across a resize. Committed scrollback is untouched (the terminal reflows it natively).
956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 |
# File 'lib/rubino/ui/bottom_composer.rb', line 956 def resize @render.synchronize do @cols = compute_cols # Forget the on-screen row geometry BEFORE redrawing (#401). The # @rows_above / @input_above / @input_below counts were recorded at the # OLD column count; on a resize the terminal reflows the wrapped input # (and partial) into a DIFFERENT number of physical rows, so the next # frame's relative \e[1A\e[2K walk-up would clear the wrong row count — # under-clearing leaves the stale copy on screen and the fresh redraw # appends BELOW it, so every reflow stacked another copy of the input # into scrollback (~20× on a 200→70 drag). The terminal already # reflows the bottom rows itself, so zeroing the counters (the same # seam Ctrl+L uses, {LiveRegion#reset_geometry!}) lets the redraw draw # ONE fresh frame over the reflowed copy instead of walking stale rows. @region.reset_geometry! # Repaint the FULL live region (cards + menu + partial + prompt) when # anything above the prompt is live, reusing the same atomic frame the # streaming writer uses; a bare draw_input would repaint only the # prompt and leave the reflowed partial/card rows blank until the turn # committed (X1). With nothing live above the prompt the cheap # prompt-only redraw is enough. Same gate as every other repaint # (#redraw → #live_region?), so the two paths can never drift again. redraw end rescue StandardError nil end |
#resume ⇒ Object
RESUME after #suspend: restore the StdoutProxy, re-enter raw mode, restart the reader, and redraw the input line from the preserved buffer.
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 |
# File 'lib/rubino/ui/bottom_composer.rb', line 399 def resume return unless @suspended @suspended = false $stdout = @saved_stdout if @saved_stdout @saved_stdout = nil install_winch_trap install_cont_trap @render.synchronize do @output.print(PASTE_ON) draw_input end @reader = start_reader self rescue IOError, Errno::ENOTTY, Errno::EIO nil end |
#row_budget ⇒ Object
The display columns available per input row: one short of the width so a glyph in the final column never arms the terminal’s deferred auto-wrap (the same rule LiveRegion#emit_row applies). Guarded so a degenerate narrow terminal still fits at least one char after the prompt instead of looping.
800 801 802 |
# File 'lib/rubino/ui/bottom_composer.rb', line 800 def row_budget [@cols - 1, @prefix_width + 1].max end |
#set_cards(lines) ⇒ Object
Sets the SUBAGENT CARD block — a small list of collapsed live rows shown above the streamed partial and the prompt (Variant A). Each frame redraws them in place from this list, so concurrent background subagents appear as a calm stack of one-liners that update without scrolling. An empty/nil list clears the block. Redraws under the same render mutex every other live write uses, so a card update from the parent never interleaves a half-frame with a streamed token or a keystroke. The list is clamped to a sane bound by the caller (UI::SubagentCards), but we also cap it here so a buggy caller can never grow the live region past the screen.
487 488 489 490 491 492 493 494 495 496 497 498 499 500 |
# File 'lib/rubino/ui/bottom_composer.rb', line 487 def set_cards(lines) # While SUSPENDED (run_in_terminal: an approval/ask owns the real # terminal) a card repaint here would draw straight over the # interactive prompt and can abort its blocked TTY read (#144). Drop # the frame, like #set_partial — the cards converge from the registry # snapshot on the next repaint after #resume. return if @suspended capped = Array(lines).first(MAX_CARD_ROWS) @render.synchronize do @cards = capped render_frame(committed: nil) end end |
#set_partial(str) ⇒ Object
Renders a LIVE, un-committed streamed line on the row directly above the prompt, redrawn in place as it grows (it does NOT scroll). Used by the StdoutProxy for partial stream tokens that have no newline yet, so the in-progress line appears live and grows in place — like prompt_toolkit batching a partial line. #print_above (a committed line) clears it.
465 466 467 468 469 470 471 472 473 474 475 476 |
# File 'lib/rubino/ui/bottom_composer.rb', line 465 def set_partial(str) # While SUSPENDED (run_in_terminal: an approval/ask owns the real # terminal) a live repaint here would draw the partial + prompt rows # straight over the interactive prompt. Drop the frame — the next # #resume redraws the region and the ticker's next frame lands normally. return if @suspended @render.synchronize do @partial = (str || "").to_s render_frame(committed: nil) end end |
#set_status(text) ⇒ Object
Updates the status bar pinned below the input (model + context saturation — see StatusBar) and repaints in place. Called at TURN BOUNDARIES only (after the footer / on session resume), never per stream delta, so the bar can’t busy-repaint. nil/empty clears the bar (its row disappears on the next frame). Dropped while suspended, like every other live repaint — the next #resume redraws.
613 614 615 616 617 618 619 620 |
# File 'lib/rubino/ui/bottom_composer.rb', line 613 def set_status(text) return if @suspended @render.synchronize do @status = (text || "").to_s redraw end end |
#start ⇒ Object
Starts the keystroke reader thread and draws the initial prompt. Installs a SIGWINCH handler that recomputes the width and redraws under the mutex. Returns self.
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'lib/rubino/ui/bottom_composer.rb', line 338 def start return self if @running @running = true self.class.current = self install_winch_trap install_cont_trap @render.synchronize do # Leave a blank row above the first prompt so the first above-output # doesn't glue onto whatever the REPL just printed. @output.print(PASTE_ON) @output.print("\r\n") draw_input end @reader = start_reader self end |
#status_row ⇒ Object
The status-bar row for this frame, or nil when there is no bar: the status text is empty, the terminal is too narrow to be useful, or the styled line wouldn’t fit the row (omit whole rather than truncate mid-ANSI — a cut escape sequence would leak attributes into the terminal).
While a turn is active (thinking OR streaming) the row also carries the type-ahead AFFORDANCE — a dim “(esc to interrupt)” hint (#421) — so the user can see that Esc cancels the current turn (Enter now QUEUES). The hint is appended only when the styled status line is present and the combined plain width still fits; it never replaces the bar.
864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 |
# File 'lib/rubino/ui/bottom_composer.rb', line 864 def status_row return nil if @cols < MIN_STATUS_COLS if (@turn_active || @content_streaming) && @on_interrupt return interrupt_hint if @status.empty? combined = "#{@status} #{interrupt_hint}" return combined if display_width(combined.gsub(ANSI_RE, "")) <= @cols - 1 # The combined line overflows — keep the bar, drop the (cosmetic) hint. end return nil if @status.empty? return nil if display_width(@status.gsub(ANSI_RE, "")) > @cols - 1 @status end |
#stop ⇒ Object
Stops the reader thread, restores cooked mode, and leaves the cursor on a fresh line so the next REPL prompt isn’t glued to the input line. Safe to call multiple times. Restores the previous SIGWINCH handler.
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 |
# File 'lib/rubino/ui/bottom_composer.rb', line 359 def stop return unless @running @running = false self.class.current = nil if self.class.current.equal?(self) stop_reader restore_winch_trap restore_cont_trap # Raw mode must never leak past the turn, even if the block-form restore # was interrupted. Best-effort. @input.cooked! if tty? @render.synchronize { clear_live_region_to_clean_line } rescue IOError, Errno::ENOTTY, Errno::EIO nil end |
#streaming? ⇒ Boolean
True while the model’s ANSWER content is actively streaming. The CLI’s stream lifecycle toggles this (begin/end below); the keystroke handler reads it to defer the Ctrl+O reveal so it never bisects the answer (D1).
524 525 526 |
# File 'lib/rubino/ui/bottom_composer.rb', line 524 def streaming? @content_streaming end |
#suspend ⇒ Object
PAUSE the composer so an interactive prompt can own the real terminal (see run_in_terminal). Stops the raw reader and leaves cooked mode so TTY::Prompt can read $stdin uncontended, restores the REAL $stdout (the composer’s @output — built BEFORE the StdoutProxy swap) so tty-screen probes the real terminal, and clears the prompt rows. The typed @buffer draft is preserved for #resume. Idempotent: a no-op once already suspended (or never started).
382 383 384 385 386 387 388 389 390 391 392 393 394 395 |
# File 'lib/rubino/ui/bottom_composer.rb', line 382 def suspend return unless @running && !@suspended @suspended = true @saved_stdout = $stdout $stdout = @output stop_reader restore_winch_trap restore_cont_trap @input.cooked! if tty? @render.synchronize { clear_live_region_to_clean_line } rescue IOError, Errno::ENOTTY, Errno::EIO nil end |
#visible_input_rows ⇒ Object
The PRINTED input rows for this frame plus the caret position within them: the layout, windowed to @max_input_rows when the buffer outgrows the cap (the window follows the caret row minimally, like a scrolling viewport), each row rendered to its final string (prompt prefix + token highlight on a single-row buffer; plain continuation rows).
809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 |
# File 'lib/rubino/ui/bottom_composer.rb', line 809 def visible_input_rows rows, caret_row, caret_col = layout_input if rows.length > @max_input_rows top = @input_scroll.clamp(0, rows.length - @max_input_rows) top = caret_row if caret_row < top top = caret_row - @max_input_rows + 1 if caret_row > top + @max_input_rows - 1 @input_scroll = top rows = rows[top, @max_input_rows] caret_row -= top else @input_scroll = 0 end single = rows.length == 1 && rows.first[:prompt] # The rail leads EVERY row; continuations hang-indent under the text # start (P12), so the indent fills the prompt columns after the rail. indent = "#{@rail}#{" " * @prompt_width}" texts = rows.map do |row| body = row[:chars].join rendered = if row[:prompt] "#{@rail}#{@prompt}#{single ? highlight_line(body) : body}" else # Hanging indent (P12): continuations align under the text start. "#{indent}#{body}" end # Fit each rendered row to one PHYSICAL terminal line (TUI-2): the # wrap math in #layout_input already breaks on display width, but a # wide CJK/emoji glyph at the wrap boundary — or a degenerate narrow # width where the prefix alone is wider than the budget — can still # leave a rendered row at @cols (or past it) display columns. Such a # row arms the terminal's deferred auto-wrap and spills onto a SECOND # physical line that the input-block clear (which walks the LOGICAL # row count from #input_drawn) never erases, so each redraw stacked # another ghost "❯ …" row that only Ctrl+L cleared. Clamping to one # column short of the width keeps logical rows == physical rows so the # clear math stays exact. ASCII never tripped this (every glyph is one # column); wide-char narrow input did. fit_row(rendered) end [texts, caret_row, caret_col] end |