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 "- 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).
-
#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).
-
#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) ⇒ BottomComposer
constructor
A new instance of BottomComposer.
-
#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.
-
#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) ⇒ BottomComposer
Returns a new instance of BottomComposer.
169 170 171 172 173 174 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 |
# File 'lib/rubino/ui/bottom_composer.rb', line 169 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) @input_queue = input_queue @input = input @output = output @on_ctrl_o = on_ctrl_o @on_mode_cycle = on_mode_cycle @on_double_esc = on_double_esc # Monotonic time of the last LONE Esc (nil when unarmed) — the # double-tap window the Esc-Esc rewind chord measures against. @last_esc_at = nil @echo = echo @on_interrupt = on_interrupt # 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. @rail_width = @rail.gsub(ANSI_RE, "").length @prompt_width = @prompt.gsub(ANSI_RE, "").length @prefix_width = @rail_width + @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.
290 291 292 |
# File 'lib/rubino/ui/bottom_composer.rb', line 290 def current @current end |
Instance Attribute Details
#buffer ⇒ Object (readonly)
The current editable buffer (test/inspection helper).
659 660 661 |
# File 'lib/rubino/ui/bottom_composer.rb', line 659 def buffer @buffer end |
#cards ⇒ Object (readonly)
The card rows currently shown (test/inspection helper).
602 603 604 |
# File 'lib/rubino/ui/bottom_composer.rb', line 602 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.
607 608 609 |
# File 'lib/rubino/ui/bottom_composer.rb', line 607 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.
279 280 281 282 283 |
# File 'lib/rubino/ui/bottom_composer.rb', line 279 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.
302 303 304 305 306 307 308 309 310 311 312 |
# File 'lib/rubino/ui/bottom_composer.rb', line 302 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.
523 524 525 526 527 528 |
# File 'lib/rubino/ui/bottom_composer.rb', line 523 def announce(text) @render.synchronize do @announce = (text || "").to_s redraw end 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.
479 480 481 |
# File 'lib/rubino/ui/bottom_composer.rb', line 479 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.
504 505 506 |
# File 'lib/rubino/ui/bottom_composer.rb', line 504 def begin_turn @turn_active = true 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.
704 705 706 707 708 709 710 711 712 713 714 715 716 |
# File 'lib/rubino/ui/bottom_composer.rb', line 704 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.
455 456 457 458 459 460 461 462 |
# File 'lib/rubino/ui/bottom_composer.rb', line 455 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
627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 |
# File 'lib/rubino/ui/bottom_composer.rb', line 627 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).
491 492 493 494 495 496 497 |
# File 'lib/rubino/ui/bottom_composer.rb', line 491 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.)
514 515 516 |
# File 'lib/rubino/ui/bottom_composer.rb', line 514 def end_turn @turn_active = false 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.
780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 |
# File 'lib/rubino/ui/bottom_composer.rb', line 780 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 "\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).
561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 |
# File 'lib/rubino/ui/bottom_composer.rb', line 561 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 |
#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.
677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 |
# File 'lib/rubino/ui/bottom_composer.rb', line 677 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).
611 612 613 |
# File 'lib/rubino/ui/bottom_composer.rb', line 611 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.
650 651 652 653 654 655 656 |
# File 'lib/rubino/ui/bottom_composer.rb', line 650 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.
465 466 467 |
# File 'lib/rubino/ui/bottom_composer.rb', line 465 def partial? !@partial.empty? 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.
591 592 593 594 595 596 597 598 599 |
# File 'lib/rubino/ui/bottom_composer.rb', line 591 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).
401 402 403 404 405 406 |
# File 'lib/rubino/ui/bottom_composer.rb', line 401 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).
837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 |
# File 'lib/rubino/ui/bottom_composer.rb', line 837 def resize @render.synchronize do @cols = compute_cols # 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.
375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 |
# File 'lib/rubino/ui/bottom_composer.rb', line 375 def resume return unless @suspended @suspended = false $stdout = @saved_stdout if @saved_stdout @saved_stdout = nil install_winch_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.
723 724 725 |
# File 'lib/rubino/ui/bottom_composer.rb', line 723 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.
435 436 437 438 439 440 441 442 443 444 445 446 447 448 |
# File 'lib/rubino/ui/bottom_composer.rb', line 435 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.
413 414 415 416 417 418 419 420 421 422 423 424 |
# File 'lib/rubino/ui/bottom_composer.rb', line 413 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.
536 537 538 539 540 541 542 543 |
# File 'lib/rubino/ui/bottom_composer.rb', line 536 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.
317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 |
# File 'lib/rubino/ui/bottom_composer.rb', line 317 def start return self if @running @running = true self.class.current = self install_winch_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).
767 768 769 770 771 772 |
# File 'lib/rubino/ui/bottom_composer.rb', line 767 def status_row return nil if @status.empty? || @cols < MIN_STATUS_COLS 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.
337 338 339 340 341 342 343 344 345 346 347 348 349 350 |
# File 'lib/rubino/ui/bottom_composer.rb', line 337 def stop return unless @running @running = false self.class.current = nil if self.class.current.equal?(self) stop_reader restore_winch_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).
472 473 474 |
# File 'lib/rubino/ui/bottom_composer.rb', line 472 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).
359 360 361 362 363 364 365 366 367 368 369 370 371 |
# File 'lib/rubino/ui/bottom_composer.rb', line 359 def suspend return unless @running && !@suspended @suspended = true @saved_stdout = $stdout $stdout = @output stop_reader restore_winch_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).
732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 |
# File 'lib/rubino/ui/bottom_composer.rb', line 732 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 if row[:prompt] "#{@rail}#{@prompt}#{single ? highlight_line(body) : body}" else # Hanging indent (P12): continuations align under the text start. "#{indent}#{body}" end end [texts, caret_row, caret_col] end |